From 7dfb484aaaa932151fc80450fdea9bdf10578e15 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Fri, 10 Jul 2026 14:22:42 -0400 Subject: [PATCH 001/102] fix(codex): add shared native gateway bootstrap Implement nemo-relay mcp as a deferred stdio lifecycle client for a fixed-port Rust sidecar with authenticated readiness, shared leases, coordinated recovery, and idle shutdown. Generate plugin-owned hooks and environment forwarding, fence install generations, preserve trust transactionally, and snapshot dynamic plugin identity so persistent bootstrap remains deterministic across supported platforms. Keep the exhaustive Rust coverage beside the implementation because CLI test modules are compiled from their external crates/cli/tests paths. Signed-off-by: Will Killian --- ATTRIBUTIONS-Rust.md | 580 ++++ Cargo.lock | 37 + crates/cli/Cargo.toml | 11 +- crates/cli/src/config.rs | 768 +++++- crates/cli/src/file_io.rs | 136 + crates/cli/src/install_generation.rs | 303 +++ crates/cli/src/installer.rs | 24 +- crates/cli/src/launcher.rs | 2 +- crates/cli/src/main.rs | 20 +- crates/cli/src/mcp.rs | 74 + crates/cli/src/mcp/gateway.rs | 252 ++ crates/cli/src/mcp/protocol.rs | 106 + crates/cli/src/mcp/session.rs | 89 + crates/cli/src/mcp/transport.rs | 71 + crates/cli/src/mcp_environment.rs | 179 ++ crates/cli/src/model.rs | 2 +- crates/cli/src/plugin_install/host.rs | 65 +- crates/cli/src/plugin_install/marketplace.rs | 93 +- crates/cli/src/plugin_install/mod.rs | 910 ++++++- .../cli/src/plugin_install/operation_lock.rs | 100 + crates/cli/src/plugin_install/setup.rs | 128 +- crates/cli/src/plugin_install/state.rs | 8 + crates/cli/src/plugin_shim/codex.rs | 768 +++++- .../cli/src/plugin_shim/codex_app_server.rs | 242 ++ crates/cli/src/plugin_shim/mod.rs | 249 +- crates/cli/src/plugin_shim/shared.rs | 469 +--- crates/cli/src/plugins/config_io.rs | 3 +- crates/cli/src/plugins/dynamic_editor.rs | 16 +- crates/cli/src/plugins/lifecycle.rs | 1305 ++++++++- .../cli/src/plugins/lifecycle/environment.rs | 353 ++- crates/cli/src/plugins/lifecycle/trust.rs | 26 +- crates/cli/src/server.rs | 365 ++- crates/cli/src/session.rs | 22 +- crates/cli/src/sidecar.rs | 788 ++++++ crates/cli/src/sidecar/health.rs | 247 ++ crates/cli/src/sidecar/process.rs | 381 +++ crates/cli/src/sidecar/state.rs | 495 ++++ crates/cli/tests/cli_tests.rs | 652 ++++- crates/cli/tests/coverage/config_tests.rs | 620 ++++- crates/cli/tests/coverage/file_io_tests.rs | 47 + crates/cli/tests/coverage/gateway_tests.rs | 6 + .../coverage/install_generation_tests.rs | 160 ++ crates/cli/tests/coverage/installer_tests.rs | 64 +- crates/cli/tests/coverage/main_tests.rs | 11 + .../cli/tests/coverage/mcp_gateway_tests.rs | 69 + crates/cli/tests/coverage/mcp_tests.rs | 703 +++++ .../coverage/plugin_install_setup_tests.rs | 97 + .../tests/coverage/plugin_install_tests.rs | 2018 +++++++++++++- .../cli/tests/coverage/plugin_shim_tests.rs | 2333 ++++++++++++++++- .../tests/coverage/plugins_lifecycle_tests.rs | 593 ++++- crates/cli/tests/coverage/server_tests.rs | 201 +- crates/cli/tests/coverage/session_tests.rs | 175 +- .../tests/coverage/sidecar_health_tests.rs | 126 + .../cli/tests/coverage/sidecar_state_tests.rs | 403 +++ crates/cli/tests/coverage/sidecar_tests.rs | 423 +++ .../codex/.codex-plugin/plugin.json | 7 +- integrations/coding-agents/codex/.mcp.json | 62 + .../coding-agents/codex/hooks/hooks.json | 36 +- 58 files changed, 17504 insertions(+), 989 deletions(-) create mode 100644 crates/cli/src/file_io.rs create mode 100644 crates/cli/src/install_generation.rs create mode 100644 crates/cli/src/mcp.rs create mode 100644 crates/cli/src/mcp/gateway.rs create mode 100644 crates/cli/src/mcp/protocol.rs create mode 100644 crates/cli/src/mcp/session.rs create mode 100644 crates/cli/src/mcp/transport.rs create mode 100644 crates/cli/src/mcp_environment.rs create mode 100644 crates/cli/src/plugin_install/operation_lock.rs create mode 100644 crates/cli/src/plugin_shim/codex_app_server.rs create mode 100644 crates/cli/src/sidecar.rs create mode 100644 crates/cli/src/sidecar/health.rs create mode 100644 crates/cli/src/sidecar/process.rs create mode 100644 crates/cli/src/sidecar/state.rs create mode 100644 crates/cli/tests/coverage/file_io_tests.rs create mode 100644 crates/cli/tests/coverage/install_generation_tests.rs create mode 100644 crates/cli/tests/coverage/mcp_gateway_tests.rs create mode 100644 crates/cli/tests/coverage/mcp_tests.rs create mode 100644 crates/cli/tests/coverage/plugin_install_setup_tests.rs create mode 100644 crates/cli/tests/coverage/sidecar_health_tests.rs create mode 100644 crates/cli/tests/coverage/sidecar_state_tests.rs create mode 100644 crates/cli/tests/coverage/sidecar_tests.rs create mode 100644 integrations/coding-agents/codex/.mcp.json diff --git a/ATTRIBUTIONS-Rust.md b/ATTRIBUTIONS-Rust.md index 4bdfa809e..f7e2c27a7 100644 --- a/ATTRIBUTIONS-Rust.md +++ b/ATTRIBUTIONS-Rust.md @@ -13124,6 +13124,215 @@ limitations under the License. ``` +## fs2 - 0.4.3 +**Repository URL**: https://github.com/danburkert/fs2-rs +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + ## futures - 0.3.32 **Repository URL**: https://github.com/rust-lang/futures-rs **License Type(s)**: Apache-2.0 @@ -46058,6 +46267,377 @@ limitations under the License. ``` +## winapi - 0.3.9 +**Repository URL**: https://github.com/retep998/winapi-rs +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +``` + +## winapi-i686-pc-windows-gnu - 0.4.0 +**Repository URL**: https://github.com/retep998/winapi-rs +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + +## winapi-x86_64-pc-windows-gnu - 0.4.0 +**Repository URL**: https://github.com/retep998/winapi-rs +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + ## windows-core - 0.62.2 **Repository URL**: https://github.com/microsoft/windows-rs **License Type(s)**: Apache-2.0 diff --git a/Cargo.lock b/Cargo.lock index f7398ba7d..a2d373eaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -753,6 +753,16 @@ dependencies = [ "num", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.32" @@ -1540,10 +1550,12 @@ dependencies = [ "clap_complete", "console 0.16.3", "dialoguer", + "fs2", "futures-util", "http", "http-body-util", "jsonschema", + "libc", "nemo-relay", "nemo-relay-adaptive", "nemo-relay-pii-redaction", @@ -1553,11 +1565,13 @@ dependencies = [ "regex", "reqwest", "ring", + "semver", "serde", "serde_json", "serde_yaml", "sha2", "strum", + "subtle", "tempfile", "thiserror 2.0.18", "tokio", @@ -1566,6 +1580,7 @@ dependencies = [ "toml_edit", "tower", "uuid", + "windows-sys 0.61.2", ] [[package]] @@ -3647,6 +3662,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index b38d57413..f1fce2c76 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -36,6 +36,7 @@ clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" console = "0.16" futures-util = "0.3" +fs2 = "0.4" http = "1" http-body-util = "0.1" dialoguer = { version = "0.11", default-features = false, features = ["password"] } @@ -44,18 +45,26 @@ percent-encoding = "2" reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "json", "rustls-tls-native-roots", "stream"] } regex = "1" ring = "0.17" +semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" sha2 = "0.11" strum = { version = "0.27", features = ["derive"] } +subtle = "2" thiserror = "2" -tokio = { version = "1", features = ["macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tokio-tungstenite = { version = "0.27", default-features = false, features = ["connect", "rustls-tls-native-roots"] } toml = "0.9" toml_edit = "0.23" uuid = { workspace = true, features = ["serde", "v7"] } +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] } + [dev-dependencies] opentelemetry = { workspace = true, features = ["trace"] } opentelemetry_sdk = { workspace = true, features = ["trace", "testing"] } diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index d4491d6f6..8507c6b6d 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -2,22 +2,42 @@ // SPDX-License-Identifier: Apache-2.0 use std::collections::HashSet; +use std::env; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; use std::net::SocketAddr; use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; use axum::http::HeaderMap; use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; -use nemo_relay::plugin::dynamic::DynamicPluginManifest; +use nemo_relay::plugin::dynamic::{ + DYNAMIC_PLUGIN_MANIFEST_FILENAME, DynamicPluginManifest, DynamicPluginManifestLoad, +}; use nemo_relay::plugin::{PluginError, merge_plugin_config_documents}; +use ring::rand::{SecureRandom, SystemRandom}; +use ring::{digest, hmac}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use strum::{Display, IntoStaticStr}; use crate::error::CliError; +use crate::file_io::{LockAttempt, try_lock_exclusive}; use crate::plugin_shim::PluginShimCommand; -use crate::plugins::lifecycle::enforce_required_dynamic_plugin_startup; +#[cfg(test)] +use crate::plugins::lifecycle::active_dynamic_plugin_components; +use crate::plugins::lifecycle::{ + ActiveDynamicPluginComponent, active_dynamic_plugin_components_for_identity, + dynamic_plugin_runtime_closure_digest, enforce_required_dynamic_plugin_startup, +}; use crate::plugins::policy::DynamicPluginHostPolicy; +pub(crate) const BOOTSTRAP_FINGERPRINT_ENV: &str = "NEMO_RELAY_BOOTSTRAP_FINGERPRINT"; +pub(crate) const PLUGIN_IDLE_TIMEOUT_ENV: &str = "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS"; +/// Maximum regular-file size hashed into persistent gateway identity (512 MiB). +pub(crate) const MAX_BOOTSTRAP_IDENTITY_FILE_BYTES: u64 = 512 * 1024 * 1024; + #[derive(Debug, Clone, Parser)] #[command(name = "nemo-relay")] #[command(about = "Coding-agent gateway for NeMo Relay observability")] @@ -51,7 +71,7 @@ pub(crate) enum Command { gateway; the gateway then forwards to `--openai-base-url` (defaults to \ api.openai.com) with `OPENAI_API_KEY` injected on the codex route (see \ NMF-86 — codex's own auth.json JWT is stripped). Requires codex-cli >= \ - 0.129.0.", + 0.143.0.", after_help = "Examples:\n \ nemo-relay codex\n \ nemo-relay codex -- exec \"fix the bug in foo.rs\"\n \ @@ -69,6 +89,19 @@ pub(crate) enum Command { nemo-relay hermes -- chat --provider custom" )] Hermes(EasyPathCommand), + /// Keep a shared Relay gateway ready for an MCP client. + #[command( + long_about = "Start or reuse a shared native NeMo Relay gateway for an MCP stdio \ + connection. The gateway binds 127.0.0.1:47632 by default and MCP \ + initialization completes only after Relay identity and readiness are \ + verified. Multiple MCP clients share the gateway; it remains available \ + until its idle timeout after the final client closes. This command \ + advertises no MCP tools.", + after_help = "Examples:\n \ + nemo-relay mcp\n \ + nemo-relay --bind 127.0.0.1:4041 mcp # explicit standalone/test bind" + )] + Mcp, /// Run the interactive setup (writes `.nemo-relay/config.toml`) Config(ConfigCommand), /// Create or edit plugin configuration (writes `plugins.toml`) @@ -426,6 +459,9 @@ pub(crate) struct ServerArgs { /// Internal override for the plugin configuration file. #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] pub(crate) plugin_config_path: Option, + /// Internal readiness file used by plugin sidecar bootstrap. + #[arg(long, hide = true)] + pub(crate) ready_file: Option, /// Maximum accepted coding-agent hook payload size, in bytes. #[arg(long, env = "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES")] pub(crate) max_hook_payload_bytes: Option, @@ -445,6 +481,7 @@ impl ServerArgs { || self.openai_base_url.is_some() || self.anthropic_base_url.is_some() || self.plugin_config_path.is_some() + || self.ready_file.is_some() || self.max_hook_payload_bytes.is_some() || self.max_passthrough_body_bytes.is_some() || self.config.is_some() @@ -579,6 +616,7 @@ pub(crate) struct ResolvedConfig { pub(crate) agents: AgentConfigs, pub(crate) dynamic_plugins: Vec, pub(crate) dynamic_plugin_policy: DynamicPluginHostPolicy, + pub(crate) bootstrap_fingerprint: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -690,6 +728,673 @@ pub(crate) fn resolve_server_config(args: &ServerArgs) -> Result Result { + if args.config.is_some() || args.plugin_config_path.is_some() || args.ready_file.is_some() { + return Err(CliError::Config( + "nemo-relay mcp uses system and user configuration only; use `nemo-relay run` for explicit or project configuration" + .into(), + )); + } + let mut resolved = load_shared_config_scoped(None, None, true)?; + apply_server_overrides(&mut resolved.gateway, args)?; + let active_dynamic_plugins = active_dynamic_plugin_components_for_identity(None, &resolved)?; + resolved.bootstrap_fingerprint = Some(persistent_bootstrap_fingerprint( + &resolved, + &active_dynamic_plugins, + )?); + Ok(resolved) +} + +/// Parent-computed identity and inputs needed to reverify a managed persistent gateway child. +#[derive(Debug, Clone)] +pub(crate) struct ManagedBootstrapIdentity { + expected: String, + persistent_args: ServerArgs, + resolved: ResolvedConfig, + active_dynamic_plugins: Vec, +} + +impl ManagedBootstrapIdentity { + pub(crate) fn fingerprint(&self) -> &str { + &self.expected + } + + pub(crate) fn verify_current(&self) -> Result<(), CliError> { + let snapshot_actual = + persistent_bootstrap_fingerprint(&self.resolved, &self.active_dynamic_plugins)?; + verify_managed_bootstrap_fingerprint(&self.expected, &snapshot_actual)?; + let resolved = resolve_persistent_server_config(&self.persistent_args)?; + let actual = resolved + .bootstrap_fingerprint + .expect("persistent gateway resolution sets a bootstrap fingerprint"); + verify_managed_bootstrap_fingerprint(&self.expected, &actual) + } +} + +/// Verifies and retains the parent-computed identity for a managed persistent gateway child. +/// +/// Ordinary daemon launches remain stateless: the internal ready-file contract identifies a child +/// spawned by the plugin bootstrap path. The child recomputes identity from the configuration and +/// active lifecycle records it is about to activate before publishing ownership or readiness. +pub(crate) fn managed_bootstrap_identity( + args: &ServerArgs, + resolved: &ResolvedConfig, + active_dynamic_plugins: &[ActiveDynamicPluginComponent], +) -> Result, CliError> { + if args.ready_file.is_none() { + return Ok(None); + } + let Some(expected) = env::var(BOOTSTRAP_FINGERPRINT_ENV) + .ok() + .filter(|fingerprint| !fingerprint.is_empty()) + else { + return Ok(None); + }; + let actual = persistent_bootstrap_fingerprint(resolved, active_dynamic_plugins)?; + verify_managed_bootstrap_fingerprint(&expected, &actual)?; + let mut persistent_args = args.clone(); + persistent_args.ready_file = None; + Ok(Some(ManagedBootstrapIdentity { + expected, + persistent_args, + resolved: resolved.clone(), + active_dynamic_plugins: active_dynamic_plugins.to_vec(), + })) +} + +fn verify_managed_bootstrap_fingerprint(expected: &str, actual: &str) -> Result<(), CliError> { + if actual == expected { + return Ok(()); + } + Err(CliError::Config( + "persistent gateway identity changed during managed bootstrap; retry so the parent can resolve the current configuration" + .into(), + )) +} + +fn persistent_bootstrap_fingerprint( + resolved: &ResolvedConfig, + active_dynamic_plugins: &[ActiveDynamicPluginComponent], +) -> Result { + let dynamic_plugins = active_dynamic_plugins + .iter() + .map(dynamic_plugin_bootstrap_identity) + .collect::, _>>()?; + let gateway = &resolved.gateway; + let idle_timeout_secs = crate::sidecar::plugin_idle_timeout() + .map_err(CliError::Config)? + .as_secs(); + let document = serde_json::json!({ + "bootstrap_protocol": 1, + "relay_version": env!("CARGO_PKG_VERSION"), + "openai_base_url": gateway.openai_base_url, + "anthropic_base_url": gateway.anthropic_base_url, + "metadata": gateway.metadata, + "plugin_config": gateway.plugin_config, + "max_hook_payload_bytes": gateway.max_hook_payload_bytes, + "max_passthrough_body_bytes": gateway.max_passthrough_body_bytes, + "plugin_idle_timeout_secs": idle_timeout_secs, + "dynamic_plugins": dynamic_plugins, + "dynamic_plugin_policy": format!("{:?}", resolved.dynamic_plugin_policy), + }); + let key = load_or_create_bootstrap_hmac_key()?; + let key = hmac::Key::new(hmac::HMAC_SHA256, &key); + let mut digest = hmac::Context::with_key(&key); + digest.update( + &serde_json::to_vec(&document).expect("persistent gateway fingerprint serializes to JSON"), + ); + let environment = env::vars_os().filter_map(|(name, _)| name.into_string().ok()); + for name in crate::mcp_environment::forwarded_names(environment, gateway.plugin_config.as_ref()) + { + if name == PLUGIN_IDLE_TIMEOUT_ENV { + continue; + } + digest.update(&[0]); + digest.update(name.as_bytes()); + digest.update(&[0]); + if let Some(value) = env::var_os(&name) { + digest.update(value.to_string_lossy().as_bytes()); + } + } + let tag = digest.sign(); + Ok(format!( + "hmac-sha256:{}", + tag.as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + )) +} + +fn dynamic_plugin_bootstrap_identity( + plugin: &ActiveDynamicPluginComponent, +) -> Result { + let manifest_identity = match (&plugin.activation_snapshot, plugin.manifest_ref.as_deref()) { + (Some(snapshot), _) => Some(dynamic_plugin_snapshot_identity(snapshot)?), + (None, Some(manifest_ref)) => Some(dynamic_plugin_manifest_identity( + manifest_ref, + plugin.environment_ref.as_deref(), + )?), + (None, None) => None, + }; + Ok(serde_json::json!({ + "plugin_id": plugin.plugin_id, + "kind": format!("{:?}", plugin.kind), + "lifecycle_generation": plugin.lifecycle_generation, + "manifest": manifest_identity, + "environment_ref": plugin.environment_ref, + "config": plugin.config, + })) +} + +fn dynamic_plugin_snapshot_identity( + snapshot: &crate::plugins::lifecycle::DynamicPluginActivationSnapshot, +) -> Result { + let (manifest, _) = load_bounded_dynamic_plugin_manifest(snapshot.identity_manifest())?; + let manifest_path = PathBuf::from(snapshot.original_manifest_ref()); + let manifest_digest = bootstrap_file_digest( + snapshot.identity_manifest(), + "dynamic plugin manifest snapshot", + )?; + let artifact_ref = manifest + .source + .as_ref() + .and_then(|source| source.artifact.as_deref()) + .or(match &manifest.load { + DynamicPluginManifestLoad::RustDynamic(load) => load.library.as_deref(), + DynamicPluginManifestLoad::Worker(_) => None, + }); + let artifact = artifact_ref + .map(|artifact_ref| { + let logical_path = resolve_dynamic_plugin_relative_path(&manifest_path, artifact_ref); + let snapshot_path = snapshot.identity_file(&logical_path).ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin activation snapshot is missing artifact {}", + logical_path.display() + )) + })?; + bootstrap_file_digest(snapshot_path, "dynamic plugin artifact snapshot") + .map(|digest| serde_json::json!({ "path": logical_path, "sha256": digest })) + }) + .transpose()?; + let signature = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.signature.as_deref()) + .map(|signature_ref| { + let logical_path = resolve_dynamic_plugin_relative_path(&manifest_path, signature_ref); + let snapshot_path = snapshot.identity_file(&logical_path).ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin activation snapshot is missing signature {}", + logical_path.display() + )) + })?; + bootstrap_file_digest(snapshot_path, "dynamic plugin signature snapshot") + .map(|digest| serde_json::json!({ "path": logical_path, "sha256": digest })) + }) + .transpose()?; + Ok(serde_json::json!({ + "path": snapshot.original_manifest_ref(), + "sha256": manifest_digest, + "artifact": artifact, + "signature": signature, + "runtime_closure_sha256": snapshot.closure_digest(), + })) +} + +fn dynamic_plugin_manifest_identity( + manifest_ref: &str, + environment_ref: Option<&str>, +) -> Result { + let (manifest, normalized_ref) = load_bounded_dynamic_plugin_manifest(manifest_ref)?; + let manifest_path = PathBuf::from(&normalized_ref); + let manifest_digest = bootstrap_file_digest(&manifest_path, "dynamic plugin manifest")?; + let artifact_ref = manifest + .source + .as_ref() + .and_then(|source| source.artifact.as_deref()) + .or(match &manifest.load { + DynamicPluginManifestLoad::RustDynamic(load) => load.library.as_deref(), + DynamicPluginManifestLoad::Worker(_) => None, + }); + let artifact = artifact_ref + .map(|artifact_ref| { + let path = resolve_dynamic_plugin_relative_path(&manifest_path, artifact_ref); + bootstrap_file_digest(&path, "dynamic plugin artifact") + .map(|digest| serde_json::json!({ "path": path, "sha256": digest })) + }) + .transpose()?; + let signature = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.signature.as_deref()) + .map(|signature_ref| { + let path = resolve_dynamic_plugin_relative_path(&manifest_path, signature_ref); + bootstrap_file_digest(&path, "dynamic plugin signature") + .map(|digest| serde_json::json!({ "path": path, "sha256": digest })) + }) + .transpose()?; + let closure_digest = dynamic_plugin_runtime_closure_digest(&normalized_ref, environment_ref)?; + Ok(serde_json::json!({ + "path": normalized_ref, + "sha256": manifest_digest, + "artifact": artifact, + "signature": signature, + "runtime_closure_sha256": closure_digest, + })) +} + +fn resolve_dynamic_plugin_relative_path(manifest_path: &Path, reference: &str) -> PathBuf { + let path = PathBuf::from(reference); + if path.is_absolute() { + path + } else { + manifest_path + .parent() + .map(|parent| parent.join(&path)) + .unwrap_or(path) + } +} + +fn bootstrap_file_digest(path: &Path, description: &str) -> Result { + let mut context = digest::Context::new(&digest::SHA256); + stream_bounded_regular_file(path, description, |bytes| context.update(bytes)) + .map_err(CliError::Config)?; + Ok(context + .finish() + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +pub(crate) fn load_bounded_dynamic_plugin_manifest( + path: impl AsRef, +) -> Result<(DynamicPluginManifest, String), CliError> { + let (manifest, normalized, _) = load_bounded_dynamic_plugin_manifest_bytes(path)?; + Ok((manifest, normalized)) +} + +pub(crate) fn load_bounded_dynamic_plugin_manifest_bytes( + path: impl AsRef, +) -> Result<(DynamicPluginManifest, String, Vec), CliError> { + let path = path.as_ref(); + let manifest_path = if path.is_dir() { + path.join(DYNAMIC_PLUGIN_MANIFEST_FILENAME) + } else { + path.to_path_buf() + }; + let normalized = fs::canonicalize(&manifest_path).map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin manifest {}: {error}", + manifest_path.display() + )) + })?; + let bytes = read_bounded_regular_file(&normalized, "dynamic plugin manifest") + .map_err(CliError::Config)?; + let contents = std::str::from_utf8(&bytes).map_err(|error| { + CliError::Config(format!( + "dynamic plugin manifest {} is not UTF-8: {error}", + normalized.display() + )) + })?; + let manifest = DynamicPluginManifest::parse_toml(contents) + .map_err(|error| CliError::Config(error.to_string()))?; + Ok((manifest, normalized.to_string_lossy().into_owned(), bytes)) +} + +pub(crate) fn read_bounded_regular_file(path: &Path, description: &str) -> Result, String> { + let mut bytes = Vec::new(); + stream_bounded_regular_file(path, description, |chunk| bytes.extend_from_slice(chunk))?; + Ok(bytes) +} + +pub(crate) fn stream_bounded_regular_file( + path: &Path, + description: &str, + mut consume: impl FnMut(&[u8]), +) -> Result<(), String> { + const BUFFER_BYTES: usize = 64 * 1024; + let metadata = fs::symlink_metadata(path).map_err(|error| { + format!( + "failed to inspect {description} {} for persistent gateway identity: {error}", + path.display() + ) + })?; + if !metadata.file_type().is_file() { + return Err(format!( + "{description} {} must be a regular file for persistent gateway identity", + path.display() + )); + } + if metadata.len() > MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { + return Err(format!( + "{description} {} exceeds the {MAX_BOOTSTRAP_IDENTITY_FILE_BYTES}-byte persistent gateway identity budget", + path.display() + )); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + let mut file = options.open(path).map_err(|error| { + format!( + "failed to read {description} {} for persistent gateway identity: {error}", + path.display() + ) + })?; + let opened_metadata = file.metadata().map_err(|error| { + format!( + "failed to inspect {description} {} for persistent gateway identity: {error}", + path.display() + ) + })?; + if !opened_metadata.file_type().is_file() { + return Err(format!( + "{description} {} must be a regular file for persistent gateway identity", + path.display() + )); + } + if opened_metadata.len() > MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { + return Err(format!( + "{description} {} exceeds the {MAX_BOOTSTRAP_IDENTITY_FILE_BYTES}-byte persistent gateway identity budget", + path.display() + )); + } + let mut buffer = [0_u8; BUFFER_BYTES]; + let mut total = 0_u64; + loop { + let read = file.read(&mut buffer).map_err(|error| { + format!( + "failed to read {description} {} for persistent gateway identity: {error}", + path.display() + ) + })?; + if read == 0 { + break; + } + total = total.saturating_add(read as u64); + if total > MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { + return Err(format!( + "{description} {} exceeds the {MAX_BOOTSTRAP_IDENTITY_FILE_BYTES}-byte persistent gateway identity budget", + path.display() + )); + } + consume(&buffer[..read]); + } + Ok(()) +} + +const BOOTSTRAP_HMAC_KEY_BYTES: usize = 32; +const BOOTSTRAP_HMAC_LOCK_TIMEOUT: Duration = Duration::from_secs(5); +const BOOTSTRAP_CHALLENGE_DOMAIN: &[u8] = b"nemo-relay/bootstrap-health/v1\0"; +const PYTHON_ENVIRONMENT_ATTESTATION_DOMAIN: &[u8] = + b"nemo-relay/python-environment-attestation/v1\0"; + +/// Per-user secret used to authenticate a managed bootstrap listener without exposing key bytes. +#[derive(Clone)] +pub(crate) struct BootstrapChallengeKey(hmac::Key); + +impl BootstrapChallengeKey { + pub(crate) fn load() -> Result { + Ok(Self(hmac::Key::new( + hmac::HMAC_SHA256, + &load_or_create_bootstrap_hmac_key()?, + ))) + } + + pub(crate) fn proof(&self, fingerprint: &str, nonce: &str) -> String { + let mut context = hmac::Context::with_key(&self.0); + context.update(BOOTSTRAP_CHALLENGE_DOMAIN); + context.update(fingerprint.as_bytes()); + context.update(&[0]); + context.update(nonce.as_bytes()); + let tag = context.sign(); + format!( + "hmac-sha256:{}", + tag.as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) + } + + pub(crate) fn verify(&self, fingerprint: &str, nonce: &str, proof: &str) -> bool { + let Some(encoded) = proof.strip_prefix("hmac-sha256:") else { + return false; + }; + let Some(tag) = decode_fixed_hex::<32>(encoded) else { + return false; + }; + let mut message = Vec::with_capacity( + BOOTSTRAP_CHALLENGE_DOMAIN.len() + fingerprint.len() + nonce.len() + 1, + ); + message.extend_from_slice(BOOTSTRAP_CHALLENGE_DOMAIN); + message.extend_from_slice(fingerprint.as_bytes()); + message.push(0); + message.extend_from_slice(nonce.as_bytes()); + hmac::verify(&self.0, &message, &tag).is_ok() + } + + #[cfg(test)] + pub(crate) fn from_bytes(bytes: &[u8]) -> Self { + Self(hmac::Key::new(hmac::HMAC_SHA256, bytes)) + } +} + +fn decode_fixed_hex(encoded: &str) -> Option<[u8; N]> { + if encoded.len() != N * 2 || !encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + let mut decoded = [0_u8; N]; + for (index, byte) in decoded.iter_mut().enumerate() { + *byte = u8::from_str_radix(&encoded[index * 2..index * 2 + 2], 16).ok()?; + } + Some(decoded) +} + +pub(crate) fn sign_python_environment_attestation( + source_artifact_sha256: &str, + environment_sha256: &str, +) -> Result { + let key = hmac::Key::new(hmac::HMAC_SHA256, &load_or_create_bootstrap_hmac_key()?); + let message = + python_environment_attestation_message(source_artifact_sha256, environment_sha256); + let tag = hmac::sign(&key, &message); + Ok(format!( + "hmac-sha256:{}", + tag.as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + )) +} + +pub(crate) fn verify_python_environment_attestation( + source_artifact_sha256: &str, + environment_sha256: &str, + authentication: &str, +) -> Result { + let Some(encoded) = authentication.strip_prefix("hmac-sha256:") else { + return Ok(false); + }; + let Some(tag) = decode_fixed_hex::<32>(encoded) else { + return Ok(false); + }; + let key = hmac::Key::new(hmac::HMAC_SHA256, &load_or_create_bootstrap_hmac_key()?); + Ok(hmac::verify( + &key, + &python_environment_attestation_message(source_artifact_sha256, environment_sha256), + &tag, + ) + .is_ok()) +} + +fn python_environment_attestation_message( + source_artifact_sha256: &str, + environment_sha256: &str, +) -> Vec { + let mut message = Vec::with_capacity( + PYTHON_ENVIRONMENT_ATTESTATION_DOMAIN.len() + + source_artifact_sha256.len() + + environment_sha256.len() + + 1, + ); + message.extend_from_slice(PYTHON_ENVIRONMENT_ATTESTATION_DOMAIN); + message.extend_from_slice(source_artifact_sha256.trim().as_bytes()); + message.push(0); + message.extend_from_slice(environment_sha256.as_bytes()); + message +} + +fn load_or_create_bootstrap_hmac_key() -> Result<[u8; BOOTSTRAP_HMAC_KEY_BYTES], CliError> { + let path = user_config_dir() + .map(|directory| directory.join("bootstrap").join("fingerprint-hmac.key")) + .ok_or_else(|| { + CliError::Config( + "cannot determine the per-user NeMo Relay bootstrap state directory; set HOME or USERPROFILE" + .into(), + ) + })?; + load_or_create_bootstrap_hmac_key_at(&path) +} + +fn load_or_create_bootstrap_hmac_key_at( + path: &Path, +) -> Result<[u8; BOOTSTRAP_HMAC_KEY_BYTES], CliError> { + load_or_create_bootstrap_hmac_key_at_with_timeout(path, BOOTSTRAP_HMAC_LOCK_TIMEOUT) +} + +fn load_or_create_bootstrap_hmac_key_at_with_timeout( + path: &Path, + lock_timeout: Duration, +) -> Result<[u8; BOOTSTRAP_HMAC_KEY_BYTES], CliError> { + let parent = path.parent().ok_or_else(|| { + CliError::Config(format!( + "bootstrap HMAC key path {} has no parent directory", + path.display() + )) + })?; + fs::create_dir_all(parent).map_err(|error| { + CliError::Config(format!( + "failed to create bootstrap state directory {}: {error}", + parent.display() + )) + })?; + #[cfg(unix)] + fs::set_permissions(parent, { + use std::os::unix::fs::PermissionsExt; + fs::Permissions::from_mode(0o700) + }) + .map_err(|error| { + CliError::Config(format!( + "failed to protect bootstrap state directory {}: {error}", + parent.display() + )) + })?; + + let mut options = OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path).map_err(|error| { + CliError::Config(format!( + "failed to open bootstrap HMAC key {}: {error}", + path.display() + )) + })?; + let lock_deadline = Instant::now() + lock_timeout; + loop { + match try_lock_exclusive(&file) { + Ok(LockAttempt::Acquired) => break, + Ok(LockAttempt::Contended) => { + if Instant::now() >= lock_deadline { + return Err(CliError::Config(format!( + "timed out waiting for bootstrap HMAC key lock {}", + path.display() + ))); + } + thread::sleep(Duration::from_millis(25)); + } + Err(error) => { + return Err(CliError::Config(format!( + "failed to lock bootstrap HMAC key {}: {error}", + path.display() + ))); + } + } + } + #[cfg(unix)] + file.set_permissions({ + use std::os::unix::fs::PermissionsExt; + fs::Permissions::from_mode(0o600) + }) + .map_err(|error| { + CliError::Config(format!( + "failed to protect bootstrap HMAC key {}: {error}", + path.display() + )) + })?; + + let length = file + .metadata() + .map_err(|error| { + CliError::Config(format!( + "failed to inspect bootstrap HMAC key {}: {error}", + path.display() + )) + })? + .len(); + if length == 0 { + let mut key = [0_u8; BOOTSTRAP_HMAC_KEY_BYTES]; + SystemRandom::new() + .fill(&mut key) + .map_err(|_| CliError::Config("failed to generate bootstrap HMAC key".into()))?; + file.write_all(&key).map_err(|error| { + CliError::Config(format!( + "failed to write bootstrap HMAC key {}: {error}", + path.display() + )) + })?; + file.sync_all().map_err(|error| { + CliError::Config(format!( + "failed to persist bootstrap HMAC key {}: {error}", + path.display() + )) + })?; + return Ok(key); + } + if length != BOOTSTRAP_HMAC_KEY_BYTES as u64 { + return Err(CliError::Config(format!( + "bootstrap HMAC key {} has invalid length {length}; expected {BOOTSTRAP_HMAC_KEY_BYTES} bytes", + path.display() + ))); + } + file.seek(SeekFrom::Start(0)).map_err(|error| { + CliError::Config(format!( + "failed to read bootstrap HMAC key {}: {error}", + path.display() + )) + })?; + let mut key = [0_u8; BOOTSTRAP_HMAC_KEY_BYTES]; + file.read_exact(&mut key).map_err(|error| { + CliError::Config(format!( + "failed to read bootstrap HMAC key {}: {error}", + path.display() + )) + })?; + Ok(key) +} + /// Resolves shared config for plugin-facing CLI commands without mutating gateway runtime fields. pub(crate) fn resolve_plugins_config( explicit: Option<&PathBuf>, @@ -790,9 +1495,17 @@ pub(crate) const PLUGINS_TOML: &str = "plugins.toml"; fn load_shared_config( explicit: Option<&PathBuf>, plugin_config_path: Option<&PathBuf>, +) -> Result { + load_shared_config_scoped(explicit, plugin_config_path, user_config_scope()) +} + +fn load_shared_config_scoped( + explicit: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, + user_only: bool, ) -> Result { let mut merged = toml::Value::Table(toml::map::Map::new()); - for path in config_paths(explicit) { + for path in config_paths_scoped(explicit, user_only) { let Some(raw) = read_config_file(&path, explicit.is_some(), "configuration")? else { continue; }; @@ -819,7 +1532,7 @@ fn load_shared_config( } merge_toml(&mut merged, parsed); } - let plugin_toml = load_plugin_toml_config(explicit, plugin_config_path)?; + let plugin_toml = load_plugin_toml_config_scoped(explicit, plugin_config_path, user_only)?; let mut resolved = ResolvedConfig { gateway: GatewayConfig::default(), ..ResolvedConfig::default() @@ -864,11 +1577,16 @@ pub(crate) fn any_config_file_exists() -> bool { // Returns the config search path. An explicit path disables implicit discovery; otherwise system // config is lowest priority, the nearest project config is next, and user config is merged last. fn config_paths(explicit: Option<&PathBuf>) -> Vec { + config_paths_scoped(explicit, user_config_scope()) +} + +fn config_paths_scoped(explicit: Option<&PathBuf>, user_only: bool) -> Vec { if let Some(path) = explicit { return vec![path.clone()]; } let mut paths = vec![PathBuf::from("/etc/nemo-relay/config.toml")]; - if let Ok(cwd) = std::env::current_dir() + if !user_only + && let Ok(cwd) = std::env::current_dir() && let Some(project) = find_project_config(&cwd) { paths.push(project); @@ -885,6 +1603,14 @@ fn config_paths(explicit: Option<&PathBuf>) -> Vec { fn plugin_config_paths( explicit: Option<&PathBuf>, plugin_config_path: Option<&PathBuf>, +) -> Vec { + plugin_config_paths_scoped(explicit, plugin_config_path, user_config_scope()) +} + +fn plugin_config_paths_scoped( + explicit: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, + user_only: bool, ) -> Vec { if let Some(path) = plugin_config_path { return vec![path.clone()]; @@ -895,9 +1621,16 @@ fn plugin_config_paths( .map(|parent| vec![parent.join(PLUGINS_TOML)]) .unwrap_or_default(); } + if user_only { + return implicit_plugin_config_paths(None, user_config_dir()); + } implicit_plugin_config_paths(std::env::current_dir().ok().as_deref(), user_config_dir()) } +fn user_config_scope() -> bool { + std::env::var("NEMO_RELAY_CONFIG_SCOPE").ok().as_deref() == Some("user") +} + /// Returns the implicit `plugins.toml` discovery paths used by the gateway and doctor. pub(crate) fn default_plugin_config_paths() -> Vec { plugin_config_paths(None, None) @@ -932,6 +1665,13 @@ pub(crate) fn user_plugin_config_path() -> Option { user_config_dir().map(|dir| dir.join(PLUGINS_TOML)) } +pub(crate) fn user_plugin_runtime_config() -> Result, CliError> { + Ok( + load_plugin_toml_config_from_paths(implicit_plugin_config_paths(None, user_config_dir()))? + .and_then(|config| config.value), + ) +} + pub(crate) fn project_plugin_config_path(start: &std::path::Path) -> PathBuf { find_project_plugin_config(start) .or_else(|| { @@ -1029,7 +1769,19 @@ fn load_plugin_toml_config( explicit: Option<&PathBuf>, plugin_config_path: Option<&PathBuf>, ) -> Result, CliError> { - load_plugin_toml_config_from_paths(plugin_config_paths(explicit, plugin_config_path)) + load_plugin_toml_config_scoped(explicit, plugin_config_path, user_config_scope()) +} + +fn load_plugin_toml_config_scoped( + explicit: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, + user_only: bool, +) -> Result, CliError> { + load_plugin_toml_config_from_paths(plugin_config_paths_scoped( + explicit, + plugin_config_path, + user_only, + )) } /// Returns the physical `plugins.toml` files that contribute effective runtime or dynamic @@ -1167,7 +1919,7 @@ fn resolve_dynamic_plugin_refs( let mut resolved = Vec::with_capacity(plugins.dynamic.len()); for dynamic in plugins.dynamic { let manifest_path = resolve_dynamic_manifest_path(source, &dynamic.manifest); - let (manifest, manifest_ref) = DynamicPluginManifest::load_from_path(&manifest_path) + let (manifest, manifest_ref) = load_bounded_dynamic_plugin_manifest(&manifest_path) .map_err(|error| { CliError::Config(format!( "invalid dynamic plugin manifest referenced by {}: {error}", diff --git a/crates/cli/src/file_io.rs b/crates/cli/src/file_io.rs new file mode 100644 index 000000000..34b04a281 --- /dev/null +++ b/crates/cli/src/file_io.rs @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Small, platform-aware filesystem primitives shared by CLI subsystems. + +use std::fs::{self, File}; +use std::io; +use std::path::Path; +#[cfg(windows)] +use std::path::PathBuf; + +use fs2::FileExt; + +/// Result of one nonblocking advisory-file-lock attempt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum LockAttempt { + Acquired, + Contended, +} + +/// Attempt an exclusive advisory lock without waiting. +pub(crate) fn try_lock_exclusive(file: &File) -> io::Result { + normalize_lock_attempt(FileExt::try_lock_exclusive(file)) +} + +/// Attempt a shared advisory lock without waiting. +pub(crate) fn try_lock_shared(file: &File) -> io::Result { + normalize_lock_attempt(FileExt::try_lock_shared(file)) +} + +fn normalize_lock_attempt(result: io::Result<()>) -> io::Result { + match result { + Ok(()) => Ok(LockAttempt::Acquired), + Err(error) if lock_is_contended(&error) => Ok(LockAttempt::Contended), + Err(error) => Err(error), + } +} + +fn lock_is_contended(error: &io::Error) -> bool { + if error.kind() == io::ErrorKind::WouldBlock { + return true; + } + #[cfg(windows)] + { + error.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION as i32) + } + #[cfg(not(windows))] + { + false + } +} + +/// Atomically replace `path` with `bytes`, creating its parent directory when needed. +pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + let tmp = path.with_extension(format!( + "{}tmp", + path.extension() + .and_then(|value| value.to_str()) + .map(|value| format!("{value}.")) + .unwrap_or_default() + )); + fs::write(&tmp, bytes) + .map_err(|error| format!("failed to write {}: {error}", tmp.display()))?; + replace_file(&tmp, path) +} + +#[cfg(not(windows))] +fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { + fs::rename(tmp, path).map_err(|error| format!("failed to replace {}: {error}", path.display())) +} + +#[cfg(windows)] +fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { + if !path.exists() { + return fs::rename(tmp, path) + .map_err(|error| format!("failed to replace {}: {error}", path.display())); + } + + let backup = replace_backup_path(path); + match fs::remove_file(&backup) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "failed to remove stale replacement backup {}: {error}", + backup.display() + )); + } + } + + match fs::rename(path, &backup) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return fs::rename(tmp, path) + .map_err(|error| format!("failed to replace {}: {error}", path.display())); + } + Err(error) => { + return Err(format!( + "failed to prepare replacement for {}: {error}", + path.display() + )); + } + } + + match fs::rename(tmp, path) { + Ok(()) => { + let _ = fs::remove_file(&backup); + Ok(()) + } + Err(error) => match fs::rename(&backup, path) { + Ok(()) => Err(format!("failed to replace {}: {error}", path.display())), + Err(restore_error) => Err(format!( + "failed to replace {}: {error}; additionally failed to restore {}: {restore_error}", + path.display(), + backup.display() + )), + }, + } +} + +#[cfg(windows)] +fn replace_backup_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("config"); + path.with_file_name(format!(".{file_name}.nemo-relay-replace.tmp")) +} + +#[cfg(test)] +#[path = "../tests/coverage/file_io_tests.rs"] +mod tests; diff --git a/crates/cli/src/install_generation.rs b/crates/cli/src/install_generation.rs new file mode 100644 index 000000000..2b59c530f --- /dev/null +++ b/crates/cli/src/install_generation.rs @@ -0,0 +1,303 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Private install-generation fencing for lifecycle-bound Codex MCP supervisors. + +use std::env; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::file_io::{LockAttempt, try_lock_exclusive, try_lock_shared}; + +pub(crate) const GENERATION_FILE_ENV: &str = "NEMO_RELAY_MCP_GENERATION_FILE"; +pub(crate) const GENERATION_FILE_NAME: &str = ".nemo-relay-generation"; +const MAX_GENERATION_TOKEN_BYTES: usize = 128; +const RETIRED_GENERATION_PREFIX: &str = "retired:"; +const DEFAULT_GENERATION_LOCK_TIMEOUT: Duration = Duration::from_secs(5); +const GENERATION_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct InstallGeneration { + path: PathBuf, + token: String, +} + +impl InstallGeneration { + pub(crate) fn capture_from_env() -> Result, String> { + env::var_os(GENERATION_FILE_ENV) + .map(PathBuf::from) + .map(Self::capture) + .transpose() + } + + pub(crate) fn capture(path: PathBuf) -> Result { + let file = open_generation(&path)?; + lock_shared_with_timeout(&file, &path, DEFAULT_GENERATION_LOCK_TIMEOUT)?; + let token = read_generation_file(&file, &path)?; + let current = read_generation_path(&path)?; + if token != current { + return Err(retired_generation_error(&path)); + } + Ok(Self { path, token }) + } + + pub(crate) fn verify_current(&self) -> Result<(), String> { + let file = open_generation(&self.path).map_err(|_| retired_generation_error(&self.path))?; + lock_shared_with_timeout(&file, &self.path, DEFAULT_GENERATION_LOCK_TIMEOUT) + .map_err(|_| retired_generation_error(&self.path))?; + let locked = read_generation_file(&file, &self.path) + .map_err(|_| retired_generation_error(&self.path))?; + let current = + read_generation_path(&self.path).map_err(|_| retired_generation_error(&self.path))?; + if locked != self.token || current != self.token { + return Err(retired_generation_error(&self.path)); + } + Ok(()) + } +} + +pub(crate) struct GenerationRetirement { + lock: Option, + path: PathBuf, + original: GenerationMarker, + changed: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum GenerationMarker { + Active(String), + Retired(String), +} + +impl GenerationMarker { + fn token(&self) -> &str { + match self { + Self::Active(token) | Self::Retired(token) => token, + } + } + + fn encoded(&self) -> String { + match self { + Self::Active(token) => format!("{token}\n"), + Self::Retired(token) => format!("{RETIRED_GENERATION_PREFIX}{token}\n"), + } + } + + fn is_retired(&self) -> bool { + matches!(self, Self::Retired(_)) + } +} + +impl GenerationRetirement { + pub(crate) fn acquire(path: &Path) -> Result, String> { + Self::acquire_with_timeout(path, DEFAULT_GENERATION_LOCK_TIMEOUT) + } + + pub(crate) fn acquire_with_timeout( + path: &Path, + timeout: Duration, + ) -> Result, String> { + let file = match OpenOptions::new().read(true).write(true).open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "failed to open MCP install generation {}: {error}", + path.display() + )); + } + }; + lock_exclusive_with_timeout(&file, path, timeout)?; + let original = read_generation_marker(&file, path)?; + Ok(Some(Self { + lock: Some(file), + path: path.to_owned(), + original, + changed: false, + })) + } + + /// Persistently invalidate this generation and release its exclusive lock before moving the + /// plugin tree. A retired marker cannot be adopted by an old or newly launched MCP process, + /// but remains recognizable so an interrupted uninstall can resume. + pub(crate) fn invalidate_for_replacement(&mut self) -> Result<(), String> { + if self.original.is_retired() || self.changed { + self.lock = None; + return Ok(()); + } + let retired = GenerationMarker::Retired(self.original.token().to_owned()); + let file = self.lock.as_mut().ok_or_else(|| { + format!( + "MCP install generation {} is not locked", + self.path.display() + ) + })?; + self.changed = true; + replace_generation_marker(file, &self.path, &retired, "invalidate")?; + self.lock = None; + Ok(()) + } + + /// Restore an invalidated marker before a rolled-back plugin is registered again. + pub(crate) fn restore_after_rollback(&mut self) -> Result<(), String> { + if !self.changed { + self.lock = None; + return Ok(()); + } + let mut file = match self.lock.take() { + Some(file) => file, + None => OpenOptions::new() + .write(true) + .open(&self.path) + .map_err(|error| { + format!( + "failed to reopen MCP install generation {} for rollback: {error}", + self.path.display() + ) + })?, + }; + replace_generation_marker(&mut file, &self.path, &self.original, "restore")?; + self.changed = false; + Ok(()) + } +} + +fn replace_generation_marker( + file: &mut File, + path: &Path, + marker: &GenerationMarker, + operation: &str, +) -> Result<(), String> { + write_generation_marker(file, marker).map_err(|error| { + format!( + "failed to {operation} MCP install generation {}: {error}", + path.display() + ) + }) +} + +fn write_generation_marker(file: &mut File, marker: &GenerationMarker) -> std::io::Result<()> { + file.set_len(0)?; + file.seek(SeekFrom::Start(0))?; + file.write_all(marker.encoded().as_bytes())?; + file.sync_all() +} + +fn lock_shared_with_timeout(file: &File, path: &Path, timeout: Duration) -> Result<(), String> { + lock_with_timeout(file, path, timeout, false) +} + +fn lock_exclusive_with_timeout(file: &File, path: &Path, timeout: Duration) -> Result<(), String> { + lock_with_timeout(file, path, timeout, true) +} + +fn lock_with_timeout( + file: &File, + path: &Path, + timeout: Duration, + exclusive: bool, +) -> Result<(), String> { + let deadline = Instant::now() + timeout; + loop { + let result = if exclusive { + try_lock_exclusive(file) + } else { + try_lock_shared(file) + }; + match result { + Ok(LockAttempt::Acquired) => return Ok(()), + Ok(LockAttempt::Contended) => { + if Instant::now() >= deadline { + return Err(format!( + "timed out waiting for MCP install generation lock {}", + path.display() + )); + } + thread::sleep(GENERATION_LOCK_RETRY_INTERVAL.min(timeout)); + } + Err(error) => { + return Err(format!( + "failed to lock MCP install generation {}: {error}", + path.display() + )); + } + } + } +} + +pub(crate) fn write_new_generation(path: &Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + fs::write(path, format!("{}\n", uuid::Uuid::now_v7())) + .map_err(|error| format!("failed to write {}: {error}", path.display())) +} + +fn open_generation(path: &Path) -> Result { + OpenOptions::new().read(true).open(path).map_err(|error| { + format!( + "failed to open MCP install generation {}: {error}", + path.display() + ) + }) +} + +fn read_generation_path(path: &Path) -> Result { + let file = open_generation(path)?; + read_generation_file(&file, path) +} + +fn read_generation_file(file: &File, path: &Path) -> Result { + match read_generation_marker(file, path)? { + GenerationMarker::Active(token) => Ok(token), + GenerationMarker::Retired(_) => Err(retired_generation_error(path)), + } +} + +fn read_generation_marker(file: &File, path: &Path) -> Result { + let mut raw = String::new(); + file.take(MAX_GENERATION_TOKEN_BYTES.saturating_add(1) as u64) + .read_to_string(&mut raw) + .map_err(|error| { + format!( + "failed to read MCP install generation {}: {error}", + path.display() + ) + })?; + if raw.len() > MAX_GENERATION_TOKEN_BYTES { + return Err(format!( + "MCP install generation {} exceeds the {MAX_GENERATION_TOKEN_BYTES}-byte limit", + path.display() + )); + } + let token = raw.trim(); + if token.is_empty() { + return Err(format!( + "MCP install generation {} is empty", + path.display() + )); + } + match token.strip_prefix(RETIRED_GENERATION_PREFIX) { + Some("") => Err(format!( + "MCP install generation {} has a retired marker without a token", + path.display() + )), + Some(token) => Ok(GenerationMarker::Retired(token.to_owned())), + None => Ok(GenerationMarker::Active(token.to_owned())), + } +} + +fn retired_generation_error(path: &Path) -> String { + format!( + "Codex plugin MCP install generation at {} has been retired", + path.display() + ) +} + +#[cfg(test)] +#[path = "../tests/coverage/install_generation_tests.rs"] +mod tests; diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 23462d0d9..9a1a2274b 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -15,10 +15,9 @@ use crate::error::CliError; // (the transparent-run temp plugin and the marketplace plugin) are plugin hooks.json, so every // event here must exist in the oldest supported Claude Code. UserPromptExpansion sets that // floor: 2.1.116 (verified empirically; 2.1.114 rejects it — see `claude_hook_floor_warning` -// in doctor.rs). Codex 0.129 has a smaller subset (SessionStart, UserPromptSubmit, PreToolUse, -// PostToolUse, Stop, PreCompact, PostCompact, PermissionRequest) and silently ignores events -// it doesn't recognize, so the union list is safe for both agents. -const HOOK_EVENTS: &[&str] = &[ +// in doctor.rs). Codex receives a separate event schema because ignored unknown events would make +// generated hooks impossible to discover and trust exhaustively. +const CLAUDE_HOOK_EVENTS: &[&str] = &[ "SessionStart", "UserPromptSubmit", "UserPromptExpansion", @@ -35,6 +34,19 @@ const HOOK_EVENTS: &[&str] = &[ "SessionEnd", ]; +const CODEX_HOOK_EVENTS: &[&str] = &[ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "SubagentStart", + "SubagentStop", + "Stop", + "PreCompact", + "PostCompact", +]; + const HOOK_FORWARD_TIMEOUT: Duration = Duration::from_secs(2); const HERMES_HOOK_EVENTS: &[&str] = &[ @@ -219,11 +231,11 @@ pub(crate) fn hook_forward_command(executable: &str, agent: CodingAgent) -> Stri } fn claude_hooks(command: &str) -> Value { - hooks_for_events(HOOK_EVENTS, command, true) + hooks_for_events(CLAUDE_HOOK_EVENTS, command, true) } fn codex_hooks(command: &str) -> Value { - hooks_for_events(HOOK_EVENTS, command, true) + hooks_for_events(CODEX_HOOK_EVENTS, command, true) } // Generates Hermes YAML-compatible hook groups. Hermes expects direct command entries rather than diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index 4a9fa13db..58a51fe01 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -406,7 +406,7 @@ impl PreparedRun { // Injects Codex hook and provider configuration through repeated `--config` flags. Codex // reserves built-in provider IDs, so run mode installs a temporary provider alias instead of // overriding `model_providers.openai`. Uses `features.hooks=true` introduced in codex-cli - // 0.129. Requires codex-cli >= 0.129.0. + // current supported Codex releases. Requires codex-cli >= 0.143.0. fn prepare_codex(&mut self, gateway_url: &str) { // Codex resolves auth via `CodexAuth::from_auth_dot_json` (`codex-rs/login/src/auth/ // manager.rs`): `auth_mode=ApiKey` uses `OPENAI_API_KEY`, `auth_mode=Chatgpt` uses the diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index d5947b0b0..15439b4fa 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -10,10 +10,14 @@ mod completions_install; mod config; mod doctor; mod error; +mod file_io; mod gateway; +mod install_generation; mod installer; mod json_path; mod launcher; +mod mcp; +mod mcp_environment; mod model; mod model_pricing; mod plugin_install; @@ -22,6 +26,7 @@ mod plugins; mod server; mod session; mod setup; +mod sidecar; use std::process::ExitCode; @@ -37,6 +42,10 @@ use crate::config::{ // exit. Errors are printed once here so subcommands can return structured errors without also // owning process-level reporting. async fn main() -> ExitCode { + if let Err(error) = sidecar::join_sidecar_job_from_env() { + eprintln!("{error}"); + return ExitCode::FAILURE; + } match run().await { Ok(code) => code, Err(error) => { @@ -80,6 +89,7 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result { launcher::easy_path(CodingAgent::Hermes, command, Some(server)).await } + Command::Mcp => mcp::run(server).await, Command::Config(command) => run_config(command).await, Command::Plugins(command) => run_plugins(command, server), Command::ModelPricing(command) => run_pricing(command), @@ -198,7 +208,15 @@ async fn run_default(server_args: &ServerArgs) -> Result Result { + // Configuration is resolved before reading stdin, but the gateway process is not acquired + // until the session receives a valid MCP initialize request. + let gateway = gateway::GatewayPlan::resolve(server_args).await?; + let frames = transport::spawn_stdin_reader()?; + session::run(gateway, frames, tokio::io::stdout()).await?; + Ok(ExitCode::SUCCESS) +} + +fn default_mcp_bind() -> SocketAddr { + crate::sidecar::DEFAULT_BIND + .parse() + .expect("default MCP gateway bind is valid") +} + +#[cfg(test)] +fn request_requires_gateway(line: &str) -> bool { + protocol::evaluate_frame(line).requires_gateway +} + +#[cfg(test)] +async fn run_session( + bind: SocketAddr, + gateway_url: String, + sidecar_args: Vec, + bootstrap_fingerprint: String, + heartbeat_interval: std::time::Duration, + reader: R, + writer: W, +) -> Result<(), CliError> +where + R: tokio::io::AsyncBufRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + let lease = gateway::GatewayPlan::test_lease( + bind, + gateway_url, + sidecar_args, + bootstrap_fingerprint, + heartbeat_interval, + ); + session::serve_with_lease(lease, reader, writer).await +} + +#[cfg(test)] +use gateway::{ + maintain_gateway_with, maintain_gateway_with_generation, verify_bootstrap_generation, +}; +#[cfg(test)] +use protocol::{MCP_PROTOCOL_VERSION, jsonrpc_error, response_for}; +#[cfg(test)] +use session::serve_stdio; +#[cfg(test)] +use transport::{MAX_MCP_FRAME_BYTES, read_bounded_frame}; + +#[cfg(test)] +#[path = "../tests/coverage/mcp_tests.rs"] +mod tests; diff --git a/crates/cli/src/mcp/gateway.rs b/crates/cli/src/mcp/gateway.rs new file mode 100644 index 000000000..f5bddcecd --- /dev/null +++ b/crates/cli/src/mcp/gateway.rs @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Acquisition and liveness lease for the shared Codex gateway. + +#[cfg(test)] +use std::ffi::OsString; +use std::net::SocketAddr; +use std::time::Duration; + +#[cfg(test)] +use crate::config::CodingAgent; +use crate::config::ServerArgs; +use crate::error::CliError; +use crate::install_generation::InstallGeneration; +use crate::sidecar::{GatewayBootstrap, GatewaySpec}; + +const UNHEALTHY_CHECKS_BEFORE_RESTART: u8 = 3; + +#[derive(Clone)] +pub(super) struct GatewayPlan { + spec: GatewaySpec, + heartbeat_interval: Duration, + generation: Option, +} + +impl GatewayPlan { + pub(super) async fn resolve(server_args: &ServerArgs) -> Result { + let generation = tokio::task::spawn_blocking(InstallGeneration::capture_from_env) + .await + .map_err(|error| { + CliError::Launch(format!("MCP generation capture task failed: {error}")) + })? + .map_err(CliError::Launch)?; + let bind = server_args.bind.unwrap_or_else(super::default_mcp_bind); + let launch = crate::sidecar::resolve_codex_gateway(server_args, bind)?; + let heartbeat_interval = + crate::sidecar::plugin_heartbeat_interval().map_err(CliError::Launch)?; + Ok(Self { + spec: launch.gateway, + heartbeat_interval, + generation, + }) + } + + pub(super) async fn acquire(&self) -> Result { + let bootstrap = ensure_gateway(self.spec.clone(), self.generation.clone()).await?; + let plan = self.clone(); + let monitor = tokio::spawn(async move { plan.monitor(bootstrap.endpoint.url).await }); + Ok(GatewayLease { monitor }) + } + + async fn monitor(self, gateway_url: String) -> Result<(), CliError> { + let health_spec = self.spec.clone(); + let restart_spec = self.spec.clone(); + let restart_generation = self.generation.clone(); + let verify_generation = self.generation; + maintain_gateway_with_generation( + self.spec.bind(), + gateway_url, + self.heartbeat_interval, + move |url| { + let spec = health_spec.clone(); + async move { + tokio::task::spawn_blocking(move || spec.is_healthy(&url)) + .await + .map_err(|error| { + CliError::Launch(format!("gateway heartbeat task failed: {error}")) + }) + } + }, + move |_bind| ensure_gateway(restart_spec.clone(), restart_generation.clone()), + move || { + let generation = verify_generation.clone(); + async move { verify_generation_async(generation).await } + }, + ) + .await + } + + #[cfg(test)] + pub(super) fn test_lease( + bind: SocketAddr, + gateway_url: String, + sidecar_args: Vec, + bootstrap_fingerprint: String, + heartbeat_interval: Duration, + ) -> GatewayLease { + let plan = Self { + spec: GatewaySpec::new(CodingAgent::Codex, bind) + .with_launch_args(sidecar_args) + .with_fingerprint(bootstrap_fingerprint), + heartbeat_interval, + generation: None, + }; + let monitor = tokio::spawn(async move { plan.monitor(gateway_url).await }); + GatewayLease { monitor } + } +} + +/// An active liveness lease. Dropping it stops heartbeats immediately. +pub(super) struct GatewayLease { + monitor: tokio::task::JoinHandle>, +} + +impl GatewayLease { + pub(super) async fn wait(&mut self) -> Result<(), CliError> { + (&mut self.monitor).await.map_err(|error| { + CliError::Launch(format!("gateway maintenance task failed: {error}")) + })? + } +} + +impl Drop for GatewayLease { + fn drop(&mut self) { + self.monitor.abort(); + } +} + +async fn ensure_gateway( + spec: GatewaySpec, + generation: Option, +) -> Result { + tokio::task::spawn_blocking(move || { + if let Some(generation) = generation.as_ref() { + generation.verify_current()?; + } + let bootstrap = spec.ensure()?; + if let Some(generation) = generation.as_ref() { + verify_bootstrap_generation(generation)?; + } + Ok(bootstrap) + }) + .await + .map_err(|error| CliError::Launch(format!("gateway bootstrap task failed: {error}")))? + .map_err(CliError::Launch) +} + +pub(super) fn verify_bootstrap_generation(generation: &InstallGeneration) -> Result<(), String> { + // A replacement MCP may already be reusing a compatible gateway started between the two + // generation checks. Leave a ready gateway for reuse or normal idle cleanup. Failures before + // readiness remain armed and are terminated by the sidecar launcher. + generation.verify_current() +} + +async fn verify_generation_async(generation: Option) -> Result<(), CliError> { + tokio::task::spawn_blocking(move || { + generation + .as_ref() + .map_or(Ok(()), InstallGeneration::verify_current) + }) + .await + .map_err(|error| CliError::Launch(format!("MCP generation verification task failed: {error}")))? + .map_err(CliError::Launch) +} + +#[cfg(test)] +pub(super) async fn maintain_gateway_with( + bind: SocketAddr, + gateway_url: String, + heartbeat_interval: Duration, + healthy: H, + restart: R, +) -> Result<(), CliError> +where + H: FnMut(String) -> HFuture, + HFuture: std::future::Future>, + R: FnMut(SocketAddr) -> RFuture, + RFuture: std::future::Future>, +{ + maintain_gateway_with_generation( + bind, + gateway_url, + heartbeat_interval, + healthy, + restart, + || async { Ok(()) }, + ) + .await +} + +pub(super) async fn maintain_gateway_with_generation( + bind: SocketAddr, + mut gateway_url: String, + heartbeat_interval: Duration, + mut healthy: H, + mut restart: R, + mut verify_generation: G, +) -> Result<(), CliError> +where + H: FnMut(String) -> HFuture, + HFuture: std::future::Future>, + R: FnMut(SocketAddr) -> RFuture, + RFuture: std::future::Future>, + G: FnMut() -> GFuture, + GFuture: std::future::Future>, +{ + let mut heartbeat = tokio::time::interval(heartbeat_interval); + let mut recovery = RecoveryState::default(); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + heartbeat.tick().await; + loop { + heartbeat.tick().await; + verify_generation().await?; + if healthy(gateway_url.clone()).await? { + recovery.record_healthy(); + continue; + } + if !recovery.record_failure()? { + continue; + } + verify_generation().await?; + let bootstrap = restart(bind).await?; + gateway_url = bootstrap.endpoint.url; + recovery.record_recovery(bootstrap.started); + } +} + +#[derive(Default)] +struct RecoveryState { + consecutive_failures: u8, + restarted: bool, +} + +impl RecoveryState { + fn record_healthy(&mut self) { + self.consecutive_failures = 0; + } + + /// Returns true when the caller should coordinate recovery. + fn record_failure(&mut self) -> Result { + self.consecutive_failures = self.consecutive_failures.saturating_add(1); + if self.consecutive_failures < UNHEALTHY_CHECKS_BEFORE_RESTART { + return Ok(false); + } + if self.restarted { + return Err(CliError::Launch( + "shared Relay gateway became unhealthy after its coordinated restart".into(), + )); + } + Ok(true) + } + + fn record_recovery(&mut self, started: bool) { + self.consecutive_failures = 0; + self.restarted |= started; + } +} + +#[cfg(test)] +#[path = "../../tests/coverage/mcp_gateway_tests.rs"] +mod tests; diff --git a/crates/cli/src/mcp/protocol.rs b/crates/cli/src/mcp/protocol.rs new file mode 100644 index 000000000..80d7c3df1 --- /dev/null +++ b/crates/cli/src/mcp/protocol.rs @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Minimal MCP/JSON-RPC protocol implemented by the lifecycle client. + +use serde_json::{Value, json}; + +pub(super) const MCP_PROTOCOL_VERSION: &str = "2025-06-18"; + +/// Result of decoding one newline-delimited MCP frame. +pub(super) struct FrameAction { + pub(super) response: Option, + pub(super) requires_gateway: bool, +} + +/// Parse a frame once and derive both its protocol response and bootstrap requirement. +pub(super) fn evaluate_frame(frame: &str) -> FrameAction { + match serde_json::from_str::(frame) { + Ok(message) => FrameAction { + requires_gateway: is_valid_initialize(&message), + response: response_for(&message), + }, + Err(_) => FrameAction { + response: Some(jsonrpc_error(Value::Null, -32700, "Parse error")), + requires_gateway: false, + }, + } +} + +fn is_valid_initialize(message: &Value) -> bool { + valid_jsonrpc_request(message) + && message.get("method").and_then(Value::as_str) == Some("initialize") + && message + .pointer("/params/protocolVersion") + .and_then(Value::as_str) + .is_some() +} + +fn valid_jsonrpc_request(message: &Value) -> bool { + message.is_object() + && message.get("jsonrpc").and_then(Value::as_str) == Some("2.0") + && message.get("id").is_some() +} + +pub(super) fn response_for(message: &Value) -> Option { + let id = message.get("id").cloned(); + if !message.is_object() || message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Some(jsonrpc_error( + id.unwrap_or(Value::Null), + -32600, + "Invalid Request", + )); + } + let id = id?; + let method = message.get("method").and_then(Value::as_str); + match method { + Some("initialize") => { + let Some(requested_protocol) = message + .pointer("/params/protocolVersion") + .and_then(Value::as_str) + else { + return Some(jsonrpc_error(id, -32602, "Missing protocolVersion")); + }; + let protocol_version = if requested_protocol == MCP_PROTOCOL_VERSION { + requested_protocol + } else { + MCP_PROTOCOL_VERSION + }; + Some(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": protocol_version, + "capabilities": { "tools": {} }, + "serverInfo": { + "name": "nemo-relay", + "version": env!("CARGO_PKG_VERSION") + } + } + })) + } + Some("tools/list") => Some(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "tools": [] } + })), + Some("ping") => Some(json!({ + "jsonrpc": "2.0", + "id": id, + "result": {} + })), + Some(_) => Some(jsonrpc_error(id, -32601, "Method not found")), + None => Some(jsonrpc_error(id, -32600, "Invalid Request")), + } +} + +pub(super) fn jsonrpc_error(id: Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + }) +} diff --git a/crates/cli/src/mcp/session.rs b/crates/cli/src/mcp/session.rs new file mode 100644 index 000000000..b84871660 --- /dev/null +++ b/crates/cli/src/mcp/session.rs @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MCP stdio session coordinated with a shared-gateway liveness lease. + +use tokio::io::{AsyncWrite, AsyncWriteExt}; + +use super::gateway::{GatewayLease, GatewayPlan}; +use super::protocol::{FrameAction, evaluate_frame}; +use super::transport::FrameReceiver; +use crate::error::CliError; + +pub(super) async fn run( + gateway: GatewayPlan, + mut frames: FrameReceiver, + mut writer: W, +) -> Result<(), CliError> +where + W: AsyncWrite + Unpin, +{ + let mut lease: Option = None; + loop { + let received = match lease.as_mut() { + Some(active) => tokio::select! { + frame = frames.recv() => frame, + result = active.wait() => return result, + }, + None => frames.recv().await, + }; + let Some(frame) = received else { + return Ok(()); + }; + let frame = frame?; + let action = evaluate_frame(&frame); + if action.requires_gateway && lease.is_none() { + lease = Some(gateway.acquire().await?); + } + write_response(action, &mut writer).await?; + } +} + +async fn write_response(action: FrameAction, writer: &mut W) -> Result<(), CliError> +where + W: AsyncWrite + Unpin, +{ + let Some(response) = action.response else { + return Ok(()); + }; + let mut encoded = serde_json::to_vec(&response) + .map_err(|error| CliError::Launch(format!("failed to encode MCP response: {error}")))?; + encoded.push(b'\n'); + writer.write_all(&encoded).await?; + writer.flush().await?; + Ok(()) +} + +#[cfg(test)] +pub(super) async fn serve_stdio(mut reader: R, mut writer: W) -> Result<(), CliError> +where + R: tokio::io::AsyncBufRead + Unpin, + W: AsyncWrite + Unpin, +{ + use tokio::io::AsyncBufReadExt; + + let mut line = String::new(); + loop { + line.clear(); + if reader.read_line(&mut line).await? == 0 { + return Ok(()); + } + write_response(evaluate_frame(&line), &mut writer).await?; + } +} + +#[cfg(test)] +pub(super) async fn serve_with_lease( + mut lease: GatewayLease, + reader: R, + writer: W, +) -> Result<(), CliError> +where + R: tokio::io::AsyncBufRead + Unpin, + W: AsyncWrite + Unpin, +{ + tokio::select! { + result = serve_stdio(reader, writer) => result, + result = lease.wait() => result, + } +} diff --git a/crates/cli/src/mcp/transport.rs b/crates/cli/src/mcp/transport.rs new file mode 100644 index 000000000..9cf2ec374 --- /dev/null +++ b/crates/cli/src/mcp/transport.rs @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Bounded newline framing for MCP stdio. + +use crate::error::CliError; + +pub(super) const MAX_MCP_FRAME_BYTES: usize = 1024 * 1024; +pub(super) type FrameReceiver = tokio::sync::mpsc::Receiver>; + +/// Read stdin on a plain thread so EOF remains dependable across Tokio platforms. +pub(super) fn spawn_stdin_reader() -> Result { + let (sender, receiver) = tokio::sync::mpsc::channel(16); + std::thread::Builder::new() + .name("nemo-relay-mcp-stdin".into()) + .spawn(move || { + let stdin = std::io::stdin(); + let mut stdin = stdin.lock(); + loop { + let mut frame = Vec::new(); + match read_bounded_frame(&mut stdin, &mut frame, MAX_MCP_FRAME_BYTES) { + Ok(0) => return, + Ok(_) => { + let line = String::from_utf8(frame).map_err(|error| { + std::io::Error::new(std::io::ErrorKind::InvalidData, error) + }); + if sender.blocking_send(line).is_err() { + return; + } + } + Err(error) => { + let _ = sender.blocking_send(Err(error)); + return; + } + } + } + }) + .map_err(|error| CliError::Launch(format!("failed to start MCP stdin reader: {error}")))?; + Ok(receiver) +} + +pub(super) fn read_bounded_frame( + reader: &mut R, + frame: &mut Vec, + limit: usize, +) -> std::io::Result { + loop { + let (consumed, complete) = { + let available = reader.fill_buf()?; + if available.is_empty() { + return Ok(frame.len()); + } + let consumed = available + .iter() + .position(|byte| *byte == b'\n') + .map_or(available.len(), |index| index + 1); + if frame.len().saturating_add(consumed) > limit { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("MCP frame exceeds the {limit}-byte limit"), + )); + } + frame.extend_from_slice(&available[..consumed]); + (consumed, available[consumed - 1] == b'\n') + }; + reader.consume(consumed); + if complete { + return Ok(frame.len()); + } + } +} diff --git a/crates/cli/src/mcp_environment.rs b/crates/cli/src/mcp_environment.rs new file mode 100644 index 000000000..781da7d25 --- /dev/null +++ b/crates/cli/src/mcp_environment.rs @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Environment names shared by Codex MCP generation and gateway compatibility checks. + +use std::collections::BTreeSet; + +use serde_json::Value; + +const BASE_MCP_ENV_VARS: &[&str] = &[ + "ANTHROPIC_API_KEY", + "APPDATA", + "AWS_ACCESS_KEY_ID", + "AWS_ALLOW_HTTP", + "AWS_CONFIG_FILE", + "AWS_DEFAULT_REGION", + "AWS_ENDPOINT_URL", + "AWS_PROFILE", + "AWS_REGION", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SHARED_CREDENTIALS_FILE", + "HOME", + "HTTPS_PROXY", + "HTTP_PROXY", + "LOCALAPPDATA", + "NEMO_RELAY_ANTHROPIC_BASE_URL", + "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", + "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES", + "NEMO_RELAY_OPENAI_BASE_URL", + "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", + "NEMO_RELAY_PYTHON", + "NO_PROXY", + "OPENAI_API_KEY", + "OTEL_EXPORTER_OTLP_COMPRESSION", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_TIMEOUT", + "OTEL_EXPORTER_OTLP_TRACES_COMPRESSION", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", + "OTEL_RESOURCE_ATTRIBUTES", + "OTEL_SDK_DISABLED", + "OTEL_SERVICE_NAME", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "TEMP", + "TMPDIR", + "USERPROFILE", + "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", + "http_proxy", + "https_proxy", + "no_proxy", +]; + +const BLOCKED_MCP_ENV_VARS: &[&str] = &[ + "NEMO_RELAY_BINDING_KIND", + "NEMO_RELAY_BOOTSTRAP_AGENT", + "NEMO_RELAY_BOOTSTRAP_FINGERPRINT", + "NEMO_RELAY_BOOTSTRAP_STATE_DIR", + "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", + "NEMO_RELAY_CONFIG_SCOPE", + "NEMO_RELAY_FAIL_CLOSED", + "NEMO_RELAY_GATEWAY_BIND", + "NEMO_RELAY_GATEWAY_URL", + "NEMO_RELAY_HOST_SOCKET", + "NEMO_RELAY_MCP_GENERATION_FILE", + "NEMO_RELAY_NATIVE_ABI_VERSION", + "NEMO_RELAY_PLUGIN_BINARY", + "NEMO_RELAY_PLUGIN_BIND", + "NEMO_RELAY_SIDECAR_JOB_NAME", + "NEMO_RELAY_PLUGIN_CONFIG_PATH", + "NEMO_RELAY_PLUGIN_GATEWAY_URL", + "NEMO_RELAY_PLUGIN_ID", + "NEMO_RELAY_RUNTIME_OWNER", + "NEMO_RELAY_WORKER_ENDPOINT_FILE", + "NEMO_RELAY_WORKER_ID", + "NEMO_RELAY_WORKER_SOCKET", + "NEMO_RELAY_WORKER_TOKEN", +]; + +pub(crate) fn forwarded_names( + environment: impl IntoIterator, + config: Option<&Value>, +) -> Vec { + forwarded_names_for_platform(environment, config, cfg!(windows)) +} + +pub(crate) fn forwarded_names_for_platform( + environment: impl IntoIterator, + config: Option<&Value>, + windows: bool, +) -> Vec { + let mut names = BASE_MCP_ENV_VARS + .iter() + .map(|name| (*name).to_string()) + .collect::>(); + for name in environment { + if prefix_allowed(&name, windows) && !blocked(&name) { + insert_name(&mut names, name, windows); + } + } + if let Some(config) = config { + collect_config_names(config, &mut names, windows); + } + names.into_iter().collect() +} + +fn prefix_allowed(name: &str, windows: bool) -> bool { + ["NEMO_RELAY_", "OTEL_", "AWS_"].iter().any(|prefix| { + if windows { + starts_with_ignore_ascii_case(name, prefix) + } else { + name.starts_with(prefix) + } + }) +} + +fn blocked(name: &str) -> bool { + BLOCKED_MCP_ENV_VARS + .iter() + .any(|blocked| name.eq_ignore_ascii_case(blocked)) + || starts_with_ignore_ascii_case(name, "NEMO_RELAY_TEST_") +} + +fn starts_with_ignore_ascii_case(value: &str, prefix: &str) -> bool { + value + .get(..prefix.len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix)) +} + +fn insert_name(names: &mut BTreeSet, name: String, windows: bool) { + if !windows + || !names + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&name)) + { + names.insert(name); + } +} + +fn collect_config_names(value: &Value, names: &mut BTreeSet, windows: bool) { + match value { + Value::Object(object) => { + for (key, value) in object { + match key.as_str() { + "header_env" => { + if let Some(headers) = value.as_object() { + for name in headers.values().filter_map(Value::as_str) { + if !name.is_empty() && !blocked(name) { + insert_name(names, name.to_owned(), windows); + } + } + } + } + "secret_access_key_var" | "session_token_var" => { + if let Some(name) = value.as_str() + && !name.is_empty() + && !blocked(name) + { + insert_name(names, name.to_owned(), windows); + } + } + _ => collect_config_names(value, names, windows), + } + } + } + Value::Array(values) => { + for value in values { + collect_config_names(value, names, windows); + } + } + _ => {} + } +} diff --git a/crates/cli/src/model.rs b/crates/cli/src/model.rs index 4d7e8a792..32b1bcfe4 100644 --- a/crates/cli/src/model.rs +++ b/crates/cli/src/model.rs @@ -30,7 +30,7 @@ pub(crate) enum NormalizedEvent { AgentEnded(SessionEvent), /// Conversation-turn boundary that the gateway uses to snapshot ATIF without closing the /// agent scope. Emitted alongside `LlmHint` for `Stop` hooks (Claude/Codex). - /// Required for codex 0.129 transparent runs because codex has no `SessionEnd`-equivalent + /// Required for Codex transparent runs because Codex has no reliable `SessionEnd`-equivalent /// event — the last `Stop` of the session leaves an up-to-date ATIF on disk. Multi-turn /// sessions write progressively complete trajectories; the underlying `AtifExporter::export()` /// is non-destructive so each snapshot is a cumulative superset of prior writes. diff --git a/crates/cli/src/plugin_install/host.rs b/crates/cli/src/plugin_install/host.rs index d8c9f0928..709194cb9 100644 --- a/crates/cli/src/plugin_install/host.rs +++ b/crates/cli/src/plugin_install/host.rs @@ -7,6 +7,7 @@ use std::env; use std::path::{Path, PathBuf}; use std::process::Command; +use semver::Version; use serde_json::Value; #[cfg(test)] @@ -14,12 +15,12 @@ use serde_json::json; use crate::config::PluginHost; -use super::state::{PluginInstallOptions, PluginLayout}; +use super::state::PluginInstallOptions; use super::{MARKETPLACE_NAME, PLUGIN_NAME, RELAY_COMMAND, host_cli}; pub(super) fn run_host_marketplace_registration( host: PluginHost, - layout: &PluginLayout, + marketplace_root: &Path, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { @@ -29,7 +30,7 @@ pub(super) fn run_host_marketplace_registration( "plugin".into(), "marketplace".into(), "add".into(), - layout.marketplace_root.display().to_string(), + marketplace_root.display().to_string(), ], options, runner, @@ -291,8 +292,9 @@ pub(super) fn require_relay( return Ok(PathBuf::from(RELAY_COMMAND)); } runner - .resolve_executable(RELAY_COMMAND)? - .ok_or_else(|| "required `nemo-relay` executable was not found on PATH".into()) + .current_executable() + .map(|path| path.canonicalize().unwrap_or(path)) + .map(crate::plugin_shim::portable_executable_path) } pub(super) fn validate_relay_plugin_shim( @@ -315,6 +317,26 @@ pub(super) fn validate_relay_plugin_shim( } } +pub(super) fn validate_relay_mcp( + relay: &Path, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + if options.dry_run { + return Ok(()); + } + let args = ["mcp".into(), "--help".into()]; + let status = runner.run_quiet(relay, &args)?; + if status == 0 { + Ok(()) + } else { + Err(format!( + "{} failed with exit code {status}; the Codex plugin requires native `nemo-relay mcp` support", + format_command(&relay.display().to_string(), &args) + )) + } +} + pub(super) fn require_host_cli( host: PluginHost, options: &PluginInstallOptions, @@ -330,6 +352,33 @@ pub(super) fn require_host_cli( .ok_or_else(|| format!("required `{cli}` CLI was not found on PATH")) } +pub(super) fn validate_codex_version( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + if options.dry_run { + return Ok(()); + } + let output = run_capture_command("codex", &["--version".into()], options, runner)?; + let version = parse_codex_version(&output.stdout).ok_or_else(|| { + format!( + "could not parse `codex --version` output {:?}; the Relay plugin requires codex-cli 0.143.0 or newer", + output.stdout.trim() + ) + })?; + if version >= Version::new(0, 143, 0) { + Ok(()) + } else { + Err(format!( + "codex-cli {version} is unsupported; the Relay plugin requires codex-cli 0.143.0 or newer" + )) + } +} + +fn parse_codex_version(raw: &str) -> Option { + Version::parse(raw.trim().strip_prefix("codex-cli ")?).ok() +} + pub(super) fn run_command( program: &str, args: &[String], @@ -448,6 +497,7 @@ impl CommandOutput { } pub(super) trait CommandRunner { + fn current_executable(&self) -> Result; fn resolve_executable(&self, command: &str) -> Result, String>; fn run(&self, program: &Path, args: &[String]) -> Result; fn run_quiet(&self, program: &Path, args: &[String]) -> Result; @@ -457,6 +507,11 @@ pub(super) trait CommandRunner { pub(super) struct RealCommandRunner; impl CommandRunner for RealCommandRunner { + fn current_executable(&self) -> Result { + env::current_exe() + .map_err(|error| format!("failed to resolve current nemo-relay executable: {error}")) + } + fn resolve_executable(&self, command: &str) -> Result, String> { Ok(find_executable(command)) } diff --git a/crates/cli/src/plugin_install/marketplace.rs b/crates/cli/src/plugin_install/marketplace.rs index 0169fd435..5009dfe8d 100644 --- a/crates/cli/src/plugin_install/marketplace.rs +++ b/crates/cli/src/plugin_install/marketplace.rs @@ -3,11 +3,14 @@ //! Generated local marketplace and plugin manifest files. +use std::env; use std::fs; +use std::path::Path; use serde_json::{Value, json}; use crate::config::{CodingAgent, PluginHost}; +use crate::install_generation::{GENERATION_FILE_ENV, write_new_generation}; use crate::installer::generated_hooks; use super::state::{PluginInstallOptions, PluginLayout, remove_path, write_json}; @@ -16,11 +19,26 @@ use super::{MARKETPLACE_NAME, PLUGIN_NAME}; pub(super) fn write_plugin_marketplace( host: PluginHost, layout: &PluginLayout, + relay: &Path, + options: &PluginInstallOptions, +) -> Result<(), String> { + write_plugin_marketplace_for_generation(host, layout, relay, &layout.generation_fence, options) +} + +pub(super) fn write_plugin_marketplace_for_generation( + host: PluginHost, + layout: &PluginLayout, + relay: &Path, + active_generation_fence: &Path, options: &PluginInstallOptions, ) -> Result<(), String> { if options.dry_run { println!("write {}", layout.marketplace_manifest.display()); println!("write {}", layout.plugin_manifest.display()); + if matches!(host, PluginHost::Codex) { + println!("write {}", layout.mcp_config.display()); + println!("write {}", layout.generation_fence.display()); + } if plugin_has_hooks_template(host) { println!("write {}", layout.hooks_path.display()); } @@ -41,8 +59,14 @@ pub(super) fn write_plugin_marketplace( } write_json(&layout.marketplace_manifest, &marketplace_manifest(host))?; write_json(&layout.plugin_manifest, &plugin_manifest(host))?; + if matches!(host, PluginHost::Codex) { + write_new_generation(&layout.generation_fence)?; + } + if let Some(mcp_config) = plugin_mcp_config(host, relay, active_generation_fence)? { + write_json(&layout.mcp_config, &mcp_config)?; + } if plugin_has_hooks_template(host) { - write_json(&layout.hooks_path, &plugin_hooks(host))?; + write_json(&layout.hooks_path, &plugin_hooks(host, relay))?; } Ok(()) } @@ -89,7 +113,9 @@ pub(super) fn marketplace_manifest(host: PluginHost) -> Value { pub(super) fn plugin_manifest(host: PluginHost) -> Value { let description = match host { - PluginHost::Codex => "Codex hooks that forward canonical lifecycle payloads to nemo-relay.", + PluginHost::Codex => { + "Native Relay gateway lifecycle and Codex hooks for complete local observability." + } PluginHost::ClaudeCode => { "Claude Code hooks that forward canonical lifecycle payloads to nemo-relay." } @@ -114,10 +140,11 @@ pub(super) fn plugin_manifest(host: PluginHost) -> Value { "keywords": keywords }); if matches!(host, PluginHost::Codex) { + manifest["mcpServers"] = json!("./.mcp.json"); manifest["interface"] = json!({ "displayName": "NeMo Relay Plugin", - "shortDescription": "Forward Codex lifecycle hooks to a local NeMo Relay sidecar.", - "longDescription": "Installs command hooks that preserve Codex hook payloads and forward them to nemo-relay for agent, subagent, tool, and lifecycle observability. Full LLM capture also requires sidecar provider routing.", + "shortDescription": "Run the native Relay gateway and capture Codex lifecycle events.", + "longDescription": "Starts the native nemo-relay gateway through a required lifecycle-bound MCP server, routes model traffic through it, and installs command hooks that preserve canonical Codex lifecycle payloads.", "developerName": "NVIDIA", "category": "Coding", "capabilities": ["Read"], @@ -129,11 +156,45 @@ pub(super) fn plugin_manifest(host: PluginHost) -> Value { manifest } -pub(super) fn plugin_hooks(host: PluginHost) -> Value { - match host { - PluginHost::Codex => { - generated_hooks(CodingAgent::Codex, "nemo-relay plugin-shim hook codex") +pub(super) fn plugin_mcp_config( + host: PluginHost, + relay: &Path, + generation_fence: &Path, +) -> Result, String> { + if !matches!(host, PluginHost::Codex) { + return Ok(None); + } + let generation_fence = absolute_or_self(generation_fence); + Ok(Some(json!({ + "nemo-relay": { + "command": relay, + "args": ["mcp"], + "env": { + "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632", + (GENERATION_FILE_ENV): generation_fence + }, + "env_vars": plugin_mcp_env_vars()?, + "required": true, + "startup_timeout_sec": 20 } + }))) +} + +fn absolute_or_self(path: &Path) -> std::path::PathBuf { + if path.is_absolute() { + return path.to_owned(); + } + env::current_dir() + .map(|current| current.join(path)) + .unwrap_or_else(|_| path.to_owned()) +} + +pub(super) fn plugin_hooks(host: PluginHost, relay: &Path) -> Value { + match host { + PluginHost::Codex => generated_hooks( + CodingAgent::Codex, + &crate::plugin_shim::codex_plugin_hook_command(relay), + ), PluginHost::ClaudeCode => generated_hooks( CodingAgent::ClaudeCode, "nemo-relay plugin-shim hook claude", @@ -142,10 +203,22 @@ pub(super) fn plugin_hooks(host: PluginHost) -> Value { } } +pub(super) fn plugin_mcp_env_vars() -> Result, String> { + let environment = env::vars_os().filter_map(|(name, _)| name.into_string().ok()); + let config = crate::config::user_plugin_runtime_config().map_err(|error| error.to_string())?; + Ok(plugin_mcp_env_vars_from(environment, config.as_ref())) +} + +pub(super) fn plugin_mcp_env_vars_from( + environment: impl IntoIterator, + config: Option<&Value>, +) -> Vec { + crate::mcp_environment::forwarded_names(environment, config) +} + pub(super) fn plugin_has_hooks_template(host: PluginHost) -> bool { match host { - PluginHost::Codex => false, - PluginHost::ClaudeCode => true, + PluginHost::Codex | PluginHost::ClaudeCode => true, PluginHost::All => unreachable!("all is expanded before hook generation"), } } diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/plugin_install/mod.rs index 886e12b2c..25bc304c1 100644 --- a/crates/cli/src/plugin_install/mod.rs +++ b/crates/cli/src/plugin_install/mod.rs @@ -5,9 +5,11 @@ mod host; mod marketplace; +mod operation_lock; mod setup; mod state; +use std::fs; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::sync::mpsc::{self, Receiver}; @@ -18,16 +20,21 @@ use serde_json::{Value, json}; use crate::config::{InstallCommand, PluginHost, UninstallCommand}; use crate::error::CliError; +use crate::install_generation::{GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration}; use host::{ CommandRunner, RealCommandRunner, host_registration_report, require_host_cli, require_relay, run_host_marketplace_registration, run_host_marketplace_removal, run_host_plugin_registration, - run_host_plugin_removal, validate_relay_plugin_shim, + run_host_plugin_removal, validate_relay_mcp, validate_relay_plugin_shim, }; -use marketplace::{marketplace_manifest, plugin_manifest, write_plugin_marketplace}; +use marketplace::{ + marketplace_manifest, plugin_hooks, plugin_manifest, plugin_mcp_config, + write_plugin_marketplace, write_plugin_marketplace_for_generation, +}; +use operation_lock::{DEFAULT_OPERATION_LOCK_TIMEOUT, PluginOperationLock}; use setup::{ - PluginSetupRunner, RealPluginSetupRunner, run_plugin_doctor, run_plugin_doctor_json, - run_plugin_setup, run_plugin_uninstall, + PluginSetupRunner, PluginSetupSnapshot, RealPluginSetupRunner, run_plugin_doctor, + run_plugin_doctor_json, run_plugin_setup, run_plugin_uninstall, }; use state::{ CanonicalizeOrSelf, HostRegistrationProgress, HostSelectionMode, PluginInstallOptions, @@ -41,6 +48,18 @@ pub(super) const PLUGIN_NAME: &str = "nemo-relay-plugin"; pub(super) const RELAY_COMMAND: &str = "nemo-relay"; const DEFAULT_HOST_PLUGIN_READINESS_TIMEOUT: Duration = Duration::from_secs(5); +fn default_operation_lock_dir() -> Result { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .map(CanonicalizeOrSelf::canonicalize_or_self) + .map(|home| home.join(".nemo-relay").join("plugin-operations")) + .ok_or_else(|| { + "cannot determine the per-user plugin operation lock directory; set HOME or USERPROFILE" + .into() + }) +} + /// One non-mutating readiness check for an installed coding-agent plugin. /// /// This is deliberately independent from the CLI doctor's status type so the installer can @@ -132,6 +151,7 @@ fn spawn_default_host_plugin_readiness( std::thread::spawn(move || { let options = PluginInstallOptions { install_dir, + operation_lock_dir: PathBuf::new(), force: false, dry_run: false, skip_doctor: true, @@ -190,11 +210,17 @@ fn failed_host_plugin_readiness( } pub(crate) fn install(command: InstallCommand) -> Result { + let operation_lock_dir = if command.dry_run { + PathBuf::new() + } else { + default_operation_lock_dir().map_err(CliError::Install)? + }; let options = PluginInstallOptions { install_dir: command .install_dir .unwrap_or_else(default_install_dir) .canonicalize_or_self(), + operation_lock_dir, force: command.force, dry_run: command.dry_run, skip_doctor: command.skip_doctor, @@ -208,11 +234,17 @@ pub(crate) fn install(command: InstallCommand) -> Result { } pub(crate) fn uninstall(command: UninstallCommand) -> Result { + let operation_lock_dir = if command.dry_run { + PathBuf::new() + } else { + default_operation_lock_dir().map_err(CliError::Install)? + }; let options = PluginInstallOptions { install_dir: command .install_dir .unwrap_or_else(default_install_dir) .canonicalize_or_self(), + operation_lock_dir, force: false, dry_run: command.dry_run, skip_doctor: true, @@ -234,6 +266,7 @@ pub(crate) fn doctor( install_dir: install_dir .unwrap_or_else(default_install_dir) .canonicalize_or_self(), + operation_lock_dir: PathBuf::new(), force: false, dry_run: false, skip_doctor: true, @@ -353,55 +386,208 @@ fn install_host( options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + install_host_with_operation_timeout( + host, + options, + runner, + setup_runner, + DEFAULT_OPERATION_LOCK_TIMEOUT, + ) +} + +fn install_host_with_operation_timeout( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, + lock_timeout: Duration, +) -> Result<(), String> { + let _operation_lock = (!options.dry_run) + .then(|| { + PluginOperationLock::acquire( + host, + &options.operation_lock_dir, + &options.install_dir, + lock_timeout, + ) + }) + .transpose()?; + install_host_locked(host, options, runner, setup_runner) +} + +fn install_host_locked( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, ) -> Result<(), String> { let relay = require_relay(options, runner)?; validate_relay_plugin_shim(&relay, options, runner)?; + if matches!(host, PluginHost::Codex) { + validate_relay_mcp(&relay, options, runner)?; + } require_host_cli(host, options, runner)?; + if matches!(host, PluginHost::Codex) { + host::validate_codex_version(options, runner)?; + } let layout = PluginLayout::new(host, &options.install_dir); - if options.force { + let codex_preflight = if !options.dry_run && matches!(host, PluginHost::Codex) { + Some(prepare_codex_install(&layout, options, runner)?) + } else { + None + }; + if !options.force + && codex_preflight + .as_ref() + .is_some_and(|preflight| preflight.previous_install_exists) + { + return Err(existing_codex_install_requires_force_error()); + } + let mut force_snapshot = None; + let staged = if options.force && !options.dry_run && matches!(host, PluginHost::Codex) { + let preflight = codex_preflight.expect("Codex force install has preflight state"); + let staged = stage_plugin_marketplace(host, &relay, &layout, options)?; + match begin_force_replacement(host, &layout, preflight, options, runner, setup_runner) { + Ok(mut snapshot) => { + if let Err(error) = setup_runner.refresh_gateway(host) { + staged.cleanup(); + return restore_force_replacement_after_error( + host, + &layout, + &mut snapshot, + options, + runner, + setup_runner, + error, + ); + } + force_snapshot = Some(snapshot); + Some(staged) + } + Err(error) => { + staged.cleanup(); + return Err(error); + } + } + } else { + None + }; + if options.force && staged.is_none() { + if !options.dry_run && matches!(host, PluginHost::Codex) { + setup_runner.refresh_gateway(host)?; + } force_cleanup_existing_install(host, &layout, options, runner, setup_runner)?; } - write_plugin_marketplace(host, &layout, options)?; + if let Some(staged) = staged.as_ref() { + if let Err(error) = staged.promote(&layout) { + return restore_force_replacement_after_error( + host, + &layout, + force_snapshot.as_mut().expect("force snapshot exists"), + options, + runner, + setup_runner, + error, + ); + } + force_snapshot + .as_mut() + .expect("force snapshot exists") + .replacement_promoted = true; + staged.cleanup(); + } else { + write_plugin_marketplace(host, &layout, &relay, options)?; + } if let Err(error) = write_state(&layout, options) { - if let Err(cleanup_error) = remove_path(&layout.marketplace_root, options) { - return Err(format!( - "{error}; additionally failed to remove generated marketplace {}: {cleanup_error}", - layout.marketplace_root.display() - )); + let _replacement_retirement = if force_snapshot.is_some() { + match retire_replacement_before_rollback(host, &layout, options, setup_runner) { + Ok(retirement) => retirement, + Err(retirement_error) => { + return Err(format!( + "{error}; refusing destructive rollback because the replacement MCP generation could not be retired: {retirement_error}" + )); + } + } + } else { + None + }; + let cleanup_error = remove_path(&layout.marketplace_root, options).err(); + let restore_error = force_snapshot.as_mut().and_then(|snapshot| { + restore_force_replacement(host, &layout, snapshot, options, runner, setup_runner).err() + }); + let errors = [cleanup_error, restore_error] + .into_iter() + .flatten() + .collect::>(); + if !errors.is_empty() { + return Err(format!("{error}; additionally {}", errors.join("; "))); } return Err(error); } let mut registration = HostRegistrationProgress::default(); - let mut setup_attempted = false; + let mut setup_installed = false; let result = (|| { - run_host_marketplace_registration(host, &layout, options, runner)?; + run_host_marketplace_registration(host, &layout.marketplace_root, options, runner)?; registration.host_marketplace_added = true; run_host_plugin_registration(host, options, runner)?; registration.host_plugin_added = true; - setup_attempted = true; - run_plugin_setup(host, options, setup_runner)?; + if !matches!(host, PluginHost::Codex) { + setup_installed = true; + } + run_plugin_setup(host, &layout, options, setup_runner)?; + setup_installed = true; mark_plugin_setup_installed(host, &layout, options)?; if !options.skip_doctor { - run_plugin_doctor(host, options, setup_runner)?; + run_plugin_doctor(host, &layout.plugin_root, options, setup_runner)?; } Ok(()) })(); if let Err(error) = result { - if let Err(rollback_error) = rollback_install( + let replacement_may_be_live = force_snapshot.is_some() || registration.host_plugin_added; + let _replacement_retirement = if replacement_may_be_live { + match retire_replacement_before_rollback(host, &layout, options, setup_runner) { + Ok(retirement) => retirement, + Err(retirement_error) => { + return Err(format!( + "{error}; refusing destructive rollback because the replacement MCP generation could not be retired: {retirement_error}" + )); + } + } + } else { + None + }; + let rollback_error = rollback_install( host, &layout, registration, - setup_attempted, + setup_installed, options, runner, setup_runner, - ) { + ) + .err(); + let restore_error = force_snapshot.as_mut().and_then(|snapshot| { + restore_force_replacement(host, &layout, snapshot, options, runner, setup_runner).err() + }); + let rollback_errors = [ + rollback_error.map(|error| format!("failed to roll back install: {error}")), + restore_error.map(|error| format!("failed to restore previous install: {error}")), + ] + .into_iter() + .flatten() + .collect::>(); + if !rollback_errors.is_empty() { return Err(format!( - "{error}; additionally failed to roll back install: {rollback_error}" + "{error}; additionally {}", + rollback_errors.join("; ") )); } return Err(error); } + if let Some(snapshot) = force_snapshot { + snapshot.commit(); + } println!( "installed {} plugin marketplace at {}", host_label(host), @@ -416,9 +602,146 @@ fn uninstall_host( runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, ) -> Result<(), String> { + uninstall_host_with_operation_timeout( + host, + options, + runner, + setup_runner, + DEFAULT_OPERATION_LOCK_TIMEOUT, + ) +} + +fn uninstall_host_with_operation_timeout( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, + lock_timeout: Duration, +) -> Result<(), String> { + let _operation_lock = (!options.dry_run) + .then(|| { + PluginOperationLock::acquire( + host, + &options.operation_lock_dir, + &options.install_dir, + lock_timeout, + ) + }) + .transpose()?; + uninstall_host_locked(host, options, runner, setup_runner) +} + +fn uninstall_host_locked( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + let state = read_state(host, &options.install_dir); + let layout = PluginLayout::new(host, &options.install_dir); + let plugin_root = state + .as_ref() + .map(|state| state.plugin_root.as_path()) + .unwrap_or(&layout.plugin_root); + let local_install_exists = state.is_some() || layout.marketplace_root.exists(); + let mut generation_retirement = retire_installed_generation( + host, + plugin_root, + local_install_exists, + options, + runner, + setup_runner, + )?; + if let Some(retirement) = generation_retirement.as_mut() { + retirement.invalidate_for_replacement().map_err(|error| { + format!( + "failed to retire installed MCP generation before uninstalling {}: {error}", + plugin_root.display() + ) + })?; + } uninstall_host_with_setup_override(host, options, runner, setup_runner, false) } +fn retire_installed_generation( + host: PluginHost, + plugin_root: &Path, + local_install_exists: bool, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result, String> { + if options.dry_run || !matches!(host, PluginHost::Codex) { + return Ok(None); + } + let generation_fence = plugin_root.join(GENERATION_FILE_NAME); + let mut existing_install = local_install_exists; + if !generation_fence.exists() { + let registration = host_registration_report(host, options, runner)?; + existing_install |= + registration.host_plugin_registered || registration.host_marketplace_registered; + if existing_install { + return Err(missing_generation_fence_error(&generation_fence)); + } + } + let retirement = GenerationRetirement::acquire(&generation_fence) + .map_err(|cause| invalid_generation_fence_error(&generation_fence, &cause))?; + if retirement.is_none() && !existing_install { + let registration = host_registration_report(host, options, runner)?; + existing_install = + registration.host_plugin_registered || registration.host_marketplace_registered; + } + if retirement.is_none() && existing_install { + return Err(missing_generation_fence_error(&generation_fence)); + } + setup_runner.refresh_gateway(host)?; + Ok(retirement) +} + +fn retire_replacement_before_rollback( + host: PluginHost, + layout: &PluginLayout, + options: &PluginInstallOptions, + setup_runner: &dyn PluginSetupRunner, +) -> Result, String> { + if options.dry_run || !matches!(host, PluginHost::Codex) { + return Ok(None); + } + let mut retirement = GenerationRetirement::acquire(&layout.generation_fence) + .map_err(|cause| invalid_generation_fence_error(&layout.generation_fence, &cause))? + .ok_or_else(|| missing_generation_fence_error(&layout.generation_fence))?; + setup_runner.refresh_gateway(host)?; + retirement.invalidate_for_replacement().map_err(|error| { + format!( + "failed to retire replacement MCP generation {} before rollback: {error}", + layout.generation_fence.display() + ) + })?; + Ok(Some(retirement)) +} + +fn existing_codex_install_requires_force_error() -> String { + "an existing fenced Codex plugin install was found; rerun `nemo-relay install codex --force` to replace it safely" + .into() +} + +fn missing_generation_fence_error(generation_fence: &Path) -> String { + unsafe_generation_fence_error(&format!("is missing at {}", generation_fence.display())) +} + +fn invalid_generation_fence_error(generation_fence: &Path, cause: &str) -> String { + unsafe_generation_fence_error(&format!( + "at {} is invalid or unreadable: {cause}", + generation_fence.display() + )) +} + +fn unsafe_generation_fence_error(problem: &str) -> String { + format!( + "cannot safely replace or uninstall an existing Codex plugin because its MCP generation marker {problem}; close all Codex clients and standalone `nemo-relay mcp` processes, run `codex plugin remove nemo-relay-plugin@nemo-relay-local` and `codex plugin marketplace remove nemo-relay-local`, remove the stale marketplace and state from the selected install directory, then run `nemo-relay install codex --force` to create a fenced install (and `nemo-relay uninstall codex` afterward if removal was intended)" + ) +} + fn uninstall_host_with_setup_override( host: PluginHost, options: &PluginInstallOptions, @@ -446,12 +769,12 @@ fn uninstall_host_with_setup_override( state.plugin_setup_installed = true; write_state_for_host(host, &state, &options.install_dir, options)?; } - run_host_unregistration(host, &mut state, &options.install_dir, options, runner)?; if force_plugin_setup_uninstall || state.plugin_setup_installed { - run_plugin_uninstall(host, options, setup_runner)?; + run_plugin_uninstall(host, &state.plugin_root, options, setup_runner)?; state.plugin_setup_installed = false; write_state_for_host(host, &state, &options.install_dir, options)?; } + run_host_unregistration(host, &mut state, &options.install_dir, options, runner)?; remove_path(&state.marketplace_root, options)?; remove_path(&state_path(host, &options.install_dir), options)?; println!("uninstalled {} plugin", host_label(host)); @@ -479,9 +802,9 @@ fn doctor_host( } readiness.ok().then_some(()).ok_or_else(|| { format!( - "{} plugin doctor checks failed; run `nemo-relay install {} --force` to repair the installation", + "{} plugin doctor checks failed; remediation: {}", host_label(host), - host_arg(host) + readiness.remediation ) }) } @@ -522,6 +845,10 @@ fn collect_host_plugin_readiness( let state_path = state_path(host, &options.install_dir); let state = read_state(host, &options.install_dir); let layout = PluginLayout::new(host, &options.install_dir); + let setup_plugin_root = state + .as_ref() + .map(|state| state.plugin_root.clone()) + .unwrap_or_else(|| layout.plugin_root.clone()); let marketplace = state .as_ref() .map(|state| state.marketplace_root.clone()) @@ -580,6 +907,38 @@ fn collect_host_plugin_readiness( validate_relay_plugin_shim(&relay, options, runner) .map(|_| "plugin-shim hook is supported".into()), ); + if let Some(plugin) = readiness.plugin.as_ref() { + readiness.push( + "Generated hooks", + generated_manifest_check( + &plugin.join("hooks").join("hooks.json"), + &plugin_hooks(host, &relay), + "hooks", + ), + ); + } + if matches!(host, PluginHost::Codex) { + readiness.push( + "Relay MCP support", + validate_relay_mcp(&relay, options, runner) + .map(|_| "native mcp subcommand is supported".into()), + ); + if let Some(plugin) = readiness.plugin.as_ref() { + let generation_fence = plugin.join(crate::install_generation::GENERATION_FILE_NAME); + let mcp_config = plugin_mcp_config_path(plugin); + readiness.push( + "MCP generation fence", + InstallGeneration::capture(generation_fence.clone()) + .map(|_| format!("valid generation at {}", generation_fence.display())), + ); + let check = plugin_mcp_config(host, &relay, &generation_fence) + .and_then(|expected| { + expected.ok_or_else(|| "Codex MCP configuration was not generated".into()) + }) + .and_then(|expected| generated_mcp_config_check(&mcp_config, &expected)); + readiness.push("Generated MCP server", check); + } + } } let host_cli_check = require_host_cli(host, options, runner); @@ -591,6 +950,18 @@ fn collect_host_plugin_readiness( .map_err(Clone::clone), ); if host_cli_check.is_ok() { + if matches!(host, PluginHost::Codex) { + let version = host::validate_codex_version(options, runner); + if version.is_err() { + readiness.remediation = + "upgrade Codex to codex-cli 0.143.0 or newer, then run `nemo-relay install codex --force`" + .into(); + } + readiness.push( + "Codex version", + version.map(|_| "codex-cli 0.143.0 or newer is installed".into()), + ); + } match host_registration_report(host, options, runner) { Ok(report) => { readiness.host_plugin_registered = Some(report.host_plugin_registered); @@ -621,7 +992,7 @@ fn collect_host_plugin_readiness( } } - match run_plugin_doctor_json(host, setup_runner) { + match run_plugin_doctor_json(host, &setup_plugin_root, setup_runner) { Ok(plugin_report) => { append_plugin_setup_checks(&mut readiness, &plugin_report); readiness.plugin_setup = Some(plugin_report); @@ -679,6 +1050,47 @@ fn generated_manifest_check(path: &Path, expected: &Value, label: &str) -> Resul } } +fn generated_mcp_config_check(path: &Path, expected: &Value) -> Result { + let raw = std::fs::read_to_string(path).map_err(|error| { + format!( + "missing or unreadable MCP server manifest {}: {error}", + path.display() + ) + })?; + let actual = serde_json::from_str::(&raw) + .map_err(|error| format!("invalid MCP server manifest {}: {error}", path.display()))?; + if actual == *expected { + return Ok(format!("valid at {}", path.display())); + } + let expected_vars = expected["nemo-relay"]["env_vars"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + let actual_vars = actual["nemo-relay"]["env_vars"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + let missing = expected_vars + .difference(&actual_vars) + .copied() + .collect::>(); + if !missing.is_empty() { + return Err(format!( + "MCP server at {} is missing forwarded environment variables: {}; run `nemo-relay install codex --force`", + path.display(), + missing.join(", ") + )); + } + Err(format!( + "unexpected MCP server manifest contents at {}", + path.display() + )) +} + fn marketplace_manifest_path(host: PluginHost, root: &Path) -> PathBuf { match host { PluginHost::Codex => root @@ -698,6 +1110,448 @@ fn plugin_manifest_path(host: PluginHost, root: &Path) -> PathBuf { } } +fn plugin_mcp_config_path(root: &Path) -> PathBuf { + root.join(".mcp.json") +} + +struct StagedPluginMarketplace { + layout: PluginLayout, + parent: PathBuf, +} + +impl StagedPluginMarketplace { + fn promote(&self, target: &PluginLayout) -> Result<(), String> { + fs::rename(&self.layout.marketplace_root, &target.marketplace_root).map_err(|error| { + format!( + "failed to promote staged marketplace {} to {}: {error}", + self.layout.marketplace_root.display(), + target.marketplace_root.display() + ) + }) + } + + fn cleanup(&self) { + let _ = fs::remove_dir_all(&self.parent); + } +} + +struct CodexInstallPreflight { + persisted: Option, + state_bytes: Option>, + previous_marketplace_root: PathBuf, + previous_plugin_root: PathBuf, + previous_generation_fence: PathBuf, + plugin_registered: bool, + marketplace_registered: bool, + previous_setup_installed: bool, + previous_install_exists: bool, + generation_retirement: Option, +} + +fn prepare_codex_install( + layout: &PluginLayout, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + let persisted = read_state(PluginHost::Codex, &options.install_dir); + let registration = host_registration_report(PluginHost::Codex, options, runner)?; + let plugin_registered = registration.host_plugin_registered; + let marketplace_registered = registration.host_marketplace_registered; + let state_bytes = match fs::read(&layout.state_path) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(format!( + "failed to snapshot {}: {error}", + layout.state_path.display() + )); + } + }; + let previous_setup_installed = persisted + .as_ref() + .is_some_and(|state| state.plugin_setup_installed) + || plugin_registered; + let previous_marketplace_root = persisted + .as_ref() + .map(|state| state.marketplace_root.clone()) + .unwrap_or_else(|| layout.marketplace_root.clone()); + let previous_plugin_root = persisted + .as_ref() + .map(|state| state.plugin_root.clone()) + .unwrap_or_else(|| layout.plugin_root.clone()); + let previous_generation_fence = previous_plugin_root.join(GENERATION_FILE_NAME); + let previous_install_exists = state_bytes.is_some() + || layout.marketplace_root.exists() + || plugin_registered + || marketplace_registered; + let generation_retirement = if previous_install_exists { + if !previous_generation_fence.exists() { + return Err(missing_generation_fence_error(&previous_generation_fence)); + } + Some( + GenerationRetirement::acquire(&previous_generation_fence) + .map_err(|cause| { + invalid_generation_fence_error(&previous_generation_fence, &cause) + })? + .ok_or_else(|| missing_generation_fence_error(&previous_generation_fence))?, + ) + } else { + None + }; + Ok(CodexInstallPreflight { + persisted, + state_bytes, + previous_marketplace_root, + previous_plugin_root, + previous_generation_fence, + plugin_registered, + marketplace_registered, + previous_setup_installed, + previous_install_exists, + generation_retirement, + }) +} + +struct ForceInstallSnapshot { + state_bytes: Option>, + setup_snapshot: Option, + original_marketplace_root: PathBuf, + original_plugin_root: PathBuf, + original_generation_fence: PathBuf, + plugin_registered: bool, + marketplace_registered: bool, + backup_marketplace_root: PathBuf, + backup_plugin_root: Option, + marketplace_moved: bool, + plugin_moved: bool, + replacement_promoted: bool, + generation_retirement: Option, +} + +impl ForceInstallSnapshot { + fn plugin_moves_with_marketplace(&self) -> bool { + self.original_plugin_root + .starts_with(&self.original_marketplace_root) + } + + fn commit(self) { + if self.marketplace_moved { + match fs::remove_dir_all(&self.backup_marketplace_root) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => eprintln!( + "warning: failed to remove replaced marketplace backup {}: {error}", + self.backup_marketplace_root.display() + ), + } + } + if self.plugin_moved + && let Some(backup_plugin_root) = self.backup_plugin_root.as_ref() + { + match fs::remove_dir_all(backup_plugin_root) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => eprintln!( + "warning: failed to remove replaced plugin backup {}: {error}", + backup_plugin_root.display() + ), + } + } + } +} + +fn stage_plugin_marketplace( + host: PluginHost, + relay: &Path, + target: &PluginLayout, + options: &PluginInstallOptions, +) -> Result { + let parent = options.install_dir.join(format!( + ".{}-install-stage-{}", + host_arg(host), + uuid::Uuid::now_v7() + )); + let layout = PluginLayout::new(host, &parent); + if let Err(error) = write_plugin_marketplace_for_generation( + host, + &layout, + relay, + &target.generation_fence, + options, + ) { + let _ = fs::remove_dir_all(&parent); + return Err(error); + } + Ok(StagedPluginMarketplace { layout, parent }) +} + +fn begin_force_replacement( + host: PluginHost, + layout: &PluginLayout, + preflight: CodexInstallPreflight, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result { + let CodexInstallPreflight { + persisted, + state_bytes, + previous_marketplace_root, + previous_plugin_root, + previous_generation_fence, + plugin_registered, + marketplace_registered, + previous_setup_installed, + previous_install_exists: _, + generation_retirement, + } = preflight; + let setup_snapshot = setup_runner.snapshot(host)?; + let backup_parent = previous_marketplace_root + .parent() + .unwrap_or(&options.install_dir); + let backup_marketplace_root = backup_parent.join(format!( + ".{}-marketplace-backup-{}", + host_arg(host), + uuid::Uuid::now_v7() + )); + let backup_plugin_root = + (!previous_plugin_root.starts_with(&previous_marketplace_root)).then(|| { + previous_plugin_root + .parent() + .unwrap_or(&options.install_dir) + .join(format!( + ".{}-plugin-backup-{}", + host_arg(host), + uuid::Uuid::now_v7() + )) + }); + let mut snapshot = ForceInstallSnapshot { + state_bytes, + setup_snapshot, + original_marketplace_root: previous_marketplace_root, + original_plugin_root: previous_plugin_root, + original_generation_fence: previous_generation_fence, + plugin_registered, + marketplace_registered, + backup_marketplace_root, + backup_plugin_root, + marketplace_moved: false, + plugin_moved: false, + replacement_promoted: false, + generation_retirement, + }; + let mut cleanup_state = persisted.unwrap_or_else(|| PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: layout.plugin_root.clone(), + host_plugin_removed: !plugin_registered, + host_marketplace_removed: !marketplace_registered, + plugin_setup_installed: previous_setup_installed, + }); + cleanup_state.host_plugin_removed = !plugin_registered; + cleanup_state.host_marketplace_removed = !marketplace_registered; + let result = run_host_unregistration( + host, + &mut cleanup_state, + &options.install_dir, + options, + runner, + ) + .and_then(|()| { + if let Some(retirement) = snapshot.generation_retirement.as_mut() { + retirement.invalidate_for_replacement().map_err(|error| { + format!( + "failed to retire previous MCP generation {} before replacement: {error}", + snapshot.original_generation_fence.display() + ) + })?; + } + if snapshot.original_marketplace_root.exists() { + fs::rename( + &snapshot.original_marketplace_root, + &snapshot.backup_marketplace_root, + ) + .map_err(|error| { + format!( + "failed to preserve existing marketplace {}: {error}", + snapshot.original_marketplace_root.display() + ) + })?; + snapshot.marketplace_moved = true; + } + if !snapshot.plugin_moves_with_marketplace() && snapshot.original_plugin_root.exists() { + let backup_plugin_root = snapshot + .backup_plugin_root + .as_ref() + .expect("separate original plugin root has a backup path"); + fs::rename(&snapshot.original_plugin_root, backup_plugin_root).map_err(|error| { + format!( + "failed to preserve existing plugin root {} containing generation marker {}: {error}", + snapshot.original_plugin_root.display(), + snapshot.original_generation_fence.display() + ) + })?; + snapshot.plugin_moved = true; + } + Ok(()) + }); + if let Err(error) = result { + return restore_force_replacement_after_error( + host, + layout, + &mut snapshot, + options, + runner, + setup_runner, + error, + ); + } + Ok(snapshot) +} + +fn restore_force_replacement_after_error( + host: PluginHost, + layout: &PluginLayout, + snapshot: &mut ForceInstallSnapshot, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, + original_error: String, +) -> Result { + match restore_force_replacement(host, layout, snapshot, options, runner, setup_runner) { + Ok(()) => Err(original_error), + Err(rollback_error) => Err(format!( + "{original_error}; additionally failed to restore previous install: {rollback_error}" + )), + } +} + +fn restore_force_replacement( + host: PluginHost, + layout: &PluginLayout, + snapshot: &mut ForceInstallSnapshot, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + let mut errors = Vec::new(); + if snapshot.replacement_promoted { + match host_registration_report(host, options, runner) { + Ok(report) => { + if report.host_plugin_registered + && let Err(error) = run_host_plugin_removal(host, options, runner) + { + errors.push(error); + } + if report.host_marketplace_registered + && let Err(error) = run_host_marketplace_removal(host, options, runner) + { + errors.push(error); + } + } + Err(error) => errors.push(error), + } + if let Err(error) = remove_path(&layout.marketplace_root, options) { + errors.push(error); + } + snapshot.replacement_promoted = false; + } + if snapshot.marketplace_moved { + if let Err(error) = fs::rename( + &snapshot.backup_marketplace_root, + &snapshot.original_marketplace_root, + ) { + errors.push(format!( + "failed to restore marketplace {}: {error}", + snapshot.original_marketplace_root.display() + )); + } else { + snapshot.marketplace_moved = false; + } + } + if snapshot.plugin_moved + && let Some(backup_plugin_root) = snapshot.backup_plugin_root.as_ref() + { + if let Err(error) = fs::rename(backup_plugin_root, &snapshot.original_plugin_root) { + errors.push(format!( + "failed to restore plugin root {} containing generation marker {}: {error}", + snapshot.original_plugin_root.display(), + snapshot.original_generation_fence.display() + )); + } else { + snapshot.plugin_moved = false; + } + } + if let Some(retirement) = snapshot.generation_retirement.as_mut() + && let Err(error) = retirement.restore_after_rollback() + { + errors.push(error); + } + match host_registration_report(host, options, runner) { + Ok(report) => { + if report.host_plugin_registered + && !snapshot.plugin_registered + && let Err(error) = run_host_plugin_removal(host, options, runner) + { + errors.push(error); + } + if report.host_marketplace_registered + && !snapshot.marketplace_registered + && let Err(error) = run_host_marketplace_removal(host, options, runner) + { + errors.push(error); + } + if snapshot.marketplace_registered + && !report.host_marketplace_registered + && let Err(error) = run_host_marketplace_registration( + host, + &snapshot.original_marketplace_root, + options, + runner, + ) + { + errors.push(error); + } + if snapshot.plugin_registered + && !report.host_plugin_registered + && let Err(error) = run_host_plugin_registration(host, options, runner) + { + errors.push(error); + } + } + Err(error) => errors.push(error), + } + if let Some(setup_snapshot) = snapshot.setup_snapshot.as_ref() + && let Err(error) = setup_runner.restore_snapshot(setup_snapshot) + { + errors.push(error); + } + if let Some(bytes) = snapshot.state_bytes.as_deref() { + if let Some(parent) = layout.state_path.parent() + && let Err(error) = fs::create_dir_all(parent) + { + errors.push(format!("failed to create {}: {error}", parent.display())); + } + if let Err(error) = fs::write(&layout.state_path, bytes) { + errors.push(format!( + "failed to restore {}: {error}", + layout.state_path.display() + )); + } + } else if let Err(error) = fs::remove_file(&layout.state_path) + && error.kind() != std::io::ErrorKind::NotFound + { + errors.push(format!( + "failed to remove {}: {error}", + layout.state_path.display() + )); + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + fn force_cleanup_existing_install( host: PluginHost, layout: &PluginLayout, @@ -706,7 +1560,7 @@ fn force_cleanup_existing_install( setup_runner: &dyn PluginSetupRunner, ) -> Result<(), String> { if layout.state_path.exists() { - uninstall_host(host, options, runner, setup_runner)?; + uninstall_host_locked(host, options, runner, setup_runner)?; } else { let mut state = PluginState { marketplace_root: layout.marketplace_root.clone(), @@ -726,12 +1580,12 @@ fn rollback_install( host: PluginHost, layout: &PluginLayout, registration: HostRegistrationProgress, - setup_attempted: bool, + setup_installed: bool, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, ) -> Result<(), String> { - if setup_attempted { + if setup_installed { return uninstall_host_with_setup_override(host, options, runner, setup_runner, true); } let mut state = read_state(host, &options.install_dir).unwrap_or_else(|| PluginState { diff --git a/crates/cli/src/plugin_install/operation_lock.rs b/crates/cli/src/plugin_install/operation_lock.rs new file mode 100644 index 000000000..ceb306c64 --- /dev/null +++ b/crates/cli/src/plugin_install/operation_lock.rs @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Cross-process serialization for per-user host state and one installation root. + +use std::fs::{self, File, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::config::PluginHost; +use crate::file_io::{LockAttempt, try_lock_exclusive}; + +pub(super) const DEFAULT_OPERATION_LOCK_TIMEOUT: Duration = Duration::from_secs(5); +const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25); + +pub(super) struct PluginOperationLock { + _global_file: File, + _root_file: File, +} + +impl PluginOperationLock { + pub(super) fn acquire( + host: PluginHost, + global_lock_dir: &Path, + install_dir: &Path, + timeout: Duration, + ) -> Result { + let deadline = Instant::now() + timeout; + let global_file = acquire_lock_file(host, global_lock_dir, deadline, "global")?; + let root_file = acquire_lock_file(host, install_dir, deadline, "install-root")?; + Ok(Self { + _global_file: global_file, + _root_file: root_file, + }) + } +} + +fn acquire_lock_file( + host: PluginHost, + directory: &Path, + deadline: Instant, + scope: &str, +) -> Result { + fs::create_dir_all(directory).map_err(|error| { + format!( + "failed to create plugin operation lock directory {}: {error}", + directory.display() + ) + })?; + let path = operation_lock_path(host, directory); + let mut options = OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(&path).map_err(|error| { + format!( + "failed to open plugin operation lock {}: {error}", + path.display() + ) + })?; + loop { + match try_lock_exclusive(&file) { + Ok(LockAttempt::Acquired) => return Ok(file), + Ok(LockAttempt::Contended) => { + if Instant::now() >= deadline { + return Err(format!( + "timed out waiting for another {} plugin install or uninstall operation on the {scope} lock at {}; wait for it to finish and retry", + host_name(host), + directory.display() + )); + } + thread::sleep( + LOCK_RETRY_INTERVAL.min(deadline.saturating_duration_since(Instant::now())), + ); + } + Err(error) => { + return Err(format!( + "failed to lock plugin operation {}: {error}", + path.display() + )); + } + } + } +} + +pub(super) fn operation_lock_path(host: PluginHost, install_dir: &Path) -> PathBuf { + install_dir.join(format!(".nemo-relay-{}-operation.lock", host_name(host))) +} + +fn host_name(host: PluginHost) -> &'static str { + match host { + PluginHost::Codex => "codex", + PluginHost::ClaudeCode => "claude-code", + PluginHost::All => unreachable!("all is expanded before operation locking"), + } +} diff --git a/crates/cli/src/plugin_install/setup.rs b/crates/cli/src/plugin_install/setup.rs index b52d36ff0..2c4b3e985 100644 --- a/crates/cli/src/plugin_install/setup.rs +++ b/crates/cli/src/plugin_install/setup.rs @@ -6,12 +6,15 @@ use crate::config::{CodingAgent, PluginHost}; use crate::plugin_shim; use serde_json::Value; +use std::path::Path; use super::DEFAULT_GATEWAY_URL; use super::state::PluginInstallOptions; +use super::state::PluginLayout; pub(super) fn run_plugin_setup( host: PluginHost, + layout: &PluginLayout, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, ) -> Result<(), String> { @@ -19,11 +22,12 @@ pub(super) fn run_plugin_setup( println!("{}", setup_action_description(host, "configure")); return Ok(()); } - setup_runner.setup(host, DEFAULT_GATEWAY_URL) + setup_runner.setup(host, DEFAULT_GATEWAY_URL, &layout.plugin_root) } pub(super) fn run_plugin_uninstall( host: PluginHost, + plugin_root: &Path, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, ) -> Result<(), String> { @@ -31,11 +35,12 @@ pub(super) fn run_plugin_uninstall( println!("{}", setup_action_description(host, "restore")); return Ok(()); } - setup_runner.uninstall(host, DEFAULT_GATEWAY_URL) + setup_runner.uninstall(host, DEFAULT_GATEWAY_URL, plugin_root) } pub(super) fn run_plugin_doctor( host: PluginHost, + plugin_root: &Path, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, ) -> Result<(), String> { @@ -43,25 +48,24 @@ pub(super) fn run_plugin_doctor( println!("{}", setup_action_description(host, "doctor")); return Ok(()); } - setup_runner.doctor(host, DEFAULT_GATEWAY_URL) + setup_runner.doctor(host, DEFAULT_GATEWAY_URL, plugin_root) } pub(super) fn run_plugin_doctor_json( host: PluginHost, + plugin_root: &Path, setup_runner: &dyn PluginSetupRunner, ) -> Result { - setup_runner.doctor_json(host, DEFAULT_GATEWAY_URL) + setup_runner.doctor_json(host, DEFAULT_GATEWAY_URL, plugin_root) } pub(super) fn setup_action_description(host: PluginHost, action: &str) -> String { match (host, action) { (PluginHost::Codex, "configure") => { - "configure Codex provider and hook-supervised lazy startup".into() + "configure Codex provider and trust plugin-owned hooks".into() } - (PluginHost::Codex, "restore") => { - "restore Codex provider and generated hook configuration".into() - } - (PluginHost::Codex, "doctor") => "check Codex provider and generated hooks".into(), + (PluginHost::Codex, "restore") => "remove Codex provider and plugin hook trust".into(), + (PluginHost::Codex, "doctor") => "check Codex provider and plugin-owned hooks".into(), (PluginHost::ClaudeCode, "configure") => { "enable Claude Code provider routing through NeMo Relay".into() } @@ -75,48 +79,126 @@ pub(super) fn setup_action_description(host: PluginHost, action: &str) -> String } pub(super) trait PluginSetupRunner { - fn setup(&self, host: PluginHost, gateway_url: &str) -> Result<(), String>; - fn uninstall(&self, host: PluginHost, gateway_url: &str) -> Result<(), String>; - fn doctor(&self, host: PluginHost, gateway_url: &str) -> Result<(), String>; - fn doctor_json(&self, host: PluginHost, gateway_url: &str) -> Result; + fn snapshot(&self, _host: PluginHost) -> Result, String> { + Ok(None) + } + + fn restore_snapshot(&self, _snapshot: &PluginSetupSnapshot) -> Result<(), String> { + Ok(()) + } + + fn refresh_gateway(&self, _host: PluginHost) -> Result<(), String> { + Ok(()) + } + + fn setup(&self, host: PluginHost, gateway_url: &str, plugin_root: &Path) -> Result<(), String>; + fn uninstall( + &self, + host: PluginHost, + gateway_url: &str, + plugin_root: &Path, + ) -> Result<(), String>; + fn doctor(&self, host: PluginHost, gateway_url: &str, plugin_root: &Path) + -> Result<(), String>; + fn doctor_json( + &self, + host: PluginHost, + gateway_url: &str, + plugin_root: &Path, + ) -> Result; } pub(super) struct RealPluginSetupRunner; +pub(super) enum PluginSetupSnapshot { + Codex(plugin_shim::CodexSetupSnapshot), + #[cfg(test)] + Mock, +} + impl PluginSetupRunner for RealPluginSetupRunner { - fn setup(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + fn snapshot(&self, host: PluginHost) -> Result, String> { match host { - PluginHost::Codex => plugin_shim::install_codex_plugin(gateway_url), + PluginHost::Codex => plugin_shim::snapshot_codex_setup() + .map(PluginSetupSnapshot::Codex) + .map(Some), + PluginHost::ClaudeCode => Ok(None), + PluginHost::All => unreachable!("all is expanded before plugin setup"), + } + } + + fn restore_snapshot(&self, snapshot: &PluginSetupSnapshot) -> Result<(), String> { + match snapshot { + PluginSetupSnapshot::Codex(snapshot) => plugin_shim::restore_codex_setup(snapshot), + #[cfg(test)] + PluginSetupSnapshot::Mock => Ok(()), + } + } + + fn refresh_gateway(&self, host: PluginHost) -> Result<(), String> { + match host { + PluginHost::Codex => plugin_shim::stop_codex_gateway(), + PluginHost::ClaudeCode => Ok(()), + PluginHost::All => unreachable!("all is expanded before plugin setup"), + } + } + + fn setup(&self, host: PluginHost, gateway_url: &str, plugin_root: &Path) -> Result<(), String> { + match host { + PluginHost::Codex => plugin_shim::install_codex_plugin(gateway_url, plugin_root), PluginHost::ClaudeCode => plugin_shim::enable_claude_provider(gateway_url), PluginHost::All => unreachable!("all is expanded before plugin setup"), } } - fn uninstall(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + fn uninstall( + &self, + host: PluginHost, + gateway_url: &str, + plugin_root: &Path, + ) -> Result<(), String> { match host { - PluginHost::Codex => plugin_shim::uninstall_codex_plugin(gateway_url), + PluginHost::Codex => plugin_shim::uninstall_codex_plugin(gateway_url, plugin_root), PluginHost::ClaudeCode => plugin_shim::restore_claude_provider(gateway_url), PluginHost::All => unreachable!("all is expanded before plugin uninstall"), } } - fn doctor(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + fn doctor( + &self, + host: PluginHost, + gateway_url: &str, + plugin_root: &Path, + ) -> Result<(), String> { match host { - PluginHost::Codex => plugin_shim::doctor_plugin(CodingAgent::Codex, gateway_url), + PluginHost::Codex => { + plugin_shim::doctor_plugin(CodingAgent::Codex, gateway_url, plugin_root) + } PluginHost::ClaudeCode => { - plugin_shim::doctor_plugin(CodingAgent::ClaudeCode, gateway_url) + plugin_shim::doctor_plugin(CodingAgent::ClaudeCode, gateway_url, plugin_root) } PluginHost::All => unreachable!("all is expanded before plugin doctor"), } } - fn doctor_json(&self, host: PluginHost, gateway_url: &str) -> Result { + fn doctor_json( + &self, + host: PluginHost, + gateway_url: &str, + plugin_root: &Path, + ) -> Result { match host { - PluginHost::Codex => plugin_shim::doctor_plugin_json(CodingAgent::Codex, gateway_url), + PluginHost::Codex => { + plugin_shim::doctor_plugin_json(CodingAgent::Codex, gateway_url, plugin_root) + } PluginHost::ClaudeCode => { - plugin_shim::doctor_plugin_json(CodingAgent::ClaudeCode, gateway_url) + plugin_shim::doctor_plugin_json(CodingAgent::ClaudeCode, gateway_url, plugin_root) } PluginHost::All => unreachable!("all is expanded before plugin doctor"), } } } + +#[cfg(test)] +#[path = "../../tests/coverage/plugin_install_setup_tests.rs"] +mod tests; diff --git a/crates/cli/src/plugin_install/state.rs b/crates/cli/src/plugin_install/state.rs index 2ceaed749..e40e86fdc 100644 --- a/crates/cli/src/plugin_install/state.rs +++ b/crates/cli/src/plugin_install/state.rs @@ -11,12 +11,14 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; use crate::config::PluginHost; +use crate::install_generation::GENERATION_FILE_NAME; use super::{PLUGIN_NAME, host_arg}; #[derive(Debug, Clone)] pub(super) struct PluginInstallOptions { pub(super) install_dir: PathBuf, + pub(super) operation_lock_dir: PathBuf, pub(super) force: bool, pub(super) dry_run: bool, pub(super) skip_doctor: bool, @@ -47,6 +49,8 @@ pub(super) struct PluginLayout { pub(super) marketplace_manifest: PathBuf, pub(super) plugin_root: PathBuf, pub(super) plugin_manifest: PathBuf, + pub(super) mcp_config: PathBuf, + pub(super) generation_fence: PathBuf, pub(super) hooks_path: PathBuf, pub(super) state_path: PathBuf, } @@ -70,6 +74,8 @@ impl PluginLayout { PluginHost::ClaudeCode => plugin_root.join(".claude-plugin").join("plugin.json"), PluginHost::All => unreachable!("all is expanded before layout resolution"), }; + let mcp_config = plugin_root.join(".mcp.json"); + let generation_fence = plugin_root.join(GENERATION_FILE_NAME); let hooks_path = plugin_root.join("hooks").join("hooks.json"); let state_path = state_path(host, install_dir); Self { @@ -78,6 +84,8 @@ impl PluginLayout { marketplace_manifest, plugin_root, plugin_manifest, + mcp_config, + generation_fence, hooks_path, state_path, } diff --git a/crates/cli/src/plugin_shim/codex.rs b/crates/cli/src/plugin_shim/codex.rs index 85933d50f..f9c2e5d0a 100644 --- a/crates/cli/src/plugin_shim/codex.rs +++ b/crates/cli/src/plugin_shim/codex.rs @@ -3,86 +3,653 @@ //! Codex-specific plugin setup, provider routing, and hook configuration. +use std::collections::BTreeSet; +use std::env; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::ExitCode; use serde_json::{Value, json}; use toml_edit::{DocumentMut, Item, Table, value}; use crate::config::CodingAgent; -use crate::installer::{generated_hooks, merge_hooks}; +use crate::installer::generated_hooks; +#[cfg(test)] +use crate::installer::merge_hooks; +use super::codex_app_server::{CodexAppServerClient, CodexHookMetadata, CodexHooksClient}; use super::shared::{ FileSnapshot, atomic_write, backup, backup_path, current_exe, ensure_table, home_dir, - read_json_object, remove_backup, restore_file_snapshot, snapshot_optional_file, write_json, + portable_executable_path, read_json_object, remove_backup, restore_file_snapshot, + snapshot_optional_file, write_json, }; -pub(super) fn install_codex(gateway_url: &str) -> Result { - let codex_dir = home_dir()?.join(".codex"); +pub(super) const CODEX_PLUGIN_ID: &str = "nemo-relay-plugin@nemo-relay-local"; +pub(super) const CODEX_PLUGIN_HOOK_KEY_PREFIX: &str = + "nemo-relay-plugin@nemo-relay-local:hooks/hooks.json:"; + +pub(crate) struct CodexSetupSnapshot { + files: Vec, + hooks: Vec, + trust_state: Vec<(String, Option)>, +} + +pub(crate) fn snapshot_codex_setup() -> Result { + let home = home_dir()?; + let codex_dir = codex_home_dir()?; + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + let mut client = CodexAppServerClient::start()?; + let hooks = relay_codex_plugin_hooks(&mut client, &home)?; + let trust_state = snapshot_relay_owned_hook_trust_state(&config_path, &hooks)?; + let files = codex_install_snapshots(&config_path, &hooks_path)?; + Ok(CodexSetupSnapshot { + files, + hooks, + trust_state, + }) +} + +pub(crate) fn restore_codex_setup(snapshot: &CodexSetupSnapshot) -> Result<(), String> { + let mut errors = Vec::new(); + if let Err(error) = restore_codex_install_snapshots(&snapshot.files) { + errors.push(format!("failed to restore Codex files: {error}")); + } + match (home_dir(), CodexAppServerClient::start()) { + (Ok(home), Ok(mut client)) => { + if let Err(error) = client.restore_hook_trust(&snapshot.trust_state) { + errors.push(format!("failed to restore Codex hook trust: {error}")); + } else if let Err(error) = + verify_restored_hook_trust(&mut client, &home, &snapshot.hooks) + { + errors.push(error); + } + } + (Err(error), _) | (_, Err(error)) => errors.push(error), + } + if let Err(error) = restore_codex_install_snapshots(&snapshot.files) { + errors.push(format!("failed to restore exact Codex files: {error}")); + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + +pub(super) fn install_codex( + gateway_url: &str, + plugin_hooks_path: &Path, +) -> Result { + let expected_command = expected_plugin_hook_command()?; + validate_plugin_hooks(plugin_hooks_path, &expected_command)?; + install_codex_with_trust( + gateway_url, + &expected_command, + |home, config_path, command| { + let mut client = CodexAppServerClient::start()?; + auto_trust_codex_hooks(&mut client, home, config_path, command) + }, + ) +} + +pub(super) fn install_codex_with_trust( + gateway_url: &str, + expected_command: &str, + trust_hooks: F, +) -> Result +where + F: FnOnce(&Path, &Path, &str) -> Result<(), String>, +{ + let home = home_dir()?; + let codex_dir = codex_home_dir()?; fs::create_dir_all(&codex_dir) .map_err(|error| format!("failed to create {}: {error}", codex_dir.display()))?; let config_path = codex_dir.join("config.toml"); let hooks_path = codex_dir.join("hooks.json"); prepare_codex_config(&config_path)?; - let hooks_snapshot = snapshot_optional_file(&hooks_path)?; - let hooks_backup_snapshot = snapshot_optional_file(&backup_path(&hooks_path))?; - if let Err(error) = install_codex_hooks(&hooks_path, gateway_url) { - if let Err(rollback_error) = - restore_codex_hooks_snapshot(&hooks_snapshot, &hooks_backup_snapshot) - { - return Err(format!( - "{error}; additionally failed to roll back Codex hooks at {}: {rollback_error}", - hooks_path.display() - )); - } - return Err(error); - } - if let Err(error) = install_codex_config(&config_path, gateway_url) { - if let Err(rollback_error) = - restore_codex_hooks_snapshot(&hooks_snapshot, &hooks_backup_snapshot) - { - return Err(format!( - "{error}; additionally failed to roll back Codex hooks at {}: {rollback_error}", - hooks_path.display() - )); - } - return Err(error); + let snapshots = codex_install_snapshots(&config_path, &hooks_path)?; + let install_result = remove_legacy_codex_hooks(&hooks_path) + .and_then(|()| install_codex_config(&config_path, gateway_url)) + .and_then(|()| trust_hooks(&home, &config_path, expected_command)); + if let Err(error) = install_result { + return match restore_codex_install_snapshots(&snapshots) { + Ok(()) => Err(error), + Err(rollback_error) => Err(format!( + "{error}; additionally failed to roll back Codex configuration: {rollback_error}" + )), + }; } println!("updated {}", config_path.display()); - println!("updated {}", hooks_path.display()); - println!("Codex Relay sidecar startup is hook-supervised; no daemon was installed."); + if hooks_path.exists() { + println!("updated {}", hooks_path.display()); + } + println!("configured Codex Relay provider and plugin hooks; no daemon was installed."); Ok(ExitCode::SUCCESS) } -pub(super) fn uninstall_codex(installed_gateway_url: &str) -> Result { - let codex_dir = home_dir()?.join(".codex"); +pub(super) fn uninstall_codex( + installed_gateway_url: &str, + _plugin_hooks_path: &Path, +) -> Result { + let mut client = CodexAppServerClient::start()?; + uninstall_codex_with_client(installed_gateway_url, Some(&mut client)) +} + +pub(super) fn uninstall_codex_with_client( + installed_gateway_url: &str, + client: Option<&mut dyn CodexHooksClient>, +) -> Result { + let home = home_dir()?; + let codex_dir = codex_home_dir()?; let config_path = codex_dir.join("config.toml"); let hooks_path = codex_dir.join("hooks.json"); - let hook_gateway_url = - codex_provider_gateway_url(&config_path).unwrap_or_else(|| installed_gateway_url.into()); - let hooks_snapshot = snapshot_optional_file(&hooks_path)?; - let hooks_backup_snapshot = snapshot_optional_file(&backup_path(&hooks_path))?; - let has_remaining_hooks = uninstall_codex_hooks(&hooks_path, &hook_gateway_url)?; - if let Err(error) = - uninstall_codex_config(&config_path, installed_gateway_url, has_remaining_hooks) - { - if let Err(rollback_error) = - restore_codex_hooks_snapshot(&hooks_snapshot, &hooks_backup_snapshot) - { - return Err(format!( - "{error}; additionally failed to roll back Codex hooks at {}: {rollback_error}", - hooks_path.display() - )); - } - return Err(error); + let client = client + .ok_or_else(|| "Codex app-server is required to clear plugin hook trust".to_string())?; + let hooks = relay_codex_plugin_hooks(client, &home)?; + let trust_state = snapshot_relay_owned_hook_trust_state(&config_path, &hooks)?; + let trust_keys = trust_state + .iter() + .map(|(key, _)| key.clone()) + .collect::>(); + let snapshots = codex_install_snapshots(&config_path, &hooks_path)?; + let uninstall_result = + clear_and_verify_hook_trust(client, &home, &config_path, &hooks, &trust_keys) + .and_then(|()| uninstall_codex_hooks(&hooks_path, installed_gateway_url)) + .and_then(|has_remaining_hooks| { + uninstall_codex_config(&config_path, installed_gateway_url, has_remaining_hooks) + }); + if let Err(error) = uninstall_result { + return rollback_codex_uninstall(client, &home, &hooks, &trust_state, &snapshots, error); } println!("updated {}", config_path.display()); println!("updated {}", hooks_path.display()); - println!("removed Codex Relay hook-supervised sidecar setup."); + println!("removed Codex Relay provider and plugin hook trust."); Ok(ExitCode::SUCCESS) } +const GENERATED_CODEX_HOOK_EVENTS: &[(&str, &str)] = &[ + ("sessionstart", "SessionStart"), + ("userpromptsubmit", "UserPromptSubmit"), + ("pretooluse", "PreToolUse"), + ("posttooluse", "PostToolUse"), + ("permissionrequest", "PermissionRequest"), + ("subagentstart", "SubagentStart"), + ("subagentstop", "SubagentStop"), + ("stop", "Stop"), + ("precompact", "PreCompact"), + ("postcompact", "PostCompact"), +]; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(super) struct CodexHookTrustReport { + trusted: Vec, + untrusted: Vec, + modified: Vec, + disabled: Vec, + missing_required: Vec, + duplicate_required: Vec, +} + +impl CodexHookTrustReport { + pub(super) fn ready(&self) -> bool { + self.untrusted.is_empty() + && self.modified.is_empty() + && self.disabled.is_empty() + && self.missing_required.is_empty() + && self.duplicate_required.is_empty() + && !self.trusted.is_empty() + } + + pub(super) fn to_json(&self) -> Value { + json!({ + "trusted": self.trusted, + "untrusted": self.untrusted, + "modified": self.modified, + "disabled": self.disabled, + "missing_required": self.missing_required, + "duplicate_required": self.duplicate_required, + }) + } + + pub(super) fn summary(&self) -> String { + format!( + "untrusted={}, modified={}, disabled={}, missing required={}, duplicate required={}", + self.untrusted.len(), + self.modified.len(), + self.disabled.len(), + self.missing_required.join(", "), + self.duplicate_required.join(", ") + ) + } +} + +pub(super) fn empty_codex_hook_trust_report() -> CodexHookTrustReport { + CodexHookTrustReport { + missing_required: GENERATED_CODEX_HOOK_EVENTS + .iter() + .map(|(_, display)| (*display).to_string()) + .collect(), + ..CodexHookTrustReport::default() + } +} + +pub(super) fn codex_hook_trust_report( + plugin_hooks_path: &Path, +) -> Result { + let home = home_dir()?; + let expected_command = expected_plugin_hook_command()?; + validate_plugin_hooks(plugin_hooks_path, &expected_command)?; + let mut client = CodexAppServerClient::start()?; + codex_hook_trust_report_with_client(&mut client, &home, &expected_command) +} + +pub(super) fn codex_hook_trust_report_with_client( + client: &mut dyn CodexHooksClient, + cwd: &Path, + expected_command: &str, +) -> Result { + let hooks = relay_codex_hooks(client, cwd, expected_command)?; + Ok(codex_hook_trust_report_for(&hooks)) +} + +pub(super) fn auto_trust_codex_hooks( + client: &mut dyn CodexHooksClient, + cwd: &Path, + config_path: &Path, + expected_command: &str, +) -> Result<(), String> { + let hooks = relay_codex_hooks(client, cwd, expected_command)?; + let before = codex_hook_trust_report_for(&hooks); + if !before.missing_required.is_empty() || !before.duplicate_required.is_empty() { + return Err(format!( + "Codex must discover exactly one Relay handler per required event (missing: {}; duplicate: {})", + before.missing_required.join(", "), + before.duplicate_required.join(", ") + )); + } + let state = snapshot_hook_trust_state(config_path, &hooks)?; + let trust_result = client.trust_hooks(&hooks).and_then(|()| { + let verified_hooks = relay_codex_hooks(client, cwd, expected_command)?; + let verified = codex_hook_trust_report_for(&verified_hooks); + let unverified_targets = hooks + .iter() + .filter(|target| { + !verified_hooks.iter().any(|actual| { + actual.key == target.key + && actual.current_hash == target.current_hash + && actual.trust_status == "trusted" + && actual.enabled + }) + }) + .map(|hook| hook.key.as_str()) + .collect::>(); + if verified.ready() && unverified_targets.is_empty() { + Ok(()) + } else { + Err(format!( + "Codex did not enable and trust all generated Relay hooks: {}; unverified targeted hooks={}", + verified.summary(), + unverified_targets.join(", ") + )) + } + }); + if let Err(error) = trust_result { + return restore_hook_trust_after_failure( + client, + cwd, + expected_command, + &hooks, + &state, + error, + ); + } + Ok(()) +} + +fn relay_codex_hooks( + client: &mut dyn CodexHooksClient, + cwd: &Path, + expected_command: &str, +) -> Result, String> { + let hooks = relay_codex_plugin_hooks(client, cwd)? + .into_iter() + .filter(|hook| hook.command.as_deref() == Some(expected_command)) + .collect::>(); + validate_loaded_hook_sources(&hooks, expected_command)?; + Ok(hooks) +} + +fn validate_loaded_hook_sources( + hooks: &[CodexHookMetadata], + expected_command: &str, +) -> Result<(), String> { + let expected = generated_hooks(CodingAgent::Codex, expected_command); + let sources = hooks + .iter() + .map(|hook| hook.source_path.as_str()) + .collect::>(); + for source in sources { + let path = Path::new(source); + let actual = read_json_object(path)?; + if actual != expected { + return Err(format!( + "Codex loaded modified Relay hooks from {}; run `nemo-relay install codex --force`", + path.display() + )); + } + } + Ok(()) +} + +fn relay_codex_plugin_hooks( + client: &mut dyn CodexHooksClient, + cwd: &Path, +) -> Result, String> { + Ok(client + .list_hooks(cwd)? + .into_iter() + .filter(|hook| { + hook.source == "plugin" + && hook.plugin_id.as_deref() == Some(CODEX_PLUGIN_ID) + && hook.handler_type == "command" + && GENERATED_CODEX_HOOK_EVENTS + .iter() + .any(|(event, _)| normalize_hook_event(&hook.event_name) == *event) + }) + .collect()) +} + +fn clear_and_verify_hook_trust( + client: &mut dyn CodexHooksClient, + cwd: &Path, + config_path: &Path, + hooks: &[CodexHookMetadata], + keys: &[String], +) -> Result<(), String> { + if keys.is_empty() { + return Ok(()); + } + client.clear_hook_trust(keys)?; + let mut uncleared = Vec::new(); + if !hooks.is_empty() { + let cleared = relay_codex_plugin_hooks(client, cwd)?; + uncleared.extend( + hooks + .iter() + .filter(|expected| { + !cleared.iter().any(|actual| { + actual.key == expected.key && actual.trust_status.as_str() == "untrusted" + }) + }) + .map(|hook| hook.key.clone()), + ); + } + let persisted = configured_hook_trust_keys(config_path)?; + uncleared.extend( + keys.iter() + .filter(|key| persisted.contains(key.as_str())) + .cloned(), + ); + uncleared.sort(); + uncleared.dedup(); + if uncleared.is_empty() { + Ok(()) + } else { + Err(format!( + "Codex did not clear trust for Relay plugin hooks: {}", + uncleared.join(", ") + )) + } +} + +pub(super) fn configured_hook_trust_keys(config_path: &Path) -> Result, String> { + let raw = read_optional_text(config_path)?; + let config = toml::from_str::(&raw) + .map_err(|error| format!("invalid TOML in {}: {error}", config_path.display()))?; + Ok(config + .get("hooks") + .and_then(|hooks| hooks.get("state")) + .and_then(toml::Value::as_table) + .into_iter() + .flat_map(|state| state.keys()) + .cloned() + .collect()) +} + +fn relay_owned_hook_trust_keys( + config_path: &Path, + hooks: &[CodexHookMetadata], +) -> Result, String> { + let mut keys = Vec::new(); + for hook in hooks { + if !keys.contains(&hook.key) { + keys.push(hook.key.clone()); + } + } + for key in configured_hook_trust_keys(config_path)? { + if key.starts_with(CODEX_PLUGIN_HOOK_KEY_PREFIX) && !keys.contains(&key) { + keys.push(key); + } + } + Ok(keys) +} + +fn snapshot_relay_owned_hook_trust_state( + config_path: &Path, + hooks: &[CodexHookMetadata], +) -> Result)>, String> { + let keys = relay_owned_hook_trust_keys(config_path, hooks)?; + snapshot_hook_trust_keys(config_path, &keys) +} + +fn snapshot_hook_trust_keys( + config_path: &Path, + keys: &[String], +) -> Result)>, String> { + let raw = read_optional_text(config_path)?; + let config = toml::from_str::(&raw) + .map_err(|error| format!("invalid TOML in {}: {error}", config_path.display()))?; + let state = config + .get("hooks") + .and_then(|hooks| hooks.get("state")) + .and_then(toml::Value::as_table); + keys.iter() + .map(|key| { + let value = state + .and_then(|state| state.get(key)) + .map(serde_json::to_value) + .transpose() + .map_err(|error| { + format!("failed to snapshot Codex hook trust for {key}: {error}") + })?; + Ok((key.clone(), value)) + }) + .collect() +} + +fn snapshot_hook_trust_state( + config_path: &Path, + hooks: &[CodexHookMetadata], +) -> Result)>, String> { + let keys = hooks + .iter() + .map(|hook| hook.key.clone()) + .collect::>(); + snapshot_hook_trust_keys(config_path, &keys) +} + +fn rollback_codex_uninstall( + client: &mut dyn CodexHooksClient, + cwd: &Path, + hooks: &[CodexHookMetadata], + trust_state: &[(String, Option)], + snapshots: &[FileSnapshot], + original_error: String, +) -> Result { + let mut rollback_errors = Vec::new(); + if let Err(error) = client.restore_hook_trust(trust_state) { + rollback_errors.push(format!("failed to restore Codex hook trust: {error}")); + } else if let Err(error) = verify_restored_hook_trust(client, cwd, hooks) { + rollback_errors.push(error); + } + if let Err(error) = restore_codex_install_snapshots(snapshots) { + rollback_errors.push(format!("failed to restore Codex files: {error}")); + } + if rollback_errors.is_empty() { + Err(original_error) + } else { + Err(format!( + "{original_error}; additionally failed to roll back Codex uninstall: {}", + rollback_errors.join("; ") + )) + } +} + +fn verify_restored_hook_trust( + client: &mut dyn CodexHooksClient, + cwd: &Path, + expected: &[CodexHookMetadata], +) -> Result<(), String> { + if expected.is_empty() { + return Ok(()); + } + let restored = relay_codex_plugin_hooks(client, cwd)?; + let matches = expected.iter().all(|expected| { + restored.iter().any(|actual| { + actual.key == expected.key + && actual.trust_status == expected.trust_status + && actual.enabled == expected.enabled + }) + }); + matches + .then_some(()) + .ok_or_else(|| "failed to verify restored Codex hook trust after uninstall rollback".into()) +} + +fn restore_hook_trust_after_failure( + client: &mut dyn CodexHooksClient, + cwd: &Path, + expected_command: &str, + before: &[CodexHookMetadata], + state: &[(String, Option)], + original_error: String, +) -> Result<(), String> { + if let Err(rollback_error) = client.restore_hook_trust(state) { + return Err(format!( + "{original_error}; additionally failed to restore Codex hook trust: {rollback_error}" + )); + } + let restored = relay_codex_hooks(client, cwd, expected_command).map_err(|rollback_error| { + format!( + "{original_error}; additionally failed to verify restored Codex hook trust: {rollback_error}" + ) + })?; + let restored_matches = before.iter().all(|expected| { + restored.iter().any(|actual| { + actual.key == expected.key + && actual.trust_status == expected.trust_status + && actual.enabled == expected.enabled + }) + }); + if !restored_matches { + return Err(format!( + "{original_error}; additionally failed to verify restored Codex hook trust state" + )); + } + Err(original_error) +} + +pub(super) fn expected_plugin_hook_command() -> Result { + let relay = current_exe()?; + let relay = relay.canonicalize().unwrap_or(relay); + let relay = portable_executable_path(relay); + Ok(codex_plugin_hook_command(&relay)) +} + +fn validate_plugin_hooks(path: &Path, expected_command: &str) -> Result<(), String> { + let actual = read_json_object(path)?; + let expected = generated_hooks(CodingAgent::Codex, expected_command); + if actual == expected { + Ok(()) + } else { + Err(format!( + "{} does not match the generated NeMo Relay plugin hooks; run `nemo-relay install codex --force`", + path.display() + )) + } +} + +pub(super) fn codex_hook_trust_report_for(hooks: &[CodexHookMetadata]) -> CodexHookTrustReport { + let mut report = CodexHookTrustReport::default(); + for hook in hooks { + match hook.trust_status.as_str() { + "trusted" => report.trusted.push(hook.key.clone()), + "modified" => report.modified.push(hook.key.clone()), + _ => report.untrusted.push(hook.key.clone()), + } + if !hook.enabled { + report.disabled.push(hook.key.clone()); + } + } + report.missing_required = GENERATED_CODEX_HOOK_EVENTS + .iter() + .filter(|(normalized, _)| { + !hooks + .iter() + .any(|hook| normalize_hook_event(&hook.event_name) == *normalized) + }) + .map(|(_, display)| (*display).to_string()) + .collect(); + report.duplicate_required = GENERATED_CODEX_HOOK_EVENTS + .iter() + .filter(|(normalized, _)| { + hooks + .iter() + .filter(|hook| normalize_hook_event(&hook.event_name) == *normalized) + .count() + > 1 + }) + .map(|(_, display)| (*display).to_string()) + .collect(); + report +} + +fn normalize_hook_event(event: &str) -> String { + event + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn codex_install_snapshots( + config_path: &Path, + hooks_path: &Path, +) -> Result, String> { + [ + config_path.to_path_buf(), + backup_path(config_path), + hooks_path.to_path_buf(), + backup_path(hooks_path), + ] + .iter() + .map(|path| snapshot_optional_file(path)) + .collect() +} + +fn restore_codex_install_snapshots(snapshots: &[FileSnapshot]) -> Result<(), String> { + let errors = snapshots + .iter() + .filter_map(|snapshot| restore_file_snapshot(snapshot).err()) + .collect::>(); + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + pub(super) fn prepare_codex_config(path: &Path) -> Result<(), String> { let raw = read_optional_text(path)?; raw.parse::() @@ -205,6 +772,22 @@ fn remove_codex_config_without_backup( } } +pub(super) fn remove_legacy_codex_hooks(path: &Path) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + let original = read_json_object(path)?; + let mut updated = original.clone(); + let relay = current_exe()?; + remove_managed_codex_hook_groups(&mut updated, &relay, None); + if updated == original { + return Ok(()); + } + backup(path)?; + write_json(path, &updated) +} + +#[cfg(test)] pub(super) fn install_codex_hooks(path: &Path, gateway_url: &str) -> Result<(), String> { let relay = current_exe()?; let command = codex_hook_command(gateway_url); @@ -255,8 +838,16 @@ pub(super) fn remove_managed_codex_hook_groups( .get_mut(&event) .and_then(Value::as_array_mut) .map(|groups| { - groups.retain(|group| { - !managed_codex_hook_group_for_relay(group, relay, keep_gateway_url) + groups.retain_mut(|group| { + let Some(commands) = group.get_mut("hooks").and_then(Value::as_array_mut) + else { + return true; + }; + let before = commands.len(); + commands.retain(|hook| { + !managed_codex_hook_for_relay(hook, relay, keep_gateway_url) + }); + commands.len() == before || !commands.is_empty() }); groups.is_empty() }) @@ -267,20 +858,12 @@ pub(super) fn remove_managed_codex_hook_groups( } } -pub(super) fn managed_codex_hook_group_for_relay( - group: &Value, +fn managed_codex_hook_for_relay( + hook: &Value, relay: &Path, keep_gateway_url: Option<&str>, ) -> bool { - let Some(hooks) = group.get("hooks").and_then(Value::as_array) else { - return false; - }; - let [hook] = hooks.as_slice() else { - return false; - }; - if hook.get("type").and_then(Value::as_str) != Some("command") - || hook.get("timeout").and_then(Value::as_u64) != Some(30) - { + if hook.get("type").and_then(Value::as_str) != Some("command") { return false; } let Some(command) = hook.get("command").and_then(Value::as_str) else { @@ -291,13 +874,26 @@ pub(super) fn managed_codex_hook_group_for_relay( } command == legacy_codex_hook_command(relay) || command == legacy_named_codex_hook_command() - || command.starts_with("nemo-relay plugin-shim hook codex --gateway-url ") - || command.starts_with(&format!( - "{} plugin-shim hook codex --gateway-url ", - shell_quote(relay) - )) + || legacy_relay_hook_command(command) +} + +fn legacy_relay_hook_command(command: &str) -> bool { + let Some((program, arguments)) = command.split_once(" plugin-shim hook codex") else { + return false; + }; + if !arguments.is_empty() && !arguments.starts_with(" --gateway-url ") { + return false; + } + let executable = program + .rsplit(['/', '\\']) + .next() + .unwrap_or(program) + .trim_matches(['\'', '"']) + .to_ascii_lowercase(); + matches!(executable.as_str(), "nemo-relay" | "nemo-relay.exe") } +#[cfg(test)] pub(super) fn hook_config_contains_generated_groups(existing: &Value, generated: &Value) -> bool { let Some(generated_hooks) = generated.get("hooks").and_then(Value::as_object) else { return false; @@ -311,6 +907,7 @@ pub(super) fn hook_config_contains_generated_groups(existing: &Value, generated: }) } +#[cfg(test)] pub(super) fn generated_event_contains_group(config: &Value, event: &str, group: &Value) -> bool { config .get("hooks") @@ -340,6 +937,7 @@ pub(super) fn codex_config_doc_has_managed_install(doc: &DocumentMut, gateway_ur && feature_hooks_enabled(doc) == Some(true) } +#[cfg(test)] pub(super) fn codex_provider_gateway_url(path: &Path) -> Option { let raw = fs::read_to_string(path).ok()?; let doc = raw.parse::().ok()?; @@ -491,7 +1089,7 @@ pub(super) fn remove_table_item_if_bool( } pub(super) fn codex_provider_installed(gateway_url: &str) -> bool { - let Ok(path) = home_dir().map(|home| home.join(".codex").join("config.toml")) else { + let Ok(path) = codex_home_dir().map(|home| home.join("config.toml")) else { return false; }; let Ok(raw) = fs::read_to_string(path) else { @@ -503,19 +1101,17 @@ pub(super) fn codex_provider_installed(gateway_url: &str) -> bool { codex_config_doc_has_managed_install(&doc, gateway_url) } -pub(super) fn codex_hooks_installed(gateway_url: &str) -> Result { - let path = home_dir()?.join(".codex").join("hooks.json"); - let value = read_json_object(&path)?; - let generated = generated_hooks(CodingAgent::Codex, &codex_hook_command(gateway_url)); - Ok(hook_config_contains_generated_groups(&value, &generated)) +pub(super) fn codex_hooks_installed(path: &Path) -> Result { + let value = read_json_object(path)?; + let generated = generated_hooks(CodingAgent::Codex, &expected_plugin_hook_command()?); + Ok(value == generated) } -pub(super) fn restore_codex_hooks_snapshot( - hooks: &FileSnapshot, - hooks_backup: &FileSnapshot, -) -> Result<(), String> { - restore_file_snapshot(hooks)?; - restore_file_snapshot(hooks_backup) +pub(super) fn codex_home_dir() -> Result { + if let Some(path) = env::var_os("CODEX_HOME").filter(|path| !path.is_empty()) { + return Ok(PathBuf::from(path)); + } + Ok(home_dir()?.join(".codex")) } pub(super) fn shell_quote(path: &Path) -> String { @@ -575,6 +1171,18 @@ pub(super) fn codex_hook_command(gateway_url: &str) -> String { ) } +pub(super) fn codex_plugin_hook_command(relay: &Path) -> String { + codex_plugin_hook_command_for_platform(relay, cfg!(windows)) +} + +pub(super) fn codex_plugin_hook_command_for_platform(relay: &Path, windows: bool) -> String { + format!( + "{} plugin-shim hook codex --gateway-url {}", + shell_quote_for_platform(relay, windows), + shell_quote_arg_for_platform(super::DEFAULT_URL, windows) + ) +} + #[cfg(test)] pub(super) fn codex_hook_command_for_platform( relay: &Path, diff --git a/crates/cli/src/plugin_shim/codex_app_server.rs b/crates/cli/src/plugin_shim/codex_app_server.rs new file mode 100644 index 000000000..3c72e043a --- /dev/null +++ b/crates/cli/src/plugin_shim/codex_app_server.rs @@ -0,0 +1,242 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Minimal synchronous client for the stable Codex app-server hook APIs. + +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(super) struct CodexHookMetadata { + pub(super) key: String, + pub(super) event_name: String, + pub(super) handler_type: String, + pub(super) command: Option, + pub(super) source_path: String, + pub(super) source: String, + #[serde(default)] + pub(super) plugin_id: Option, + pub(super) enabled: bool, + pub(super) current_hash: String, + pub(super) trust_status: String, +} + +pub(super) trait CodexHooksClient { + fn list_hooks(&mut self, cwd: &Path) -> Result, String>; + fn trust_hooks(&mut self, hooks: &[CodexHookMetadata]) -> Result<(), String>; + fn clear_hook_trust(&mut self, keys: &[String]) -> Result<(), String>; + fn restore_hook_trust(&mut self, state: &[(String, Option)]) -> Result<(), String>; +} + +pub(super) struct CodexAppServerClient { + child: Child, + stdin: ChildStdin, + messages: Receiver>, + next_id: u64, +} + +impl CodexAppServerClient { + pub(super) fn start() -> Result { + let mut command = codex_app_server_command(); + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("failed to start `codex app-server`: {error}"))?; + let stdin = child + .stdin + .take() + .ok_or_else(|| "failed to open Codex app-server stdin".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "failed to open Codex app-server stdout".to_string())?; + let (sender, messages) = mpsc::channel(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let parsed = line + .map_err(|error| format!("failed to read Codex app-server response: {error}")) + .and_then(|line| { + serde_json::from_str(&line) + .map_err(|error| format!("invalid JSON from Codex app-server: {error}")) + }); + if sender.send(parsed).is_err() { + break; + } + } + }); + let mut client = Self { + child, + stdin, + messages, + next_id: 1, + }; + client.request( + "initialize", + json!({ + "clientInfo": { + "name": "nemo_relay", + "title": "NeMo Relay", + "version": env!("CARGO_PKG_VERSION") + } + }), + )?; + client.notify("initialized", None)?; + Ok(client) + } + + fn request(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id += 1; + self.write_message(&json!({"method": method, "id": id, "params": params}))?; + let deadline = Instant::now() + REQUEST_TIMEOUT; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(format!( + "timed out waiting for Codex app-server `{method}` response" + )); + } + let message = self.messages.recv_timeout(remaining).map_err(|error| { + format!("timed out waiting for Codex app-server `{method}` response: {error}") + })??; + if message.get("id").and_then(Value::as_u64) != Some(id) { + continue; + } + if let Some(error) = message.get("error") { + return Err(format!("Codex app-server `{method}` failed: {error}")); + } + return message + .get("result") + .cloned() + .ok_or_else(|| format!("Codex app-server `{method}` response had no result")); + } + } + + fn notify(&mut self, method: &str, params: Option) -> Result<(), String> { + let mut message = json!({"method": method}); + if let Some(params) = params { + message["params"] = params; + } + self.write_message(&message) + } + + fn write_message(&mut self, message: &Value) -> Result<(), String> { + serde_json::to_writer(&mut self.stdin, message) + .map_err(|error| format!("failed to encode Codex app-server request: {error}"))?; + self.stdin + .write_all(b"\n") + .and_then(|_| self.stdin.flush()) + .map_err(|error| format!("failed to write Codex app-server request: {error}")) + } + + fn batch_write(&mut self, edits: Vec) -> Result<(), String> { + self.request( + "config/batchWrite", + json!({"edits": edits, "reloadUserConfig": true}), + )?; + Ok(()) + } +} + +impl CodexHooksClient for CodexAppServerClient { + fn list_hooks(&mut self, cwd: &Path) -> Result, String> { + let response = self.request("hooks/list", json!({"cwds": [cwd]}))?; + let entry = response + .get("data") + .and_then(Value::as_array) + .and_then(|data| data.first()) + .ok_or_else(|| "Codex app-server returned no hook-list entry".to_string())?; + if let Some(errors) = entry.get("errors").and_then(Value::as_array) + && !errors.is_empty() + { + return Err(format!("Codex app-server could not load hooks: {errors:?}")); + } + serde_json::from_value(entry.get("hooks").cloned().unwrap_or_else(|| json!([]))) + .map_err(|error| format!("invalid hooks/list response from Codex app-server: {error}")) + } + + fn trust_hooks(&mut self, hooks: &[CodexHookMetadata]) -> Result<(), String> { + let state = hooks + .iter() + .fold(serde_json::Map::new(), |mut state, hook| { + state.insert( + hook.key.clone(), + json!({"trusted_hash": hook.current_hash, "enabled": true}), + ); + state + }); + self.batch_write(vec![json!({ + "keyPath": "hooks.state", + "value": state, + "mergeStrategy": "upsert" + })]) + } + + fn clear_hook_trust(&mut self, keys: &[String]) -> Result<(), String> { + let edits = keys + .iter() + .map(|key| { + json!({ + "keyPath": hook_state_key_path(key), + "value": null, + "mergeStrategy": "upsert" + }) + }) + .collect(); + self.batch_write(edits) + } + + fn restore_hook_trust(&mut self, state: &[(String, Option)]) -> Result<(), String> { + let edits = state + .iter() + .map(|(key, value)| { + json!({ + "keyPath": hook_state_key_path(key), + "value": value, + "mergeStrategy": "upsert" + }) + }) + .collect(); + self.batch_write(edits) + } +} + +impl Drop for CodexAppServerClient { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +pub(super) fn hook_state_key_path(key: &str) -> String { + let quoted = serde_json::to_string(key).expect("serializing a string cannot fail"); + format!("hooks.state.{quoted}") +} + +#[cfg(not(windows))] +fn codex_app_server_command() -> Command { + let mut command = Command::new("codex"); + command.arg("app-server"); + command +} + +#[cfg(windows)] +fn codex_app_server_command() -> Command { + let mut command = Command::new( + std::env::var_os("COMSPEC").unwrap_or_else(|| std::ffi::OsString::from("cmd.exe")), + ); + command.args(["/d", "/s", "/c", "codex app-server"]); + command +} diff --git a/crates/cli/src/plugin_shim/mod.rs b/crates/cli/src/plugin_shim/mod.rs index 20a08c177..5b1ade0d8 100644 --- a/crates/cli/src/plugin_shim/mod.rs +++ b/crates/cli/src/plugin_shim/mod.rs @@ -5,35 +5,43 @@ mod claude; mod codex; +mod codex_app_server; mod command; mod shared; +pub(crate) use codex::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; +pub(crate) use shared::portable_executable_path; +#[cfg(test)] +pub(crate) use shared::strip_windows_verbatim_prefix; + pub(crate) use command::PluginShimCommand; use std::env; use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; use serde_json::{Value, json}; use claude::{claude_provider, claude_settings_base_url}; +use codex::{codex_hook_trust_report, empty_codex_hook_trust_report}; use codex::{codex_hooks_installed, codex_provider_installed, install_codex, uninstall_codex}; use command::{ PluginShimDoctorCommand, PluginShimInstallCommand, PluginShimProviderAction, PluginShimProviderCommand, PluginShimSubcommand, PluginShimUninstallCommand, }; +#[cfg(test)] +use shared::MAX_HOOK_RESPONSE_BYTES; use shared::{ - ExecOrStatus, current_exe, fail_closed, gateway_url, healthz, plugin_idle_timeout, post_hook, - print_check, print_info, relay_binary, + ExecOrStatus, HookForwardError, current_exe, fail_closed, gateway_url, healthz, + plugin_idle_timeout, post_hook, print_check, print_info, relay_binary, }; -use crate::config::CodingAgent; +use crate::config::{CodingAgent, ServerArgs}; use crate::error::CliError; -pub(super) const DEFAULT_BIND: &str = "127.0.0.1:47632"; -pub(super) const DEFAULT_URL: &str = "http://127.0.0.1:47632"; -pub(super) const HEALTHZ_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500); -pub(super) const STALE_LOCK_AFTER: std::time::Duration = std::time::Duration::from_secs(10); +pub(super) use crate::sidecar::{DEFAULT_BIND, DEFAULT_URL}; +const DEFAULT_HOOK_STDIN_BYTES: usize = crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES; pub(crate) fn run(command: PluginShimCommand) -> Result { match command.command { @@ -52,29 +60,64 @@ fn serve(args: Vec) -> Result { let bind = env::var("NEMO_RELAY_PLUGIN_BIND").unwrap_or_else(|_| DEFAULT_BIND.into()); let mut command = Command::new(relay); command.arg("--bind").arg(bind).args(args); - command.env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", plugin_idle_timeout()); + command.env( + "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", + plugin_idle_timeout()?.as_secs().to_string(), + ); command .exec_or_status() .map_err(|error| format!("failed to start nemo-relay sidecar: {error}")) } fn hook(agent: CodingAgent, explicit_gateway_url: Option<&str>) -> Result { + let url = gateway_url(agent, explicit_gateway_url); + let codex_launch = if matches!(agent, CodingAgent::Codex) { + Some(crate::sidecar::loopback_bind(&url).and_then(|bind| { + crate::sidecar::resolve_codex_gateway(&ServerArgs::default(), bind) + .map_err(|error| error.to_string()) + })) + } else { + None + }; + let max_hook_payload_bytes = codex_launch + .as_ref() + .and_then(|launch| launch.as_ref().ok()) + .map_or(DEFAULT_HOOK_STDIN_BYTES, |launch| { + launch.max_hook_payload_bytes + }); let mut input = std::io::stdin(); let mut output = std::io::stdout(); hook_with_io( - agent, - explicit_gateway_url, + HookInvocation { + agent, + gateway_url: Some(&url), + max_payload_bytes: max_hook_payload_bytes, + preflight_gateway: matches!(agent, CodingAgent::Codex), + }, &mut input, &mut output, - shared::ensure_sidecar, + |agent, url| match codex_launch.as_ref() { + Some(Ok(launch)) => launch.gateway.ensure().map(|_| ()), + Some(Err(error)) => Err(error.clone()), + None => crate::sidecar::loopback_bind(url) + .and_then(|bind| crate::sidecar::GatewaySpec::new(agent, bind).ensure()) + .map(|_| ()), + }, post_hook, fail_closed, ) } -fn hook_with_io( +#[derive(Clone, Copy)] +struct HookInvocation<'a> { agent: CodingAgent, - explicit_gateway_url: Option<&str>, + gateway_url: Option<&'a str>, + max_payload_bytes: usize, + preflight_gateway: bool, +} + +fn hook_with_io( + invocation: HookInvocation<'_>, input: &mut R, output: &mut W, mut ensure_sidecar: E, @@ -84,20 +127,44 @@ fn hook_with_io( where R: Read, W: Write, - E: FnMut(CodingAgent, &str), - P: FnMut(CodingAgent, &str, &[u8]) -> Result, String>, + E: FnMut(CodingAgent, &str) -> Result<(), String>, + P: FnMut(CodingAgent, &str, &[u8]) -> Result, HookForwardError>, F: FnOnce() -> bool, { - let url = gateway_url(agent, explicit_gateway_url); + let agent = invocation.agent; + let url = gateway_url(agent, invocation.gateway_url); + let fail_closed = fail_closed(); let mut payload = Vec::new(); - input - .read_to_end(&mut payload) - .map_err(|error| format!("failed to read hook payload: {error}"))?; + if let Err(error) = read_hook_payload(input, &mut payload, invocation.max_payload_bytes) { + if fail_closed { + return Err(error); + } + eprintln!("{error}"); + return Ok(ExitCode::SUCCESS); + } if payload.iter().all(u8::is_ascii_whitespace) { payload = b"{}".to_vec(); } - ensure_sidecar(agent, &url); - match post_hook(agent, &url, &payload) { + let preflight = if invocation.preflight_gateway { + ensure_sidecar(agent, &url) + } else { + Ok(()) + }; + let forwarded = match preflight { + Err(error) => Err(HookForwardError::not_retryable(format!( + "gateway identity preflight failed: {error}" + ))), + Ok(()) => match post_hook(agent, &url, &payload) { + Err(error) if error.is_retryable() => match ensure_sidecar(agent, &url) { + Ok(()) => post_hook(agent, &url, &payload), + Err(start_error) => Err(HookForwardError::not_retryable(format!( + "{error}; sidecar bootstrap failed: {start_error}" + ))), + }, + result => result, + }, + }; + match forwarded { Ok(body) => { if !body.is_empty() { output @@ -106,7 +173,7 @@ where } Ok(ExitCode::SUCCESS) } - Err(error) if fail_closed() => Err(error), + Err(error) if fail_closed => Err(error.to_string()), Err(error) => { eprintln!("{error}"); Ok(ExitCode::SUCCESS) @@ -114,9 +181,24 @@ where } } +fn read_hook_payload( + input: &mut R, + payload: &mut Vec, + limit: usize, +) -> Result<(), String> { + input + .take(limit.saturating_add(1) as u64) + .read_to_end(payload) + .map_err(|error| format!("failed to read hook payload: {error}"))?; + if payload.len() > limit { + return Err(format!("hook payload exceeds the {limit}-byte limit")); + } + Ok(()) +} + fn install(command: PluginShimInstallCommand) -> Result { match command.agent { - CodingAgent::Codex => install_codex(&command.gateway_url), + CodingAgent::Codex => install_codex(&command.gateway_url, &plugin_hooks_path_from_env()?), other => Err(format!( "plugin install supports codex, got {}", other.as_arg() @@ -126,7 +208,7 @@ fn install(command: PluginShimInstallCommand) -> Result { fn uninstall(command: PluginShimUninstallCommand) -> Result { match command.agent { - CodingAgent::Codex => uninstall_codex(&command.gateway_url), + CodingAgent::Codex => uninstall_codex(&command.gateway_url, &plugin_hooks_path_from_env()?), other => Err(format!( "plugin uninstall supports codex, got {}", other.as_arg() @@ -145,19 +227,32 @@ fn provider(command: PluginShimProviderCommand) -> Result { } fn doctor(command: PluginShimDoctorCommand) -> Result { - Ok(if doctor_ok(command.agent, &command.gateway_url)? { - ExitCode::SUCCESS - } else { - ExitCode::FAILURE - }) + let plugin_hooks = matches!(command.agent, CodingAgent::Codex) + .then(plugin_hooks_path_from_env) + .transpose()?; + Ok( + if doctor_ok(command.agent, &command.gateway_url, plugin_hooks.as_deref())? { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }, + ) } -pub(crate) fn install_codex_plugin(gateway_url: &str) -> Result<(), String> { - install_codex(gateway_url).map(|_| ()) +pub(crate) fn install_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Result<(), String> { + install_codex(gateway_url, &plugin_root.join("hooks").join("hooks.json")).map(|_| ()) } -pub(crate) fn uninstall_codex_plugin(gateway_url: &str) -> Result<(), String> { - uninstall_codex(gateway_url).map(|_| ()) +pub(crate) fn stop_codex_gateway() -> Result<(), String> { + crate::sidecar::stop_owned_sidecar(CodingAgent::Codex) +} + +pub(crate) fn codex_plugin_hook_command(relay: &std::path::Path) -> String { + codex::codex_plugin_hook_command(relay) +} + +pub(crate) fn uninstall_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Result<(), String> { + uninstall_codex(gateway_url, &plugin_root.join("hooks").join("hooks.json")).map(|_| ()) } pub(crate) fn enable_claude_provider(gateway_url: &str) -> Result<(), String> { @@ -168,18 +263,30 @@ pub(crate) fn restore_claude_provider(gateway_url: &str) -> Result<(), String> { claude_provider(PluginShimProviderAction::Restore, gateway_url).map(|_| ()) } -pub(crate) fn doctor_plugin(agent: CodingAgent, gateway_url: &str) -> Result<(), String> { - if doctor_ok(agent, gateway_url)? { +pub(crate) fn doctor_plugin( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, +) -> Result<(), String> { + if doctor_ok( + agent, + gateway_url, + Some(&plugin_root.join("hooks").join("hooks.json")), + )? { Ok(()) } else { Err(format!("{} plugin doctor checks failed", agent.as_arg())) } } -pub(crate) fn doctor_plugin_json(agent: CodingAgent, gateway_url: &str) -> Result { +pub(crate) fn doctor_plugin_json( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, +) -> Result { let plugin_binary = current_exe().ok().is_some_and(|path| path.exists()); let sidecar_running = healthz(gateway_url); - let (checks, ok) = match agent { + let (checks, ok, codex_trust) = match agent { CodingAgent::ClaudeCode => { let provider = claude_settings_base_url().as_deref() == Some(gateway_url); ( @@ -189,19 +296,29 @@ pub(crate) fn doctor_plugin_json(agent: CodingAgent, gateway_url: &str) -> Resul "claude_provider_routing": provider }), plugin_binary && provider, + None, ) } CodingAgent::Codex => { + let plugin_hooks_path = plugin_root.join("hooks").join("hooks.json"); let provider = codex_provider_installed(gateway_url); - let hooks = codex_hooks_installed(gateway_url)?; + let hooks = codex_hooks_installed(&plugin_hooks_path)?; + let trust = if hooks { + codex_hook_trust_report(&plugin_hooks_path)? + } else { + empty_codex_hook_trust_report() + }; + let hooks_trusted = trust.ready(); ( json!({ "plugin_binary": plugin_binary, "sidecar_running": sidecar_running, "codex_provider_alias": provider, - "codex_hooks": hooks + "codex_hooks": hooks, + "codex_hooks_trusted": hooks_trusted }), - plugin_binary && provider && hooks, + plugin_binary && provider && hooks && hooks_trusted, + Some(trust), ) } other => { @@ -211,18 +328,28 @@ pub(crate) fn doctor_plugin_json(agent: CodingAgent, gateway_url: &str) -> Resul )); } }; - Ok(json!({ + let mut report = json!({ "ok": ok, "sidecar_health": if sidecar_running { "running" + } else if matches!(agent, CodingAgent::Codex) { + "not_running_mcp_start" } else { "not_running_lazy_start" }, "checks": checks - })) + }); + if let Some(trust) = codex_trust { + report["codex_hook_trust"] = trust.to_json(); + } + Ok(report) } -fn doctor_ok(agent: CodingAgent, gateway_url: &str) -> Result { +fn doctor_ok( + agent: CodingAgent, + gateway_url: &str, + plugin_hooks_path: Option<&Path>, +) -> Result { let mut ok = true; ok &= print_check( "plugin binary", @@ -230,6 +357,11 @@ fn doctor_ok(agent: CodingAgent, gateway_url: &str) -> Result { ); if healthz(gateway_url) { print_info("sidecar health", "running"); + } else if matches!(agent, CodingAgent::Codex) { + print_info( + "sidecar health", + "not running; the required plugin MCP starts it before the captured turn", + ); } else { print_info( "sidecar health", @@ -244,11 +376,21 @@ fn doctor_ok(agent: CodingAgent, gateway_url: &str) -> Result { ); } CodingAgent::Codex => { - ok &= print_check( - "codex provider alias", - codex_provider_installed(gateway_url), - ); - ok &= print_check("codex hooks", codex_hooks_installed(gateway_url)?); + let plugin_hooks_path = plugin_hooks_path + .ok_or_else(|| "Codex plugin hooks path is required for doctor".to_string())?; + let provider = codex_provider_installed(gateway_url); + let hooks = codex_hooks_installed(plugin_hooks_path)?; + ok &= print_check("codex provider alias", provider); + ok &= print_check("codex hooks", hooks); + let trust = if hooks { + codex_hook_trust_report(plugin_hooks_path)? + } else { + empty_codex_hook_trust_report() + }; + ok &= print_check("codex hooks trusted and enabled", trust.ready()); + if !trust.ready() { + print_info("codex hook trust", &trust.summary()); + } } other => { return Err(format!( @@ -260,13 +402,24 @@ fn doctor_ok(agent: CodingAgent, gateway_url: &str) -> Result { Ok(ok) } +fn plugin_hooks_path_from_env() -> Result { + env::var_os("PLUGIN_ROOT") + .map(PathBuf::from) + .map(|root| root.join("hooks").join("hooks.json")) + .ok_or_else(|| "PLUGIN_ROOT is required for Codex plugin hook setup".into()) +} + #[cfg(test)] use crate::installer::generated_hooks; #[cfg(test)] +use crate::sidecar::*; +#[cfg(test)] use claude::*; #[cfg(test)] use codex::*; #[cfg(test)] +use codex_app_server::*; +#[cfg(test)] use shared::*; #[cfg(test)] diff --git a/crates/cli/src/plugin_shim/shared.rs b/crates/cli/src/plugin_shim/shared.rs index 70cae8d49..8462f718d 100644 --- a/crates/cli/src/plugin_shim/shared.rs +++ b/crates/cli/src/plugin_shim/shared.rs @@ -1,77 +1,25 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Shared plugin-shim filesystem, sidecar, HTTP, and formatting helpers. +//! Filesystem, hook transport, and process helpers shared by plugin shims. use std::env; -use std::fs::{self, OpenOptions}; +use std::fs; use std::io::{Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode, Stdio}; -use std::thread; +use std::process::{Command, ExitCode}; use std::time::Duration; -use reqwest::Url; use serde_json::{Value, json}; use toml_edit::{DocumentMut, Item, Table}; use crate::config::CodingAgent; +pub(super) use crate::file_io::atomic_write; +use crate::sidecar::{DEFAULT_URL, loopback_authority, parse_loopback_url}; +pub(super) use crate::sidecar::{current_exe, healthz, plugin_idle_timeout, relay_binary}; -use super::{DEFAULT_URL, HEALTHZ_TIMEOUT, STALE_LOCK_AFTER}; - -pub(super) fn ensure_sidecar(agent: CodingAgent, url: &str) { - if healthz(url) { - return; - } - let runtime = runtime_dir(); - let _ = fs::create_dir_all(&runtime); - let lock = runtime.join(format!("{}-sidecar.lock", sidecar_lock_name(url))); - let mut acquired = false; - for _ in 0..40 { - match fs::create_dir(&lock) { - Ok(()) => { - acquired = true; - break; - } - Err(_) if healthz(url) => return, - Err(_) if repair_stale_lock(&lock) => continue, - Err(_) => thread::sleep(Duration::from_millis(50)), - } - } - if !acquired { - eprintln!("nemo-relay sidecar lock timed out"); - return; - } - let result = start_sidecar(agent, url, &runtime); - let _ = fs::remove_dir(&lock); - if let Err(error) = result { - eprintln!("{error}"); - } -} - -pub(super) fn repair_stale_lock(lock: &Path) -> bool { - repair_stale_lock_after(lock, STALE_LOCK_AFTER) -} - -pub(super) fn repair_stale_lock_after(lock: &Path, stale_after: Duration) -> bool { - if !lock.exists() || !lock_is_old(lock, stale_after) { - return false; - } - match fs::remove_dir_all(lock) { - Ok(()) => return true, - Err(error) => eprintln!("failed to repair stale nemo-relay sidecar lock: {error}"), - } - false -} - -pub(super) fn lock_is_old(lock: &Path, stale_after: Duration) -> bool { - lock.metadata() - .and_then(|metadata| metadata.modified()) - .ok() - .and_then(|modified| modified.elapsed().ok()) - .is_some_and(|elapsed| elapsed >= stale_after) -} +pub(super) const MAX_HOOK_RESPONSE_BYTES: usize = 1024 * 1024; pub(super) fn ensure_table<'a>(doc: &'a mut DocumentMut, name: &str) -> &'a mut Table { if !doc.as_table().contains_key(name) || !doc[name].is_table() { @@ -101,86 +49,6 @@ pub(super) fn write_json(path: &Path, value: &Value) -> Result<(), String> { atomic_write(path, &bytes) } -pub(super) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; - } - let tmp = path.with_extension(format!( - "{}tmp", - path.extension() - .and_then(|value| value.to_str()) - .map(|value| format!("{value}.")) - .unwrap_or_default() - )); - fs::write(&tmp, bytes) - .map_err(|error| format!("failed to write {}: {error}", tmp.display()))?; - replace_file(&tmp, path) -} - -#[cfg(not(windows))] -pub(super) fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { - fs::rename(tmp, path).map_err(|error| format!("failed to replace {}: {error}", path.display())) -} - -#[cfg(windows)] -pub(super) fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { - if !path.exists() { - return fs::rename(tmp, path) - .map_err(|error| format!("failed to replace {}: {error}", path.display())); - } - - let backup = replace_backup_path(path); - match fs::remove_file(&backup) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(format!( - "failed to remove stale replacement backup {}: {error}", - backup.display() - )); - } - } - - match fs::rename(path, &backup) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return fs::rename(tmp, path) - .map_err(|error| format!("failed to replace {}: {error}", path.display())); - } - Err(error) => { - return Err(format!( - "failed to prepare replacement for {}: {error}", - path.display() - )); - } - } - - match fs::rename(tmp, path) { - Ok(()) => { - let _ = fs::remove_file(&backup); - Ok(()) - } - Err(error) => match fs::rename(&backup, path) { - Ok(()) => Err(format!("failed to replace {}: {error}", path.display())), - Err(restore_error) => Err(format!( - "failed to replace {}: {error}; additionally failed to restore {}: {restore_error}", - path.display(), - backup.display() - )), - }, - } -} - -#[cfg(windows)] -pub(super) fn replace_backup_path(path: &Path) -> PathBuf { - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("config"); - path.with_file_name(format!(".{file_name}.nemo-relay-replace.tmp")) -} - pub(super) fn backup(path: &Path) -> Result<(), String> { let backup = backup_path(path); if backup.exists() { @@ -270,137 +138,110 @@ pub(super) fn restore_file_snapshot(snapshot: &FileSnapshot) -> Result<(), Strin } } -pub(super) fn start_sidecar(agent: CodingAgent, url: &str, runtime: &Path) -> Result<(), String> { - if healthz(url) { - return Ok(()); - } - let (_, port) = parse_loopback_url(url)?; - let bind = format!("127.0.0.1:{port}"); - let relay = relay_binary()?; - let log_path = runtime.join(format!("{}-sidecar.log", agent.as_arg())); - let log = OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - .map_err(|error| format!("failed to open {}: {error}", log_path.display()))?; - let err_log = log - .try_clone() - .map_err(|error| format!("failed to clone sidecar log handle: {error}"))?; - let mut child = Command::new(relay) - .arg("--bind") - .arg(bind) - .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", plugin_idle_timeout()) - .stdin(Stdio::null()) - .stdout(Stdio::from(log)) - .stderr(Stdio::from(err_log)) - .spawn() - .map_err(|error| format!("failed to spawn nemo-relay sidecar: {error}"))?; - let pid_path = runtime.join(format!("{}-sidecar.pid", agent.as_arg())); - let _ = fs::write(&pid_path, child.id().to_string()); - for _ in 0..50 { - if healthz(url) { - return Ok(()); - } - match child.try_wait() { - Ok(Some(status)) => { - let _ = fs::remove_file(&pid_path); - return Err(format!( - "nemo-relay sidecar exited before becoming ready at {url}: {status}" - )); - } - Ok(None) => {} - Err(error) => { - let _ = fs::remove_file(&pid_path); - return Err(format!( - "failed to inspect nemo-relay sidecar process: {error}" - )); - } - } - thread::sleep(Duration::from_millis(50)); - } - terminate_unready_sidecar(child, &pid_path, url) +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct HookForwardError { + message: String, + retryable: bool, } -pub(super) fn terminate_unready_sidecar( - mut child: std::process::Child, - pid_path: &Path, - url: &str, -) -> Result<(), String> { - match child.try_wait() { - Ok(Some(status)) => { - let _ = fs::remove_file(pid_path); - return Err(format!( - "nemo-relay sidecar exited before becoming ready at {url}: {status}" - )); +impl HookForwardError { + pub(super) fn retryable(message: impl Into) -> Self { + Self { + message: message.into(), + retryable: true, } - Ok(None) => {} - Err(error) => { - let _ = fs::remove_file(pid_path); - return Err(format!( - "failed to inspect nemo-relay sidecar process: {error}" - )); + } + + pub(super) fn not_retryable(message: impl Into) -> Self { + Self { + message: message.into(), + retryable: false, } } - if let Err(error) = child.kill() { - let _ = fs::remove_file(pid_path); - return Err(format!( - "nemo-relay sidecar did not become ready at {url}; failed to terminate startup process: {error}" - )); + + pub(super) fn is_retryable(&self) -> bool { + self.retryable + } +} + +impl std::fmt::Display for HookForwardError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) } - let _ = child.wait(); - let _ = fs::remove_file(pid_path); - Err(format!( - "nemo-relay sidecar did not become ready at {url}; terminated startup process" - )) } -pub(super) fn post_hook(agent: CodingAgent, url: &str, payload: &[u8]) -> Result, String> { +pub(super) fn post_hook( + agent: CodingAgent, + url: &str, + payload: &[u8], +) -> Result, HookForwardError> { let hook_path = match agent { CodingAgent::ClaudeCode => "/hooks/claude-code", CodingAgent::Codex => "/hooks/codex", _ => { - return Err(format!( + return Err(HookForwardError::not_retryable(format!( "plugin shim hook forwarding supports claude and codex, got {}", agent.as_arg() - )); + ))); } }; - let (host, port) = parse_loopback_url(url)?; + let (host, port) = parse_loopback_url(url).map_err(HookForwardError::not_retryable)?; let addrs = (host.as_str(), port) .to_socket_addrs() - .map_err(|error| format!("hook forward failed: {error}"))?; + .map_err(|error| HookForwardError::retryable(format!("hook forward failed: {error}")))?; let mut stream = None; + let mut connect_error = None; for addr in addrs { match TcpStream::connect_timeout(&addr, Duration::from_secs(2)) { Ok(candidate) => { stream = Some(candidate); break; } - Err(_) => continue, + Err(error) => connect_error = Some(error), } } let Some(mut stream) = stream else { - return Err("hook forward failed: connection timed out".into()); + let detail = connect_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "no loopback address resolved".into()); + return Err(HookForwardError::retryable(format!( + "hook forward failed before sending request bytes: {detail}" + ))); }; stream .set_read_timeout(Some(Duration::from_secs(2))) - .map_err(|error| format!("failed to set read timeout: {error}"))?; + .map_err(|error| { + HookForwardError::not_retryable(format!("failed to set read timeout: {error}")) + })?; stream .set_write_timeout(Some(Duration::from_secs(2))) - .map_err(|error| format!("failed to set write timeout: {error}"))?; + .map_err(|error| { + HookForwardError::not_retryable(format!("failed to set write timeout: {error}")) + })?; let request = format!( - "POST {hook_path} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + "POST {hook_path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + loopback_authority(&host, port), payload.len() ); stream .write_all(request.as_bytes()) .and_then(|_| stream.write_all(payload)) - .map_err(|error| format!("hook forward failed: {error}"))?; + .map_err(|error| { + HookForwardError::not_retryable(format!("hook forward failed: {error}")) + })?; let mut response = Vec::new(); stream + .take(MAX_HOOK_RESPONSE_BYTES.saturating_add(1) as u64) .read_to_end(&mut response) - .map_err(|error| format!("hook forward failed: {error}"))?; - parse_http_response(&response) + .map_err(|error| { + HookForwardError::not_retryable(format!("hook forward failed: {error}")) + })?; + if response.len() > MAX_HOOK_RESPONSE_BYTES { + return Err(HookForwardError::not_retryable(format!( + "hook forward response exceeds the {MAX_HOOK_RESPONSE_BYTES}-byte limit" + ))); + } + parse_http_response(&response).map_err(HookForwardError::not_retryable) } pub(super) fn parse_http_response(response: &[u8]) -> Result, String> { @@ -428,62 +269,6 @@ pub(super) fn parse_http_response(response: &[u8]) -> Result, String> { } } -pub(super) fn healthz(url: &str) -> bool { - let Ok((host, port)) = parse_loopback_url(url) else { - return false; - }; - let Ok(addrs) = (host.as_str(), port).to_socket_addrs() else { - return false; - }; - let mut stream = None; - for addr in addrs { - match TcpStream::connect_timeout(&addr, HEALTHZ_TIMEOUT) { - Ok(candidate) => { - stream = Some(candidate); - break; - } - Err(_) => continue, - } - } - let Some(mut stream) = stream else { - return false; - }; - if stream.set_read_timeout(Some(HEALTHZ_TIMEOUT)).is_err() - || stream.set_write_timeout(Some(HEALTHZ_TIMEOUT)).is_err() - { - return false; - } - let request = - format!("GET /healthz HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"); - if stream.write_all(request.as_bytes()).is_err() { - return false; - } - let mut response = [0_u8; 32]; - stream - .read(&mut response) - .ok() - .is_some_and(|count| response[..count].starts_with(b"HTTP/1.1 200")) -} - -pub(super) fn parse_loopback_url(url: &str) -> Result<(String, u16), String> { - let without_scheme = url - .strip_prefix("http://") - .ok_or_else(|| format!("plugin shim only supports http loopback URLs: {url}"))?; - let authority = without_scheme.split('/').next().unwrap_or(without_scheme); - let (host, port) = authority - .rsplit_once(':') - .ok_or_else(|| format!("missing port in gateway URL: {url}"))?; - if host != "127.0.0.1" && host != "localhost" { - return Err(format!( - "plugin shim only supports loopback gateway URLs: {url}" - )); - } - let port = port - .parse::() - .map_err(|error| format!("invalid gateway port in {url}: {error}"))?; - Ok((host.to_string(), port)) -} - pub(super) fn gateway_url(agent: CodingAgent, explicit: Option<&str>) -> String { if let Some(url) = explicit { return url.to_string(); @@ -496,96 +281,46 @@ pub(super) fn gateway_url(agent: CodingAgent, explicit: Option<&str>) -> String env::var("NEMO_RELAY_PLUGIN_GATEWAY_URL").unwrap_or_else(|_| DEFAULT_URL.into()) } -pub(super) fn relay_binary() -> Result { - if let Ok(path) = env::var("NEMO_RELAY_PLUGIN_BINARY") { - let path = PathBuf::from(path); - if path.exists() { - return Ok(path); - } - return Err(format!( - "NEMO_RELAY_PLUGIN_BINARY does not exist: {}", - path.display() - )); - } - current_exe() -} - -pub(super) fn current_exe() -> Result { - env::current_exe().map_err(|error| format!("failed to resolve current executable: {error}")) -} - -pub(super) fn runtime_dir() -> PathBuf { - runtime_dir_for( - env::var_os("XDG_RUNTIME_DIR"), - env::var_os("TMPDIR"), - env::var_os("TEMP"), - env::temp_dir(), - env::var_os("USER"), - env::var_os("USERNAME"), - ) -} - -pub(super) fn runtime_dir_for( - xdg_runtime_dir: Option, - tmpdir: Option, - temp: Option, - temp_dir: PathBuf, - user: Option, - username: Option, -) -> PathBuf { - if let Some(base) = xdg_runtime_dir.or(tmpdir).or(temp) { - return PathBuf::from(base).join("nemo-relay-plugin"); - } - temp_dir - .join(runtime_user_segment(user, username)) - .join("nemo-relay-plugin") -} - -pub(super) fn sidecar_lock_name(url: &str) -> String { - let raw = Url::parse(url) - .ok() - .and_then(|parsed| { - let host = parsed.host_str()?; - let port = parsed.port_or_known_default()?; - Some(format!("{host}-{port}")) - }) - .unwrap_or_else(|| url.to_string()); - sanitize_filesystem_segment(&raw) -} +#[cfg(windows)] +pub(crate) fn portable_executable_path(path: PathBuf) -> PathBuf { + use std::ffi::OsString; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; -fn runtime_user_segment( - user: Option, - username: Option, -) -> String { - let raw = user - .or(username) - .and_then(|value| value.into_string().ok()) - .unwrap_or_else(|| "unknown-user".into()); - sanitize_filesystem_segment(&raw) + let encoded = path.as_os_str().encode_wide().collect::>(); + strip_windows_verbatim_prefix(&encoded) + .map(|value| OsString::from_wide(&value)) + .map(PathBuf::from) + .unwrap_or(path) } -fn sanitize_filesystem_segment(raw: &str) -> String { - let sanitized: String = raw - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { - character - } else { - '_' - } - }) - .collect(); - if sanitized.is_empty() { - "unknown".into() +#[cfg(not(windows))] +pub(crate) fn portable_executable_path(path: PathBuf) -> PathBuf { + path +} + +#[cfg(any(test, windows))] +pub(crate) fn strip_windows_verbatim_prefix(encoded: &[u16]) -> Option> { + const VERBATIM_PREFIX: &[u16] = &[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16]; + const VERBATIM_UNC_PREFIX: &[u16] = &[ + b'\\' as u16, + b'\\' as u16, + b'?' as u16, + b'\\' as u16, + b'U' as u16, + b'N' as u16, + b'C' as u16, + b'\\' as u16, + ]; + + if let Some(rest) = encoded.strip_prefix(VERBATIM_UNC_PREFIX) { + let mut normalized = vec![b'\\' as u16, b'\\' as u16]; + normalized.extend_from_slice(rest); + Some(normalized) } else { - sanitized + encoded.strip_prefix(VERBATIM_PREFIX).map(ToOwned::to_owned) } } -pub(super) fn plugin_idle_timeout() -> String { - env::var("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS").unwrap_or_else(|_| "300".into()) -} - pub(super) fn fail_closed() -> bool { env::var("NEMO_RELAY_FAIL_CLOSED").ok().as_deref() == Some("1") } diff --git a/crates/cli/src/plugins/config_io.rs b/crates/cli/src/plugins/config_io.rs index d3c55fc3e..2f07c89b6 100644 --- a/crates/cli/src/plugins/config_io.rs +++ b/crates/cli/src/plugins/config_io.rs @@ -6,7 +6,6 @@ use std::path::{Path, PathBuf}; use console::style; -use nemo_relay::plugin::dynamic::DynamicPluginManifest; use nemo_relay::plugin::{ConfigPolicy, PluginConfig, validate_plugin_config}; use nemo_relay_adaptive::plugin_component::register_adaptive_component; use nemo_relay_pii_redaction::component::register_pii_redaction_component; @@ -424,7 +423,7 @@ pub(crate) fn remove_dynamic_plugin_reference( target_manifest_ref .as_ref() .is_some_and(|target_manifest_ref| manifest_ref == target_manifest_ref) - || DynamicPluginManifest::load_from_path(manifest_ref) + || crate::config::load_bounded_dynamic_plugin_manifest(manifest_ref) .map(|(manifest, _)| manifest.plugin.id.trim() == plugin_id) .unwrap_or(false) }); diff --git a/crates/cli/src/plugins/dynamic_editor.rs b/crates/cli/src/plugins/dynamic_editor.rs index 99111f7ab..8a269fd25 100644 --- a/crates/cli/src/plugins/dynamic_editor.rs +++ b/crates/cli/src/plugins/dynamic_editor.rs @@ -282,13 +282,15 @@ fn load_dynamic_plugin_state( entry: DynamicPluginConfigEntry, plugin_ids: &mut HashSet, ) -> Result { - let (manifest, manifest_ref) = DynamicPluginManifest::load_from_path(&entry.manifest_path) - .map_err(|error| { - CliError::Config(format!( - "could not load dynamic plugin manifest '{}' for editing: {error}", - entry.manifest - )) - })?; + let (manifest, manifest_ref) = crate::config::load_bounded_dynamic_plugin_manifest( + &entry.manifest_path, + ) + .map_err(|error| { + CliError::Config(format!( + "could not load dynamic plugin manifest '{}' for editing: {error}", + entry.manifest + )) + })?; let plugin_id = manifest.plugin.id.trim().to_owned(); if !plugin_ids.insert(plugin_id.clone()) { return Err(CliError::Config(format!( diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index 8ee4d7b2c..fb554f69e 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -1,23 +1,27 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; +use std::fs; use std::path::Path; use std::path::PathBuf; use std::process::ExitCode; +use std::sync::Arc; use nemo_relay::plugin::dynamic::{ DynamicPluginCheckState, DynamicPluginCompatibility, DynamicPluginFailure, DynamicPluginFailurePhase, DynamicPluginKind, DynamicPluginLoadContract, DynamicPluginManifest, - DynamicPluginRecord, DynamicPluginValidationStatus, + DynamicPluginManifestLoad, DynamicPluginRecord, DynamicPluginValidationStatus, WorkerRuntime, }; use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; use crate::config::{ - PluginsAddCommand, PluginsDisableCommand, PluginsEnableCommand, PluginsInspectCommand, - PluginsListCommand, PluginsRemoveCommand, PluginsValidateCommand, ResolvedConfig, - ResolvedDynamicPluginConfig, ServerArgs, resolve_plugins_config, + MAX_BOOTSTRAP_IDENTITY_FILE_BYTES, PluginsAddCommand, PluginsDisableCommand, + PluginsEnableCommand, PluginsInspectCommand, PluginsListCommand, PluginsRemoveCommand, + PluginsValidateCommand, ResolvedConfig, ResolvedDynamicPluginConfig, ServerArgs, + load_bounded_dynamic_plugin_manifest_bytes, read_bounded_regular_file, resolve_plugins_config, }; use crate::error::{CliError, PluginLifecycleFailureKind}; use crate::plugins::policy::{ @@ -36,8 +40,10 @@ mod target; mod trust; use self::environment::{ - ProcessPythonEnvironmentCommandRunner, PythonEnvironmentCommandRunner, environment_state, - provision_python_environment, remove_managed_environment, + ENVIRONMENT_ATTESTATION_FILE, MANAGED_ENVIRONMENTS_DIR, ProcessPythonEnvironmentCommandRunner, + PythonEnvironmentCommandRunner, environment_state, provision_python_environment, + read_environment_attestation, remove_managed_environment, validate_python_entrypoint_artifact, + verify_environment_attestation, }; use self::responses::{ ValidateResponseInput, failure, generic_failure, inspect_data, inspect_success, list_success, @@ -52,6 +58,24 @@ use self::trust::{EvaluatedDynamicPluginTrust, evaluate_dynamic_plugin_trust}; const VALIDATION_MESSAGE: &str = "validated by CLI"; +#[cfg(test)] +pub(crate) fn attest_test_python_environment( + environment: &Path, + source_artifact_sha256: &str, +) -> Result<(), String> { + self::environment::write_environment_attestation(environment, source_artifact_sha256) +} + +#[cfg(test)] +pub(crate) fn reset_test_python_environment_digest_calls() { + self::environment::reset_environment_tree_digest_calls(); +} + +#[cfg(test)] +pub(crate) fn test_python_environment_digest_calls() -> usize { + self::environment::environment_tree_digest_calls() +} + pub(crate) fn add(command: PluginsAddCommand, server: &ServerArgs) -> Result<(), CliError> { add_with_environment_runner(command, server, &ProcessPythonEnvironmentCommandRunner) } @@ -443,46 +467,1275 @@ pub(crate) fn remove(command: PluginsRemoveCommand, server: &ServerArgs) -> Resu pub(crate) struct ActiveDynamicPluginComponent { pub(crate) plugin_id: String, pub(crate) kind: DynamicPluginKind, + pub(crate) lifecycle_generation: u64, pub(crate) manifest_ref: Option, pub(crate) environment_ref: Option, pub(crate) config: Map, + pub(crate) activation_snapshot: Option>, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct DynamicPluginActivationSnapshot { + root: PathBuf, + original_manifest_ref: String, + identity_manifest: PathBuf, + activation_manifest: PathBuf, + activation_environment_ref: Option, + identity_files: HashMap, + closure_digest: String, + verification_digest: String, +} + +impl DynamicPluginActivationSnapshot { + fn create( + manifest_ref: &str, + expected_plugin_id: &str, + expected_kind: DynamicPluginKind, + environment_ref: Option<&str>, + host_policy: &crate::plugins::policy::DynamicPluginHostPolicy, + ) -> Result, CliError> { + let (mut manifest, original_manifest_ref, manifest_bytes) = + load_bounded_dynamic_plugin_manifest_bytes(manifest_ref)?; + if manifest.plugin.id.trim() != expected_plugin_id || manifest.plugin.kind != expected_kind + { + return Err(CliError::Config(format!( + "dynamic plugin manifest identity changed before activation for '{expected_plugin_id}'" + ))); + } + let policy = evaluate_dynamic_plugin_host_policy(host_policy, &manifest); + validate_python_entrypoint_artifact(&manifest, &original_manifest_ref) + .map_err(CliError::Config)?; + + let root = std::env::temp_dir().join(format!( + "nemo-relay-plugin-snapshot-{}", + uuid::Uuid::now_v7().simple() + )); + fs::create_dir(&root).map_err(|error| { + CliError::Config(format!( + "failed to create dynamic plugin activation snapshot {}: {error}", + root.display() + )) + })?; + let mut root_guard = SnapshotRootGuard(Some(root.clone())); + #[cfg(unix)] + fs::set_permissions(&root, { + use std::os::unix::fs::PermissionsExt; + fs::Permissions::from_mode(0o700) + }) + .map_err(|error| { + CliError::Config(format!( + "failed to protect dynamic plugin activation snapshot {}: {error}", + root.display() + )) + })?; + + let identity_manifest = root.join("identity-manifest.toml"); + fs::write(&identity_manifest, &manifest_bytes).map_err(|error| { + CliError::Config(format!( + "failed to write dynamic plugin activation snapshot {}: {error}", + identity_manifest.display() + )) + })?; + let original_manifest_path = PathBuf::from(&original_manifest_ref); + let manifest_directory = original_manifest_path + .parent() + .ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin manifest {} has no parent directory", + original_manifest_path.display() + )) + })? + .to_path_buf(); + let runtime_root = root.join("runtime"); + let mut budget = SnapshotBudget::default(); + let mut copied_files = HashMap::new(); + copy_snapshot_directory( + &manifest_directory, + &runtime_root, + &mut copied_files, + &mut budget, + false, + &mut Vec::new(), + )?; + let declared_artifact = manifest + .source + .as_ref() + .and_then(|source| source.artifact.as_deref()) + .map(|artifact| fs::canonicalize(resolve_manifest_relative_path(&original_manifest_path, artifact))) + .transpose() + .map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin artifact for '{expected_plugin_id}': {error}" + )) + })?; + let mut identity_files = HashMap::new(); + + match &mut manifest.load { + DynamicPluginManifestLoad::RustDynamic(load) => { + if let Some(library) = load.library.as_deref() { + let (logical, _, copied) = copy_snapshot_file( + &root, + &original_manifest_path, + library, + "library", + &mut copied_files, + &mut budget, + )?; + identity_files + .entry(logical) + .or_insert_with(|| copied.clone()); + load.library = Some(copied.to_string_lossy().into_owned()); + } + } + DynamicPluginManifestLoad::Worker(load) + if matches!( + load.runtime, + Some(WorkerRuntime::Rust | WorkerRuntime::Command) + ) => + { + if let Some(entrypoint) = load.entrypoint.as_deref() { + let (logical, canonical, copied) = copy_snapshot_file( + &root, + &original_manifest_path, + entrypoint, + "entrypoint", + &mut copied_files, + &mut budget, + )?; + if declared_artifact.as_ref() != Some(&canonical) { + return Err(CliError::Config(format!( + "command worker dynamic plugin '{expected_plugin_id}' must declare its load.entrypoint as the integrity-checked source.artifact" + ))); + } + identity_files + .entry(logical) + .or_insert_with(|| copied.clone()); + load.entrypoint = Some(copied.to_string_lossy().into_owned()); + } + } + DynamicPluginManifestLoad::Worker(_) => {} + } + + if let Some(source) = manifest.source.as_mut() + && let Some(artifact) = source.artifact.as_deref() + { + let (logical, _, copied) = copy_snapshot_file( + &root, + &original_manifest_path, + artifact, + "artifact", + &mut copied_files, + &mut budget, + )?; + identity_files.insert(logical, copied.clone()); + source.artifact = Some(copied.to_string_lossy().into_owned()); + } + if let Some(integrity) = manifest.integrity.as_mut() + && let Some(signature) = integrity.signature.as_deref() + { + let (logical, _, copied) = copy_snapshot_file( + &root, + &original_manifest_path, + signature, + "signature", + &mut copied_files, + &mut budget, + )?; + identity_files.insert(logical, copied.clone()); + integrity.signature = Some(copied.to_string_lossy().into_owned()); + } + + let activation_environment_ref = if matches!( + &manifest.load, + DynamicPluginManifestLoad::Worker(load) + if load.runtime == Some(WorkerRuntime::Python) + ) { + let environment = environment_ref.ok_or_else(|| { + CliError::Config(format!( + "Python worker dynamic plugin '{expected_plugin_id}' has no managed environment" + )) + })?; + let source_artifact_sha256 = trusted_source_artifact_sha256(&manifest)?; + let environment = PathBuf::from(environment); + verify_environment_attestation(&environment, source_artifact_sha256) + .map_err(CliError::Config)?; + let environment_name = environment.file_name().ok_or_else(|| { + CliError::Config(format!( + "managed Python environment {} has no lifecycle environment name", + environment.display() + )) + })?; + let copied_environment = root.join(MANAGED_ENVIRONMENTS_DIR).join(environment_name); + copy_snapshot_directory( + &environment, + &copied_environment, + &mut copied_files, + &mut budget, + true, + &mut Vec::new(), + )?; + verify_environment_attestation(&copied_environment, source_artifact_sha256) + .map_err(CliError::Config)?; + Some(copied_environment.to_string_lossy().into_owned()) + } else { + None + }; + + let activation_manifest = runtime_root.join("relay-plugin.toml"); + let rendered = toml::to_string(&manifest).map_err(|error| { + CliError::Config(format!( + "failed to encode dynamic plugin activation snapshot for '{expected_plugin_id}': {error}" + )) + })?; + if rendered.len() as u64 > MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { + return Err(CliError::Config(format!( + "dynamic plugin activation manifest for '{expected_plugin_id}' exceeds the {MAX_BOOTSTRAP_IDENTITY_FILE_BYTES}-byte activation snapshot budget" + ))); + } + fs::write(&activation_manifest, rendered).map_err(|error| { + CliError::Config(format!( + "failed to write dynamic plugin activation manifest {}: {error}", + activation_manifest.display() + )) + })?; + + let trust = evaluate_dynamic_plugin_trust( + &manifest, + activation_manifest.to_string_lossy().as_ref(), + &policy, + ); + if !policy.policy_satisfied { + return Err(CliError::Config(format!( + "dynamic plugin '{expected_plugin_id}' activation snapshot violates host policy" + ))); + } + if let Some(failure) = trust.failure() { + return Err(CliError::Config( + failure.display(expected_plugin_id).to_string(), + )); + } + + let closure_digest = snapshot_tree_digest(&root, true)?; + let verification_digest = snapshot_tree_digest(&root, false)?; + #[cfg(unix)] + protect_snapshot_tree(&root)?; + #[cfg(windows)] + protect_snapshot_tree(&root)?; + root_guard.0 = None; + Ok(Arc::new(Self { + root, + original_manifest_ref, + identity_manifest, + activation_manifest, + activation_environment_ref, + identity_files, + closure_digest, + verification_digest, + })) + } + + pub(crate) fn activation_manifest_ref(&self) -> String { + self.activation_manifest.to_string_lossy().into_owned() + } + + pub(crate) fn activation_environment_ref(&self) -> Option<&str> { + self.activation_environment_ref.as_deref() + } + + pub(crate) fn closure_digest(&self) -> &str { + &self.closure_digest + } + + pub(crate) fn verify_current(&self) -> Result<(), CliError> { + let actual = snapshot_tree_digest(&self.root, false)?; + if actual == self.verification_digest { + Ok(()) + } else { + Err(CliError::Config(format!( + "dynamic plugin activation snapshot {} changed before code load", + self.root.display() + ))) + } + } + + pub(crate) fn original_manifest_ref(&self) -> &str { + &self.original_manifest_ref + } + + pub(crate) fn identity_manifest(&self) -> &Path { + &self.identity_manifest + } + + pub(crate) fn identity_file(&self, logical_path: &Path) -> Option<&Path> { + self.identity_files.get(logical_path).map(PathBuf::as_path) + } +} + +struct SnapshotRootGuard(Option); + +impl Drop for SnapshotRootGuard { + fn drop(&mut self) { + if let Some(root) = self.0.take() { + make_snapshot_removable(&root); + let _ = fs::remove_dir_all(root); + } + } +} + +impl Drop for DynamicPluginActivationSnapshot { + fn drop(&mut self) { + make_snapshot_removable(&self.root); + let _ = fs::remove_dir_all(&self.root); + } +} + +fn copy_snapshot_file( + root: &Path, + manifest_path: &Path, + reference: &str, + label: &str, + copied_files: &mut HashMap, + budget: &mut SnapshotBudget, +) -> Result<(PathBuf, PathBuf, PathBuf), CliError> { + let logical = resolve_manifest_relative_path(manifest_path, reference); + let canonical = fs::canonicalize(&logical).map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin {label} {}: {error}", + logical.display() + )) + })?; + if let Some(copied) = copied_files.get(&canonical) + && !matches!(label, "library" | "entrypoint") + { + return Ok((logical, canonical, copied.clone())); + } + if matches!(label, "library" | "entrypoint") { + let manifest_directory = manifest_path + .parent() + .and_then(|parent| fs::canonicalize(parent).ok()); + if manifest_directory + .as_ref() + .is_some_and(|directory| canonical.starts_with(directory)) + && let Some(copied) = copied_files.get(&canonical) + { + // The manifest directory is copied as a complete closure before declared paths are + // rewritten, so in-tree load targets already retain adjacent resources. + return Ok((logical, canonical, copied.clone())); + } + } + let external = root.join(format!("external-{label}")); + if matches!(label, "library" | "entrypoint") { + let parent = canonical.parent().ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin {label} {} has no parent directory", + canonical.display() + )) + })?; + copy_snapshot_directory( + parent, + &external, + copied_files, + budget, + false, + &mut Vec::new(), + )?; + } else { + fs::create_dir_all(&external).map_err(|error| CliError::Config(error.to_string()))?; + let destination = external.join(canonical.file_name().unwrap_or_default()); + copy_snapshot_regular_file(&canonical, &destination, copied_files, budget, label)?; + } + let copied = copied_files.get(&canonical).cloned().ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin {label} {} was not included in its activation snapshot", + canonical.display() + )) + })?; + Ok((logical, canonical, copied)) +} + +const MAX_SNAPSHOT_FILES: usize = 100_000; +const MAX_SNAPSHOT_DEPTH: usize = 128; + +#[derive(Default)] +struct SnapshotBudget { + entries: usize, + bytes: u64, +} + +impl SnapshotBudget { + fn record(&mut self, path: &Path, bytes: usize) -> Result<(), CliError> { + self.record_entries(path, 1)?; + self.record_bytes(path, bytes) + } + + fn record_entries(&mut self, path: &Path, count: usize) -> Result<(), CliError> { + self.entries = self.entries.saturating_add(count); + if self.entries > MAX_SNAPSHOT_FILES { + return Err(CliError::Config(format!( + "dynamic plugin runtime closure exceeds the {MAX_SNAPSHOT_FILES}-entry activation snapshot budget at {}", + path.display() + ))); + } + Ok(()) + } + + fn record_bytes(&mut self, path: &Path, bytes: usize) -> Result<(), CliError> { + self.bytes = self.bytes.saturating_add(bytes as u64); + if self.bytes > MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { + return Err(CliError::Config(format!( + "dynamic plugin runtime closure exceeds the {MAX_BOOTSTRAP_IDENTITY_FILE_BYTES}-byte activation snapshot budget at {}", + path.display() + ))); + } + Ok(()) + } + + fn record_directory(&mut self, path: &Path) -> Result<(), CliError> { + self.record_entries(path, 1) + } +} + +fn copy_snapshot_directory( + source: &Path, + destination: &Path, + copied_files: &mut HashMap, + budget: &mut SnapshotBudget, + skip_python_cache: bool, + ancestors: &mut Vec, +) -> Result<(), CliError> { + budget.record_directory(source)?; + copy_snapshot_directory_contents( + source, + destination, + copied_files, + budget, + skip_python_cache, + ancestors, + ) +} + +fn copy_snapshot_directory_contents( + source: &Path, + destination: &Path, + copied_files: &mut HashMap, + budget: &mut SnapshotBudget, + skip_python_cache: bool, + ancestors: &mut Vec, +) -> Result<(), CliError> { + if ancestors.len() >= MAX_SNAPSHOT_DEPTH { + return Err(CliError::Config(format!( + "dynamic plugin runtime closure exceeds the {MAX_SNAPSHOT_DEPTH}-directory traversal depth at {}", + source.display() + ))); + } + let canonical = fs::canonicalize(source).map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin runtime directory {}: {error}", + source.display() + )) + })?; + if ancestors.contains(&canonical) { + return Err(CliError::Config(format!( + "dynamic plugin runtime closure contains a directory symlink cycle at {}", + source.display() + ))); + } + ancestors.push(canonical.clone()); + fs::create_dir_all(destination).map_err(|error| { + CliError::Config(format!( + "failed to create dynamic plugin snapshot directory {}: {error}", + destination.display() + )) + })?; + let mut entries = bounded_runtime_directory_entries( + &canonical, + MAX_SNAPSHOT_FILES.saturating_sub(budget.entries), + )?; + budget.record_entries(source, entries.len())?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let source_path = entry.path(); + if skip_python_cache + && (entry.file_name() == "__pycache__" + || source_path.extension().and_then(|value| value.to_str()) == Some("pyc")) + { + continue; + } + let destination_path = destination.join(entry.file_name()); + let metadata = fs::symlink_metadata(&source_path) + .map_err(|error| CliError::Config(error.to_string()))?; + let resolved = if metadata.file_type().is_symlink() { + fs::canonicalize(&source_path).map_err(|error| { + CliError::Config(format!( + "failed to resolve dynamic plugin runtime symlink {}: {error}", + source_path.display() + )) + })? + } else { + source_path.clone() + }; + let resolved_metadata = + fs::metadata(&resolved).map_err(|error| CliError::Config(error.to_string()))?; + if resolved_metadata.is_dir() { + copy_snapshot_directory_contents( + &resolved, + &destination_path, + copied_files, + budget, + skip_python_cache, + ancestors, + )?; + } else if resolved_metadata.is_file() { + // A macOS venv's `bin/python` is normally an absolute symlink to the managed + // interpreter. Dereferencing that link while snapshotting turns the interpreter + // into a standalone file whose @rpath no longer points at libpython, so the worker + // exits before it can create its socket. Preserve only these launcher links; all + // other runtime symlinks remain dereferenced to keep the activation snapshot + // self-contained and deterministic. + #[cfg(unix)] + if metadata.file_type().is_symlink() && is_python_venv_launcher(&source_path) { + let target = fs::read_link(&source_path).map_err(|error| { + CliError::Config(format!( + "failed to read Python venv launcher symlink {}: {error}", + source_path.display() + )) + })?; + if let Some(parent) = destination_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| CliError::Config(error.to_string()))?; + } + std::os::unix::fs::symlink(&target, &destination_path).map_err(|error| { + CliError::Config(format!( + "failed to preserve Python venv launcher symlink {}: {error}", + destination_path.display() + )) + })?; + copied_files.insert(resolved, destination_path); + continue; + } + copy_snapshot_regular_file( + &resolved, + &destination_path, + copied_files, + budget, + "runtime file", + )?; + } else { + return Err(CliError::Config(format!( + "dynamic plugin runtime entry {} must resolve to a regular file or directory", + source_path.display() + ))); + } + } + ancestors.pop(); + Ok(()) +} + +#[cfg(unix)] +fn is_python_venv_launcher(path: &Path) -> bool { + let Some(parent) = path.parent() else { + return false; + }; + parent.file_name() == Some(std::ffi::OsStr::new("bin")) + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "python" || name.starts_with("python3")) +} + +fn copy_snapshot_regular_file( + source: &Path, + destination: &Path, + copied_files: &mut HashMap, + budget: &mut SnapshotBudget, + description: &str, +) -> Result<(), CliError> { + let bytes = read_bounded_regular_file(source, &format!("dynamic plugin {description}")) + .map_err(CliError::Config)?; + budget.record_bytes(source, bytes.len())?; + fs::write(destination, bytes).map_err(|error| { + CliError::Config(format!( + "failed to write dynamic plugin snapshot file {}: {error}", + destination.display() + )) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(source) + .map_err(|error| CliError::Config(error.to_string()))? + .permissions() + .mode(); + fs::set_permissions(destination, fs::Permissions::from_mode(mode)) + .map_err(|error| CliError::Config(error.to_string()))?; + } + copied_files.insert(source.to_path_buf(), destination.to_path_buf()); + Ok(()) +} + +fn resolve_manifest_relative_path(manifest_path: &Path, reference: &str) -> PathBuf { + let path = PathBuf::from(reference); + if path.is_absolute() { + path + } else { + manifest_path + .parent() + .map(|parent| parent.join(&path)) + .unwrap_or(path) + } +} + +#[cfg(unix)] +fn protect_snapshot_tree(root: &Path) -> Result<(), CliError> { + use std::os::unix::fs::PermissionsExt; + for entry in fs::read_dir(root).map_err(|error| CliError::Config(error.to_string()))? { + let path = entry + .map_err(|error| CliError::Config(error.to_string()))? + .path(); + let metadata = + fs::symlink_metadata(&path).map_err(|error| CliError::Config(error.to_string()))?; + if metadata.is_dir() { + protect_snapshot_tree(&path)?; + continue; + } + if metadata.file_type().is_symlink() { + continue; + } + let mode = metadata.permissions().mode() & !0o222; + fs::set_permissions(&path, fs::Permissions::from_mode(mode)) + .map_err(|error| CliError::Config(error.to_string()))?; + } + fs::set_permissions(root, fs::Permissions::from_mode(0o500)) + .map_err(|error| CliError::Config(error.to_string())) +} + +#[cfg(windows)] +fn protect_snapshot_tree(root: &Path) -> Result<(), CliError> { + for entry in fs::read_dir(root).map_err(|error| CliError::Config(error.to_string()))? { + let path = entry + .map_err(|error| CliError::Config(error.to_string()))? + .path(); + let metadata = + fs::symlink_metadata(&path).map_err(|error| CliError::Config(error.to_string()))?; + if metadata.is_dir() { + protect_snapshot_tree(&path)?; + } else if !metadata.file_type().is_symlink() { + let mut permissions = metadata.permissions(); + permissions.set_readonly(true); + fs::set_permissions(&path, permissions) + .map_err(|error| CliError::Config(error.to_string()))?; + } + } + Ok(()) +} + +fn snapshot_tree_digest(root: &Path, stable_identity: bool) -> Result { + let mut files = Vec::new(); + let mut entries = 0_usize; + collect_snapshot_files(root, root, &mut files, None, &mut entries)?; + files.sort(); + let mut digest = Sha256::new(); + let mut budget = SnapshotBudget::default(); + for relative in files { + if stable_identity { + let activation_manifest = Path::new("runtime").join("relay-plugin.toml"); + let is_python_environment_content = relative.starts_with(MANAGED_ENVIRONMENTS_DIR) + && relative.file_name() != Some(std::ffi::OsStr::new(ENVIRONMENT_ATTESTATION_FILE)); + if relative == activation_manifest || is_python_environment_content { + continue; + } + } + let path = root.join(&relative); + let metadata = fs::symlink_metadata(&path).map_err(|error| { + CliError::Config(format!( + "failed to inspect dynamic plugin activation snapshot entry {}: {error}", + path.display() + )) + })?; + if metadata.file_type().is_symlink() { + let target = fs::read_link(&path).map_err(|error| { + CliError::Config(format!( + "failed to read dynamic plugin activation snapshot symlink {}: {error}", + path.display() + )) + })?; + let target = target.as_os_str().as_encoded_bytes(); + budget.record(&path, target.len())?; + update_snapshot_entry_digest( + &mut digest, + &relative, + SnapshotEntryKind::Symlink, + target, + ); + } else { + let bytes = read_bounded_regular_file(&path, "dynamic plugin activation snapshot file") + .map_err(CliError::Config)?; + budget.record(&path, bytes.len())?; + update_snapshot_entry_digest(&mut digest, &relative, SnapshotEntryKind::File, &bytes); + } + } + Ok(digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +pub(crate) fn dynamic_plugin_runtime_closure_digest( + manifest_ref: &str, + environment_ref: Option<&str>, +) -> Result { + let (manifest, normalized_manifest_ref, manifest_bytes) = + load_bounded_dynamic_plugin_manifest_bytes(manifest_ref)?; + let manifest_path = PathBuf::from(normalized_manifest_ref); + let manifest_directory = manifest_path.parent().ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin manifest {} has no parent directory", + manifest_path.display() + )) + })?; + let mut closure = RuntimeClosureSources::default(); + closure.record_bytes(PathBuf::from("identity-manifest.toml"), manifest_bytes)?; + collect_runtime_closure_directory( + manifest_directory, + Path::new("runtime"), + false, + &mut Vec::new(), + &mut closure, + )?; + + let declared_artifact = manifest + .source + .as_ref() + .and_then(|source| source.artifact.as_deref()) + .map(|artifact| fs::canonicalize(resolve_manifest_relative_path(&manifest_path, artifact))) + .transpose() + .map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin artifact for '{}': {error}", + manifest.plugin.id + )) + })?; + match &manifest.load { + DynamicPluginManifestLoad::RustDynamic(load) => { + if let Some(library) = load.library.as_deref() { + collect_declared_runtime_closure_file( + &manifest_path, + library, + "library", + &mut closure, + )?; + } + } + DynamicPluginManifestLoad::Worker(load) + if matches!( + load.runtime, + Some(WorkerRuntime::Rust | WorkerRuntime::Command) + ) => + { + if let Some(entrypoint) = load.entrypoint.as_deref() { + let canonical_entrypoint = + fs::canonicalize(resolve_manifest_relative_path(&manifest_path, entrypoint)) + .map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin entrypoint for '{}': {error}", + manifest.plugin.id + )) + })?; + if declared_artifact.as_ref() != Some(&canonical_entrypoint) { + return Err(CliError::Config(format!( + "command worker dynamic plugin '{}' must declare its load.entrypoint as the integrity-checked source.artifact", + manifest.plugin.id + ))); + } + collect_declared_runtime_closure_file( + &manifest_path, + entrypoint, + "entrypoint", + &mut closure, + )?; + } + } + DynamicPluginManifestLoad::Worker(load) if load.runtime == Some(WorkerRuntime::Python) => { + let environment_ref = environment_ref.ok_or_else(|| { + CliError::Config(format!( + "Python worker dynamic plugin '{}' has no managed environment", + manifest.plugin.id + )) + })?; + let environment = Path::new(environment_ref); + let source_artifact_sha256 = trusted_source_artifact_sha256(&manifest)?; + read_environment_attestation(environment, source_artifact_sha256) + .map_err(CliError::Config)?; + let environment_name = environment.file_name().ok_or_else(|| { + CliError::Config(format!( + "managed Python environment {} has no lifecycle environment name", + environment.display() + )) + })?; + closure.record_file( + Path::new(MANAGED_ENVIRONMENTS_DIR) + .join(environment_name) + .join(ENVIRONMENT_ATTESTATION_FILE), + environment.join(ENVIRONMENT_ATTESTATION_FILE), + )?; + } + DynamicPluginManifestLoad::Worker(_) => {} + } + if let Some(artifact) = manifest + .source + .as_ref() + .and_then(|source| source.artifact.as_deref()) + { + collect_declared_runtime_closure_file(&manifest_path, artifact, "artifact", &mut closure)?; + } + if let Some(signature) = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.signature.as_deref()) + { + collect_declared_runtime_closure_file( + &manifest_path, + signature, + "signature", + &mut closure, + )?; + } + + closure.digest() +} + +fn trusted_source_artifact_sha256(manifest: &DynamicPluginManifest) -> Result<&str, CliError> { + manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.sha256.as_deref()) + .map(str::trim) + .filter(|digest| !digest.is_empty()) + .ok_or_else(|| { + CliError::Config(format!( + "Python worker dynamic plugin '{}' requires integrity.sha256 to bind its complete installed environment to the trusted source artifact", + manifest.plugin.id + )) + }) +} + +enum RuntimeClosureSource { + File(PathBuf), + Bytes(Vec), +} + +#[derive(Default)] +struct RuntimeClosureSources { + files: BTreeMap, + copied_files: HashMap, + entries: usize, +} + +impl RuntimeClosureSources { + fn record_file(&mut self, relative: PathBuf, source: PathBuf) -> Result<(), CliError> { + self.entries = self.entries.saturating_add(1); + self.record_reserved_file(relative, source); + self.enforce_file_budget() + } + + fn record_reserved_file(&mut self, relative: PathBuf, source: PathBuf) { + self.files + .insert(relative.clone(), RuntimeClosureSource::File(source.clone())); + self.copied_files.insert(source, relative); + } + + fn record_bytes(&mut self, relative: PathBuf, bytes: Vec) -> Result<(), CliError> { + self.entries = self.entries.saturating_add(1); + self.files + .insert(relative, RuntimeClosureSource::Bytes(bytes)); + self.enforce_file_budget() + } + + fn enforce_file_budget(&self) -> Result<(), CliError> { + if self.entries > MAX_SNAPSHOT_FILES { + Err(CliError::Config(format!( + "dynamic plugin runtime closure exceeds the {MAX_SNAPSHOT_FILES}-entry activation snapshot budget" + ))) + } else { + Ok(()) + } + } + + fn record_entry(&mut self) -> Result<(), CliError> { + self.entries = self.entries.saturating_add(1); + self.enforce_file_budget() + } + + fn record_entries(&mut self, count: usize) -> Result<(), CliError> { + self.entries = self.entries.saturating_add(count); + self.enforce_file_budget() + } + + fn digest(self) -> Result { + let activation_manifest = Path::new("runtime").join("relay-plugin.toml"); + let mut digest = Sha256::new(); + let mut budget = SnapshotBudget::default(); + for (relative, source) in self.files { + if relative == activation_manifest { + continue; + } + let bytes = match source { + RuntimeClosureSource::File(path) => { + read_bounded_regular_file(&path, "dynamic plugin runtime closure file") + .map_err(CliError::Config)? + } + RuntimeClosureSource::Bytes(bytes) => bytes, + }; + budget.record(&relative, bytes.len())?; + update_snapshot_entry_digest(&mut digest, &relative, SnapshotEntryKind::File, &bytes); + } + Ok(digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) + } +} + +#[derive(Clone, Copy)] +enum SnapshotEntryKind { + File = 0, + Symlink = 1, +} + +fn update_snapshot_entry_digest( + digest: &mut Sha256, + relative: &Path, + kind: SnapshotEntryKind, + payload: &[u8], +) { + let relative = relative.as_os_str().as_encoded_bytes(); + digest.update([kind as u8]); + digest.update((relative.len() as u64).to_le_bytes()); + digest.update(relative); + digest.update((payload.len() as u64).to_le_bytes()); + digest.update(payload); +} + +fn collect_declared_runtime_closure_file( + manifest_path: &Path, + reference: &str, + label: &str, + closure: &mut RuntimeClosureSources, +) -> Result<(), CliError> { + let logical = resolve_manifest_relative_path(manifest_path, reference); + let canonical = fs::canonicalize(&logical).map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin {label} {}: {error}", + logical.display() + )) + })?; + if closure.copied_files.contains_key(&canonical) && !matches!(label, "library" | "entrypoint") { + return Ok(()); + } + if matches!(label, "library" | "entrypoint") { + let manifest_directory = manifest_path + .parent() + .and_then(|parent| fs::canonicalize(parent).ok()); + if manifest_directory + .as_ref() + .is_some_and(|directory| canonical.starts_with(directory)) + && closure.copied_files.contains_key(&canonical) + { + return Ok(()); + } + let parent = canonical.parent().ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin {label} {} has no parent directory", + canonical.display() + )) + })?; + return collect_runtime_closure_directory( + parent, + Path::new(&format!("external-{label}")), + false, + &mut Vec::new(), + closure, + ); + } + let file_name = canonical.file_name().ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin {label} {} has no file name", + canonical.display() + )) + })?; + closure.record_file( + Path::new(&format!("external-{label}")).join(file_name), + canonical, + ) +} + +fn collect_runtime_closure_directory( + source: &Path, + destination: &Path, + skip_python_cache: bool, + ancestors: &mut Vec, + closure: &mut RuntimeClosureSources, +) -> Result<(), CliError> { + closure.record_entry()?; + collect_runtime_closure_directory_contents( + source, + destination, + skip_python_cache, + ancestors, + closure, + ) +} + +fn collect_runtime_closure_directory_contents( + source: &Path, + destination: &Path, + skip_python_cache: bool, + ancestors: &mut Vec, + closure: &mut RuntimeClosureSources, +) -> Result<(), CliError> { + if ancestors.len() >= MAX_SNAPSHOT_DEPTH { + return Err(CliError::Config(format!( + "dynamic plugin runtime closure exceeds the {MAX_SNAPSHOT_DEPTH}-directory traversal depth at {}", + source.display() + ))); + } + let canonical = fs::canonicalize(source).map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin runtime directory {}: {error}", + source.display() + )) + })?; + if ancestors.contains(&canonical) { + return Err(CliError::Config(format!( + "dynamic plugin runtime closure contains a directory symlink cycle at {}", + source.display() + ))); + } + ancestors.push(canonical.clone()); + let mut entries = bounded_runtime_directory_entries( + &canonical, + MAX_SNAPSHOT_FILES.saturating_sub(closure.entries), + )?; + closure.record_entries(entries.len())?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let source_path = entry.path(); + if skip_python_cache + && (entry.file_name() == "__pycache__" + || source_path.extension().and_then(|value| value.to_str()) == Some("pyc")) + { + continue; + } + let metadata = fs::symlink_metadata(&source_path) + .map_err(|error| CliError::Config(error.to_string()))?; + let resolved = if metadata.file_type().is_symlink() { + fs::canonicalize(&source_path).map_err(|error| { + CliError::Config(format!( + "failed to resolve dynamic plugin runtime symlink {}: {error}", + source_path.display() + )) + })? + } else { + source_path.clone() + }; + let resolved_metadata = + fs::metadata(&resolved).map_err(|error| CliError::Config(error.to_string()))?; + let relative = destination.join(entry.file_name()); + if resolved_metadata.is_dir() { + collect_runtime_closure_directory_contents( + &resolved, + &relative, + skip_python_cache, + ancestors, + closure, + )?; + } else if resolved_metadata.is_file() { + closure.record_reserved_file(relative, resolved); + } else { + return Err(CliError::Config(format!( + "dynamic plugin runtime entry {} must resolve to a regular file or directory", + source_path.display() + ))); + } + } + ancestors.pop(); + Ok(()) +} + +fn bounded_runtime_directory_entries( + directory: &Path, + remaining_entries: usize, +) -> Result, CliError> { + let mut entries = Vec::new(); + for entry in fs::read_dir(directory).map_err(|error| CliError::Config(error.to_string()))? { + if entries.len() >= remaining_entries { + return Err(CliError::Config(format!( + "dynamic plugin runtime closure exceeds the {MAX_SNAPSHOT_FILES}-entry activation snapshot budget at {}", + directory.display() + ))); + } + entries.push(entry.map_err(|error| CliError::Config(error.to_string()))?); + } + Ok(entries) +} + +fn collect_snapshot_files( + root: &Path, + directory: &Path, + files: &mut Vec, + logical_depth: Option, + entries: &mut usize, +) -> Result<(), CliError> { + if let Some(depth) = logical_depth + && depth >= MAX_SNAPSHOT_DEPTH + { + return Err(CliError::Config(format!( + "dynamic plugin activation snapshot exceeds the {MAX_SNAPSHOT_DEPTH}-directory traversal depth at {}", + directory.display() + ))); + } + let resets_child_depth = + directory == root || directory == root.join(environment::MANAGED_ENVIRONMENTS_DIR); + for entry in fs::read_dir(directory).map_err(|error| CliError::Config(error.to_string()))? { + *entries = entries.saturating_add(1); + if *entries > MAX_SNAPSHOT_FILES { + return Err(CliError::Config(format!( + "dynamic plugin activation snapshot exceeds the {MAX_SNAPSHOT_FILES}-entry verification budget at {}", + directory.display() + ))); + } + let path = entry + .map_err(|error| CliError::Config(error.to_string()))? + .path(); + let metadata = + fs::symlink_metadata(&path).map_err(|error| CliError::Config(error.to_string()))?; + if metadata.is_dir() { + let child_depth = if resets_child_depth { + 0 + } else { + logical_depth.unwrap_or(0).saturating_add(1) + }; + collect_snapshot_files(root, &path, files, Some(child_depth), entries)?; + } else { + files.push( + path.strip_prefix(root) + .map_err(|error| CliError::Config(error.to_string()))? + .to_path_buf(), + ); + } + } + Ok(()) +} + +fn make_snapshot_removable(root: &Path) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(root, fs::Permissions::from_mode(0o700)); + } + for entry in entries.flatten() { + let path = entry.path(); + let Ok(metadata) = fs::symlink_metadata(&path) else { + continue; + }; + if metadata.is_dir() { + make_snapshot_removable(&path); + } else { + #[cfg(windows)] + if !metadata.file_type().is_symlink() { + let mut permissions = metadata.permissions(); + permissions.set_readonly(false); + let _ = fs::set_permissions(&path, permissions); + } + } + } } pub(crate) fn active_dynamic_plugin_components( explicit: Option<&PathBuf>, resolved: &ResolvedConfig, +) -> Result, CliError> { + active_dynamic_plugin_components_inner(explicit, resolved, true) +} + +pub(crate) fn active_dynamic_plugin_components_for_identity( + explicit: Option<&PathBuf>, + resolved: &ResolvedConfig, +) -> Result, CliError> { + let scopes = load_scoped_registries(explicit)?; + active_dynamic_plugin_components_from_scopes(&scopes, resolved, false) +} + +fn active_dynamic_plugin_components_inner( + explicit: Option<&PathBuf>, + resolved: &ResolvedConfig, + create_activation_snapshots: bool, ) -> Result, CliError> { let scopes = load_and_hydrate_scopes(explicit, resolved)?; + active_dynamic_plugin_components_from_scopes(&scopes, resolved, create_activation_snapshots) +} + +fn active_dynamic_plugin_components_from_scopes( + scopes: &[ScopedRegistry], + resolved: &ResolvedConfig, + create_activation_snapshots: bool, +) -> Result, CliError> { let host_config_by_id = host_config_by_id(resolved); let mut components = Vec::new(); for resolved_plugin in &resolved.dynamic_plugins { - let Some(entry) = find_record_by_id(&scopes, &resolved_plugin.plugin_id)? else { + let Some(record) = scopes + .iter() + .find(|scope| scope.plugins_toml_path == resolved_plugin.source) + .and_then(|scope| scope.registry.get(&resolved_plugin.plugin_id)) + else { return Err(CliError::Config(format!( "dynamic plugin '{}' is present in resolved config but not lifecycle state", resolved_plugin.plugin_id ))); }; - if entry.record.is_tombstoned() || !entry.record.spec.enabled { + if record.is_tombstoned() || !record.spec.enabled { continue; } - let host_config = host_config_by_id - .get(&entry.record.metadata.id) - .ok_or_else(|| { - CliError::Config(format!( - "dynamic plugin '{}' is enabled but has no resolved host config", - entry.record.metadata.id - )) - })?; + let host_config = host_config_by_id.get(&record.metadata.id).ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin '{}' is enabled but has no resolved host config", + record.metadata.id + )) + })?; + let manifest_ref = match record.metadata.kind { + DynamicPluginKind::RustDynamic => Some(manifest_ref_from_record(record)?), + DynamicPluginKind::Worker => record.source.manifest_ref.clone(), + }; + let activation_snapshot = if create_activation_snapshots { + manifest_ref + .as_deref() + .map(|manifest_ref| { + DynamicPluginActivationSnapshot::create( + manifest_ref, + &record.metadata.id, + record.metadata.kind, + record.source.environment_ref.as_deref(), + &resolved.dynamic_plugin_policy, + ) + }) + .transpose()? + } else { + None + }; components.push(ActiveDynamicPluginComponent { - plugin_id: entry.record.metadata.id.clone(), - kind: entry.record.metadata.kind, - manifest_ref: match entry.record.metadata.kind { - DynamicPluginKind::RustDynamic => Some(manifest_ref_from_record(&entry.record)?), - DynamicPluginKind::Worker => entry.record.source.manifest_ref.clone(), - }, - environment_ref: entry.record.source.environment_ref.clone(), + plugin_id: record.metadata.id.clone(), + kind: record.metadata.kind, + lifecycle_generation: record.metadata.generation, + manifest_ref, + environment_ref: record.source.environment_ref.clone(), config: host_config.config.clone(), + activation_snapshot, }); } @@ -814,7 +2067,7 @@ fn load_manifest_for_action( path: impl Into, ) -> Result<(DynamicPluginManifest, String), CliError> { let path = path.into(); - DynamicPluginManifest::load_from_path(&path) + crate::config::load_bounded_dynamic_plugin_manifest(&path) .map_err(|error| CliError::Config(format!("dynamic plugin {action} failed: {error}"))) } @@ -958,7 +2211,7 @@ fn required_startup_failure( )); } - if let Err(error) = DynamicPluginManifest::load_from_path(&manifest_ref) { + if let Err(error) = crate::config::load_bounded_dynamic_plugin_manifest(&manifest_ref) { return Some(format!( "- {}: required dynamic plugin manifest at {} is unreadable: {}", entry.record.metadata.id, diff --git a/crates/cli/src/plugins/lifecycle/environment.rs b/crates/cli/src/plugins/lifecycle/environment.rs index c66eacf0f..10fee5167 100644 --- a/crates/cli/src/plugins/lifecycle/environment.rs +++ b/crates/cli/src/plugins/lifecycle/environment.rs @@ -4,13 +4,29 @@ use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::process::Command; +#[cfg(test)] +use std::sync::atomic::{AtomicUsize, Ordering}; use nemo_relay::plugin::dynamic::{ DynamicPluginCheckState, DynamicPluginManifest, DynamicPluginManifestLoad, WorkerRuntime, }; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -const MANAGED_ENVIRONMENTS_DIR: &str = ".dynamic-plugin-environments"; +pub(super) const MANAGED_ENVIRONMENTS_DIR: &str = ".dynamic-plugin-environments"; +pub(super) const ENVIRONMENT_ATTESTATION_FILE: &str = ".nemo-relay-environment.sha256"; +const MAX_ENVIRONMENT_FILES: usize = 100_000; +pub(super) const MAX_ENVIRONMENT_DEPTH: usize = 128; +#[cfg(test)] +static ENVIRONMENT_TREE_DIGEST_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[derive(Deserialize, Serialize)] +struct EnvironmentAttestation { + version: u8, + source_artifact_sha256: String, + environment_sha256: String, + authentication: String, +} pub(super) trait PythonEnvironmentCommandRunner { fn run(&self, program: &OsStr, args: &[OsString]) -> Result<(), String>; @@ -42,6 +58,105 @@ pub(super) fn is_python_worker(manifest: &DynamicPluginManifest) -> bool { ) } +pub(super) fn validate_python_entrypoint_artifact( + manifest: &DynamicPluginManifest, + manifest_ref: &str, +) -> Result<(), String> { + let DynamicPluginManifestLoad::Worker(load) = &manifest.load else { + return Ok(()); + }; + if load.runtime != Some(WorkerRuntime::Python) { + return Ok(()); + } + + let source = manifest.source.as_ref().ok_or_else(|| { + "Python worker plugins must declare source.manifest_root and source.artifact".to_string() + })?; + let manifest_root = source + .manifest_root + .as_deref() + .map(str::trim) + .filter(|root| !root.is_empty()) + .ok_or_else(|| { + "Python worker plugins added through the CLI must declare source.manifest_root" + .to_string() + })?; + let artifact = source + .artifact + .as_deref() + .map(str::trim) + .filter(|artifact| !artifact.is_empty()) + .ok_or_else(|| "Python worker plugins must declare source.artifact".to_string())?; + let entrypoint = load + .entrypoint + .as_deref() + .map(str::trim) + .filter(|entrypoint| !entrypoint.is_empty()) + .ok_or_else(|| "Python worker plugins must declare load.entrypoint".to_string())?; + let (module, callable) = entrypoint.split_once(':').ok_or_else(|| { + format!( + "Python worker load.entrypoint '{entrypoint}' must use the unambiguous module:function form" + ) + })?; + if callable.is_empty() + || module.is_empty() + || module + .split('.') + .any(|segment| segment.is_empty() || segment.contains(['/', '\\', ':'])) + { + return Err(format!( + "Python worker load.entrypoint '{entrypoint}' must use the unambiguous module:function form" + )); + } + + let manifest_path = Path::new(manifest_ref); + let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new(".")); + let unresolved_manifest_root = resolve_relative_path(manifest_dir, manifest_root); + let manifest_root = unresolved_manifest_root.canonicalize().map_err(|error| { + format!( + "could not resolve Python plugin source.manifest_root {}: {error}", + unresolved_manifest_root.display() + ) + })?; + let artifact = resolve_relative_path(manifest_dir, artifact) + .canonicalize() + .map_err(|error| format!("could not resolve Python source.artifact: {error}"))?; + let module_path = module + .split('.') + .fold(manifest_root.clone(), |path, segment| path.join(segment)); + let module_file = module_path.with_extension("py"); + let package_file = module_path.join("__init__.py"); + let mut candidates = [module_file, package_file] + .into_iter() + .filter(|path| path.is_file()) + .map(|path| { + path.canonicalize().map_err(|error| { + format!( + "could not resolve Python entrypoint module file {}: {error}", + path.display() + ) + }) + }) + .collect::, _>>()?; + candidates.sort(); + candidates.dedup(); + let [entrypoint_artifact] = candidates.as_slice() else { + return Err(format!( + "Python worker load.entrypoint '{entrypoint}' must resolve to exactly one source module under source.manifest_root; expected {} or {}", + module_path.with_extension("py").display(), + module_path.join("__init__.py").display() + )); + }; + if entrypoint_artifact != &artifact { + return Err(format!( + "Python worker load.entrypoint '{entrypoint}' resolves to {}, but integrity-checked source.artifact resolves to {}; the executed entrypoint module must be the integrity-checked artifact", + entrypoint_artifact.display(), + artifact.display() + )); + } + Ok(()) +} + pub(super) fn provision_python_environment( manifest: &DynamicPluginManifest, manifest_ref: &str, @@ -51,6 +166,7 @@ pub(super) fn provision_python_environment( if !is_python_worker(manifest) { return Ok(None); } + validate_python_entrypoint_artifact(manifest, manifest_ref)?; let manifest_root = manifest .source @@ -132,9 +248,239 @@ pub(super) fn provision_python_environment( )); } + let source_artifact_sha256 = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.sha256.as_deref()) + .map(str::trim) + .filter(|digest| !digest.is_empty()) + .ok_or_else(|| { + "Python worker plugins require integrity.sha256 to bind the installed environment to the trusted source artifact" + .to_string() + })?; + write_environment_attestation(&environment, source_artifact_sha256)?; + Ok(Some(environment)) } +pub(super) fn read_environment_attestation( + environment: &Path, + expected_source_artifact_sha256: &str, +) -> Result { + let attestation_path = environment.join(ENVIRONMENT_ATTESTATION_FILE); + let raw = std::fs::read_to_string(&attestation_path) + .map_err(|error| format!("failed to read {}: {error}", attestation_path.display()))?; + let attestation = serde_json::from_str::(&raw).map_err(|error| { + format!( + "managed Python environment attestation {} is invalid: {error}", + attestation_path.display() + ) + })?; + if attestation.version != 1 + || attestation.source_artifact_sha256 != expected_source_artifact_sha256.trim() + || attestation.environment_sha256.len() != 64 + || !attestation + .environment_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err(format!( + "managed Python environment attestation {} does not match the trusted source artifact", + attestation_path.display() + )); + } + if !crate::config::verify_python_environment_attestation( + &attestation.source_artifact_sha256, + &attestation.environment_sha256, + &attestation.authentication, + ) + .map_err(|error| error.to_string())? + { + return Err(format!( + "managed Python environment attestation {} failed authentication", + attestation_path.display() + )); + } + Ok(attestation.environment_sha256) +} + +pub(super) fn verify_environment_attestation( + environment: &Path, + expected_source_artifact_sha256: &str, +) -> Result { + let expected = read_environment_attestation(environment, expected_source_artifact_sha256)?; + let actual = environment_tree_digest(environment)?; + if actual != expected { + return Err(format!( + "managed Python environment {} changed after provisioning", + environment.display() + )); + } + Ok(actual) +} + +pub(super) fn write_environment_attestation( + environment: &Path, + source_artifact_sha256: &str, +) -> Result<(), String> { + let digest = environment_tree_digest(environment)?; + let path = environment.join(ENVIRONMENT_ATTESTATION_FILE); + let authentication = + crate::config::sign_python_environment_attestation(source_artifact_sha256, &digest) + .map_err(|error| error.to_string())?; + let mut bytes = serde_json::to_vec_pretty(&EnvironmentAttestation { + version: 1, + source_artifact_sha256: source_artifact_sha256.trim().to_owned(), + environment_sha256: digest, + authentication, + }) + .map_err(|error| format!("failed to encode {}: {error}", path.display()))?; + bytes.push(b'\n'); + std::fs::write(&path, bytes) + .map_err(|error| format!("failed to write {}: {error}", path.display())) +} + +pub(super) fn environment_tree_digest(environment: &Path) -> Result { + #[cfg(test)] + ENVIRONMENT_TREE_DIGEST_CALLS.fetch_add(1, Ordering::Relaxed); + environment_tree_digest_with_limit(environment, MAX_ENVIRONMENT_FILES) +} + +fn environment_tree_digest_with_limit( + environment: &Path, + max_entries: usize, +) -> Result { + let mut digest = Sha256::new(); + let mut total = 0_u64; + let mut entries = 0_usize; + digest_environment_directory( + environment, + Path::new(""), + &mut Vec::new(), + &mut digest, + &mut total, + &mut entries, + max_entries, + )?; + Ok(digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +#[cfg(test)] +pub(super) fn test_environment_tree_digest_with_entry_limit( + environment: &Path, + max_entries: usize, +) -> Result { + environment_tree_digest_with_limit(environment, max_entries) +} + +#[cfg(test)] +pub(super) fn reset_environment_tree_digest_calls() { + ENVIRONMENT_TREE_DIGEST_CALLS.store(0, Ordering::Relaxed); +} + +#[cfg(test)] +pub(super) fn environment_tree_digest_calls() -> usize { + ENVIRONMENT_TREE_DIGEST_CALLS.load(Ordering::Relaxed) +} + +fn digest_environment_directory( + directory: &Path, + relative_directory: &Path, + ancestors: &mut Vec, + digest: &mut Sha256, + total: &mut u64, + entries: &mut usize, + max_entries: usize, +) -> Result<(), String> { + if ancestors.len() >= MAX_ENVIRONMENT_DEPTH { + return Err(format!( + "managed Python environment exceeds the {MAX_ENVIRONMENT_DEPTH}-directory traversal depth at {}", + directory.display() + )); + } + let canonical_directory = std::fs::canonicalize(directory) + .map_err(|error| format!("failed to normalize {}: {error}", directory.display()))?; + if ancestors.contains(&canonical_directory) { + return Err(format!( + "managed Python environment contains a directory symlink cycle at {}", + directory.display() + )); + } + ancestors.push(canonical_directory.clone()); + let mut children = Vec::new(); + for child in std::fs::read_dir(directory) + .map_err(|error| format!("failed to read {}: {error}", directory.display()))? + { + *entries = entries.saturating_add(1); + if *entries > max_entries { + return Err(format!( + "managed Python environment exceeds the {max_entries}-entry attestation budget at {}", + directory.display() + )); + } + children.push( + child.map_err(|error| format!("failed to read {}: {error}", directory.display()))?, + ); + } + children.sort_by_key(std::fs::DirEntry::file_name); + for child in children { + let path = child.path(); + let relative = relative_directory.join(child.file_name()); + if relative == Path::new(ENVIRONMENT_ATTESTATION_FILE) + || path.file_name().and_then(|name| name.to_str()) == Some("__pycache__") + || path.extension().and_then(|extension| extension.to_str()) == Some("pyc") + { + continue; + } + let metadata = std::fs::symlink_metadata(&path) + .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?; + let source = if metadata.file_type().is_symlink() { + std::fs::canonicalize(&path) + .map_err(|error| format!("failed to resolve {}: {error}", path.display()))? + } else { + path.clone() + }; + let source_metadata = std::fs::metadata(&source) + .map_err(|error| format!("failed to inspect {}: {error}", source.display()))?; + if source_metadata.is_dir() { + digest_environment_directory( + &source, + &relative, + ancestors, + digest, + total, + entries, + max_entries, + )?; + continue; + } + if !source_metadata.is_file() { + return Err(format!( + "managed Python environment entry {} must resolve to a regular file or directory", + path.display() + )); + } + let bytes = + crate::config::read_bounded_regular_file(&source, "managed Python environment file")?; + *total = total.saturating_add(bytes.len() as u64); + if *total > crate::config::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { + return Err(format!( + "managed Python environment exceeds the {}-byte attestation budget", + crate::config::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + )); + } + digest.update(relative.to_string_lossy().as_bytes()); + digest.update([0]); + digest.update(&bytes); + } + ancestors.pop(); + Ok(()) +} + pub(super) fn remove_managed_environment( state_path: &Path, plugin_id: &str, @@ -174,6 +520,11 @@ pub(super) fn environment_state( .map(|metadata| !metadata.file_type().is_dir()) .unwrap_or(true) || !environment_python_path(&configured).is_file() + || manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.sha256.as_deref()) + .is_none_or(|digest| verify_environment_attestation(&configured, digest).is_err()) { return DynamicPluginCheckState::Invalid; } diff --git a/crates/cli/src/plugins/lifecycle/trust.rs b/crates/cli/src/plugins/lifecycle/trust.rs index b3c6b225f..82a2de462 100644 --- a/crates/cli/src/plugins/lifecycle/trust.rs +++ b/crates/cli/src/plugins/lifecycle/trust.rs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 use std::fmt; -use std::fs; use std::path::{Path, PathBuf}; use base64::Engine; @@ -319,10 +318,11 @@ fn verify_signature( let signature_path = resolve_artifact_path(manifest_ref, signature_ref); let signature_bytes = read_signature_bytes(&signature_path)?; let artifact_bytes = - fs::read(artifact_path).map_err(|error| DynamicPluginTrustFailure::ArtifactRead { - path: artifact_path.to_path_buf(), - error: error.to_string(), - })?; + crate::config::read_bounded_regular_file(artifact_path, "dynamic plugin artifact") + .map_err(|error| DynamicPluginTrustFailure::ArtifactRead { + path: artifact_path.to_path_buf(), + error, + })?; let mut parse_errors = Vec::new(); for trusted_public_key in trusted_public_keys { @@ -348,10 +348,12 @@ fn verify_signature( } fn read_signature_bytes(path: &Path) -> TrustResult> { - let raw = fs::read(path).map_err(|error| DynamicPluginTrustFailure::SignatureRead { - path: path.to_path_buf(), - error: error.to_string(), - })?; + let raw = crate::config::read_bounded_regular_file(path, "dynamic plugin signature").map_err( + |error| DynamicPluginTrustFailure::SignatureRead { + path: path.to_path_buf(), + error, + }, + )?; let trimmed = String::from_utf8_lossy(&raw).trim().to_owned(); if trimmed.is_empty() { return Err(DynamicPluginTrustFailure::SignatureRead { @@ -400,9 +402,11 @@ fn resolve_artifact_path(manifest_ref: &str, artifact_ref: &str) -> PathBuf { } fn file_sha256(path: &Path) -> Result { - let bytes = fs::read(path)?; let mut digest = Sha256::new(); - digest.update(&bytes); + crate::config::stream_bounded_regular_file(path, "dynamic plugin artifact", |bytes| { + digest.update(bytes); + }) + .map_err(std::io::Error::other)?; Ok(format!( "sha256:{}", digest diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index 386ab8a26..5161ec470 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -3,13 +3,15 @@ use std::future::Future; use std::net::SocketAddr; +use std::path::Path; use std::pin::Pin; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use axum::extract::rejection::JsonRejection; use axum::extract::{DefaultBodyLimit, State}; -use axum::http::HeaderMap; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use nemo_relay::plugin::dynamic::{ @@ -23,14 +25,15 @@ use nemo_relay_adaptive::plugin_component::register_adaptive_component; use nemo_relay_pii_redaction::component::register_pii_redaction_component; use reqwest::Client; use serde_json::Value; +use subtle::ConstantTimeEq; use tokio::net::TcpListener; use tokio::sync::oneshot; use crate::adapters::{claude_code, codex, hermes}; -use crate::config::GatewayConfig; +use crate::config::{BootstrapChallengeKey, GatewayConfig, ManagedBootstrapIdentity}; use crate::error::CliError; use crate::gateway; -use crate::plugins::lifecycle::ActiveDynamicPluginComponent; +use crate::plugins::lifecycle::{ActiveDynamicPluginComponent, DynamicPluginActivationSnapshot}; use crate::session::SessionManager; const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); @@ -40,17 +43,47 @@ const HTTP_READ_TIMEOUT: Duration = Duration::from_secs(300); #[derive(Clone)] pub(crate) struct AppState { pub(crate) config: GatewayConfig, + pub(crate) bootstrap_fingerprint: Option, + pub(crate) bootstrap_challenge_key: Option, pub(crate) http: Client, pub(crate) sessions: SessionManager, pub(crate) last_activity: Arc>, + pub(crate) bootstrap_shutdown: Option, +} + +#[derive(Clone)] +pub(crate) struct BootstrapShutdown { + token: String, + sender: Arc>>>, } /// Binds the configured address and activates enabled dynamic plugins before serving. pub(crate) async fn serve_with_dynamic( config: GatewayConfig, dynamic_plugins: Vec, + managed_bootstrap: Option, + ready_file: Option<&Path>, ) -> Result<(), CliError> { - let listener = TcpListener::bind(config.bind).await.map_err(|err| { + let listener = bind_listener(config.bind).await?; + print_startup_status(listener.local_addr()?, &config); + let bootstrap_fingerprint = managed_bootstrap + .as_ref() + .map(|identity| identity.fingerprint().to_owned()); + serve_listener_with_dynamic_inner( + listener, + config, + dynamic_plugins, + bootstrap_fingerprint, + managed_bootstrap, + Some(ShutdownMode::ProcessSignal), + ready_file, + ) + .await +} + +/// Binds a gateway listener and translates address conflicts into actionable diagnostics. +pub(crate) async fn bind_listener(bind: SocketAddr) -> Result { + TcpListener::bind(bind).await.map_err(|err| { // Translate the common bind-failure (port already in use) into an actionable message. // Plain `io error: Address already in use (os error 48)` is unhelpful; the friendly // version names the likely cause and points at the real fixes. @@ -62,23 +95,15 @@ pub(crate) async fn serve_with_dynamic( `taskkill /IM nemo-relay.exe`)\n \ • use an ephemeral port: `nemo-relay --bind 127.0.0.1:0`\n \ • pick a free port: `nemo-relay --bind 127.0.0.1:4041`", - config.bind + bind )) } else { CliError::Io(err) } - })?; - print_startup_status(listener.local_addr()?, &config); - serve_listener_with_dynamic_inner( - listener, - config, - dynamic_plugins, - Some(ShutdownMode::ProcessSignal), - ) - .await + }) } -fn print_startup_status(bind: SocketAddr, config: &GatewayConfig) { +pub(crate) fn print_startup_status(bind: SocketAddr, config: &GatewayConfig) { let use_color = std::io::IsTerminal::is_terminal(&std::io::stderr()) && std::env::var_os("NO_COLOR").is_none(); eprint!("{}", render_startup_status(bind, config, use_color)); @@ -122,6 +147,25 @@ pub(crate) async fn serve_listener( serve_listener_with_dynamic(listener, config, Vec::new(), shutdown).await } +#[cfg(test)] +pub(crate) async fn serve_listener_with_bootstrap( + listener: TcpListener, + config: GatewayConfig, + bootstrap_fingerprint: String, + shutdown: Option>, +) -> Result<(), CliError> { + serve_listener_with_dynamic_inner( + listener, + config, + Vec::new(), + Some(bootstrap_fingerprint), + None, + shutdown.map(ShutdownMode::Receiver), + None, + ) + .await +} + /// Serves the gateway router and activates enabled dynamic plugin components. pub(crate) async fn serve_listener_with_dynamic( listener: TcpListener, @@ -133,7 +177,10 @@ pub(crate) async fn serve_listener_with_dynamic( listener, config, dynamic_plugins, + None, + None, shutdown.map(ShutdownMode::Receiver), + None, ) .await } @@ -149,18 +196,41 @@ async fn serve_listener_with_dynamic_inner( listener: TcpListener, config: GatewayConfig, dynamic_plugins: Vec, + bootstrap_fingerprint: Option, + managed_bootstrap: Option, shutdown_mode: Option, + ready_file: Option<&Path>, ) -> Result<(), CliError> { + let bootstrap_challenge_key = bootstrap_fingerprint + .as_ref() + .map(|_| BootstrapChallengeKey::load()) + .transpose()?; let plugin_activation = PluginActivation::initialize(config.plugin_config.clone(), dynamic_plugins).await?; - let state = AppState::new(config); + let (bootstrap_shutdown, bootstrap_shutdown_rx) = bootstrap_shutdown_channel(); + let state = AppState::new_with_bootstrap( + config, + bootstrap_fingerprint, + bootstrap_challenge_key, + bootstrap_shutdown, + ); let sessions = state.sessions.clone(); let last_activity = state.last_activity.clone(); let app = router_with_state(state); - let idle_shutdown = matches!(&shutdown_mode, None | Some(ShutdownMode::ProcessSignal)) - .then(plugin_idle_timeout) - .flatten() - .map(|timeout| idle_shutdown_future(last_activity, sessions.clone(), timeout)); + let local_address = listener.local_addr()?; + if let Some(identity) = managed_bootstrap.as_ref() { + identity.verify_current()?; + } + crate::sidecar::publish_sidecar_owner_from_env(local_address).map_err(CliError::Launch)?; + if let Some(path) = ready_file { + write_ready_file(path, local_address)?; + } + let idle_shutdown = if matches!(&shutdown_mode, None | Some(ShutdownMode::ProcessSignal)) { + plugin_idle_timeout()? + .map(|timeout| idle_shutdown_future(last_activity, sessions.clone(), timeout)) + } else { + None + }; let shutdown: Option = match shutdown_mode { Some(ShutdownMode::Receiver(receiver)) => Some(Box::pin(async move { let _ = receiver.await; @@ -177,6 +247,18 @@ async fn serve_listener_with_dynamic_inner( })), None => idle_shutdown.map(|idle| Box::pin(idle) as ShutdownFuture), }; + let shutdown = match (shutdown, bootstrap_shutdown_rx) { + (Some(shutdown), Some(receiver)) => Some(Box::pin(async move { + tokio::select! { + _ = shutdown => {} + _ = receiver => {} + } + }) as ShutdownFuture), + (None, Some(receiver)) => Some(Box::pin(async move { + let _ = receiver.await; + }) as ShutdownFuture), + (shutdown, None) => shutdown, + }; let serve_result = match shutdown { Some(shutdown) => { axum::serve(listener, app) @@ -243,7 +325,17 @@ pub(crate) fn router(config: GatewayConfig) -> Router { } impl AppState { + #[cfg(test)] fn new(config: GatewayConfig) -> Self { + Self::new_with_bootstrap(config, None, None, None) + } + + fn new_with_bootstrap( + config: GatewayConfig, + bootstrap_fingerprint: Option, + bootstrap_challenge_key: Option, + bootstrap_shutdown: Option, + ) -> Self { let sessions = SessionManager::new(config.clone()); sessions.start_idle_sweeper(); let http = Client::builder() @@ -254,9 +346,12 @@ impl AppState { .expect("gateway HTTP client configuration is valid"); Self { config, + bootstrap_fingerprint, + bootstrap_challenge_key, http, sessions, last_activity: Arc::new(Mutex::new(Instant::now())), + bootstrap_shutdown, } } @@ -271,6 +366,7 @@ fn router_with_state(state: AppState) -> Router { let max_hook_payload_bytes = state.config.max_hook_payload_bytes; Router::new() .route("/healthz", get(healthz)) + .route("/bootstrap/shutdown", post(shutdown_bootstrap_sidecar)) .route("/hooks/codex", post(codex_hook)) .route("/hooks/claude-code", post(claude_code_hook)) .route("/hooks/hermes", post(hermes_hook)) @@ -286,15 +382,145 @@ fn router_with_state(state: AppState) -> Router { .with_state(state) } -async fn healthz(State(state): State) -> Json { - state.touch(); - Json(serde_json::json!({ "status": "ok" })) +fn bootstrap_shutdown_channel() -> (Option, Option>) { + let Some(token) = std::env::var("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN") + .ok() + .filter(|token| !token.is_empty()) + else { + return (None, None); + }; + let (sender, receiver) = oneshot::channel(); + ( + Some(BootstrapShutdown { + token, + sender: Arc::new(Mutex::new(Some(sender))), + }), + Some(receiver), + ) } -fn plugin_idle_timeout() -> Option { - let raw = std::env::var("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS").ok()?; - let seconds = raw.parse::().ok()?; - (seconds > 0).then(|| Duration::from_secs(seconds)) +async fn shutdown_bootstrap_sidecar( + State(state): State, + headers: HeaderMap, +) -> StatusCode { + let Some(shutdown) = state.bootstrap_shutdown.as_ref() else { + return StatusCode::NOT_FOUND; + }; + if headers + .get("x-nemo-relay-bootstrap-token") + .and_then(|value| value.to_str().ok()) + != Some(shutdown.token.as_str()) + { + return StatusCode::FORBIDDEN; + } + let Ok(mut sender) = shutdown.sender.lock() else { + return StatusCode::INTERNAL_SERVER_ERROR; + }; + let Some(sender) = sender.take() else { + return StatusCode::GONE; + }; + let _ = sender.send(()); + StatusCode::NO_CONTENT +} + +async fn healthz(State(state): State, headers: HeaderMap) -> Response { + let presented_fingerprint = headers + .get("x-nemo-relay-bootstrap-fingerprint") + .and_then(|value| value.to_str().ok()); + let mut response_headers = HeaderMap::new(); + let compatible = match presented_fingerprint { + None => true, + Some(expected) => { + let fingerprint_matches = state + .bootstrap_fingerprint + .as_deref() + .is_some_and(|actual| bool::from(actual.as_bytes().ct_eq(expected.as_bytes()))); + let nonce = headers + .get("x-nemo-relay-bootstrap-nonce") + .and_then(|value| value.to_str().ok()) + .filter(|nonce| { + nonce.len() == 64 && nonce.bytes().all(|byte| byte.is_ascii_hexdigit()) + }); + match ( + fingerprint_matches, + nonce, + state.bootstrap_challenge_key.as_ref(), + ) { + (true, Some(nonce), Some(key)) => { + let proof = key.proof(expected, nonce); + response_headers.insert( + "x-nemo-relay-bootstrap-proof", + HeaderValue::from_str(&proof).expect("bootstrap proof is an ASCII value"), + ); + state.touch(); + true + } + _ => false, + } + } + }; + ( + if compatible { + StatusCode::OK + } else { + StatusCode::CONFLICT + }, + response_headers, + Json(serde_json::json!({ + "status": if compatible { "ok" } else { "incompatible" }, + "service": "nemo-relay", + "version": env!("CARGO_PKG_VERSION"), + "bootstrap_protocol": 1 + })), + ) + .into_response() +} + +fn write_ready_file(path: &Path, bind: SocketAddr) -> Result<(), CliError> { + let bytes = serde_json::to_vec(&serde_json::json!({ + "address": bind, + "service": "nemo-relay", + "version": env!("CARGO_PKG_VERSION"), + "bootstrap_protocol": 1 + })) + .map_err(|error| CliError::Launch(format!("failed to encode readiness file: {error}")))?; + let temporary = path.with_extension(format!( + "{}tmp", + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| format!("{extension}.")) + .unwrap_or_default() + )); + std::fs::write(&temporary, bytes).map_err(|error| { + CliError::Launch(format!( + "failed to write readiness file {}: {error}", + temporary.display() + )) + })?; + std::fs::rename(&temporary, path).map_err(|error| { + let _ = std::fs::remove_file(&temporary); + CliError::Launch(format!( + "failed to publish readiness file {}: {error}", + path.display() + )) + }) +} + +fn plugin_idle_timeout() -> Result, CliError> { + let Some(raw) = std::env::var("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS").ok() else { + return Ok(None); + }; + let seconds = raw.parse::().map_err(|error| { + CliError::Config(format!( + "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS must be a positive integer: {error}" + )) + })?; + if seconds == 0 { + return Err(CliError::Config( + "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS must be greater than 0".into(), + )); + } + Ok(Some(Duration::from_secs(seconds))) } async fn idle_shutdown_future( @@ -307,20 +533,38 @@ async fn idle_shutdown_future( .max(Duration::from_secs(1)); loop { tokio::time::sleep(tick).await; - let elapsed = last_activity - .lock() - .map(|last_activity| last_activity.elapsed()) - .unwrap_or(timeout); - if elapsed >= timeout && !sessions.has_open_sessions().await { + if idle_shutdown_ready(&last_activity, timeout, sessions.has_open_sessions()).await { break; } } } +async fn idle_shutdown_ready( + last_activity: &Arc>, + timeout: Duration, + has_open_sessions: F, +) -> bool +where + F: std::future::Future, +{ + let observed = match last_activity.lock() { + Ok(last_activity) if last_activity.elapsed() >= timeout => *last_activity, + Ok(_) => return false, + Err(_) => return true, + }; + if has_open_sessions.await { + return false; + } + last_activity.lock().map_or(true, |last_activity| { + *last_activity == observed && last_activity.elapsed() >= timeout + }) +} + struct PluginActivation { active: bool, native: Option, worker: Option, + _snapshots: Vec>, } impl PluginActivation { @@ -333,6 +577,7 @@ impl PluginActivation { active: false, native: None, worker: None, + _snapshots: Vec::new(), }); }; register_adaptive_component().map_err(|error| { @@ -341,16 +586,26 @@ impl PluginActivation { register_pii_redaction_component().map_err(|error| { CliError::Config(format!("PII redaction plugin registration failed: {error}")) })?; + for plugin in &dynamic_plugins { + if let Some(snapshot) = plugin.activation_snapshot.as_ref() { + snapshot.verify_current()?; + } + } let native_specs = dynamic_plugins .iter() .filter(|plugin| plugin.kind == DynamicPluginKind::RustDynamic) .map(|plugin| { - let manifest_ref = plugin.manifest_ref.clone().ok_or_else(|| { - CliError::Config(format!( - "native dynamic plugin '{}' has no manifest_ref in lifecycle state", - plugin.plugin_id - )) - })?; + let manifest_ref = plugin + .activation_snapshot + .as_ref() + .map(|snapshot| snapshot.activation_manifest_ref()) + .or_else(|| plugin.manifest_ref.clone()) + .ok_or_else(|| { + CliError::Config(format!( + "native dynamic plugin '{}' has no manifest_ref in lifecycle state", + plugin.plugin_id + )) + })?; Ok(NativePluginLoadSpec { plugin_id: plugin.plugin_id.clone(), manifest_ref, @@ -361,20 +616,34 @@ impl PluginActivation { .iter() .filter(|plugin| plugin.kind == DynamicPluginKind::Worker) .map(|plugin| { - let manifest_ref = plugin.manifest_ref.clone().ok_or_else(|| { - CliError::Config(format!( - "worker dynamic plugin '{}' has no manifest_ref in lifecycle state", - plugin.plugin_id - )) - })?; + let manifest_ref = plugin + .activation_snapshot + .as_ref() + .map(|snapshot| snapshot.activation_manifest_ref()) + .or_else(|| plugin.manifest_ref.clone()) + .ok_or_else(|| { + CliError::Config(format!( + "worker dynamic plugin '{}' has no manifest_ref in lifecycle state", + plugin.plugin_id + )) + })?; Ok(WorkerPluginLoadSpec { plugin_id: plugin.plugin_id.clone(), manifest_ref, - environment_ref: plugin.environment_ref.clone(), + environment_ref: plugin + .activation_snapshot + .as_ref() + .and_then(|snapshot| snapshot.activation_environment_ref()) + .map(ToOwned::to_owned) + .or_else(|| plugin.environment_ref.clone()), config: plugin.config.clone(), }) }) .collect::, CliError>>()?; + let snapshots = dynamic_plugins + .iter() + .filter_map(|plugin| plugin.activation_snapshot.clone()) + .collect(); let native = if native_specs.is_empty() { None @@ -383,6 +652,11 @@ impl PluginActivation { CliError::Config(format!("native plugin load failed: {error}")) })?) }; + for plugin in &dynamic_plugins { + if let Some(snapshot) = plugin.activation_snapshot.as_ref() { + snapshot.verify_current()?; + } + } let worker = if worker_specs.is_empty() { None @@ -415,6 +689,7 @@ impl PluginActivation { active: true, native, worker, + _snapshots: snapshots, }) } diff --git a/crates/cli/src/session.rs b/crates/cli/src/session.rs index a479a5c0f..cf2a90bae 100644 --- a/crates/cli/src/session.rs +++ b/crates/cli/src/session.rs @@ -1235,15 +1235,22 @@ impl Session { .await?; return Ok(()); } - self.close_turn(event.payload, "closed_by_turn_end").await?; + self.close_turn(event.payload, Some(event.metadata), "closed_by_turn_end") + .await?; Ok(()) } async fn close_turn_for_reason(&mut self, reason: &str) -> Result, CliError> { - self.close_turn(json!({ "status": reason }), reason).await + self.close_turn(json!({ "status": reason }), None, reason) + .await } - async fn close_turn(&mut self, output: Value, reason: &str) -> Result, CliError> { + async fn close_turn( + &mut self, + output: Value, + boundary_metadata: Option, + reason: &str, + ) -> Result, CliError> { if self.turn_scope.is_none() { return Ok(Vec::new()); } @@ -1252,7 +1259,7 @@ impl Session { let closed_subagents = self.close_active_subagents(reason).await?; let output = self.last_turn_llm_output.take().unwrap_or(output); self.clear_correlation_state(); - self.close_turn_scope(output)?; + self.close_turn_scope(output, boundary_metadata)?; Ok(closed_subagents) } @@ -1359,7 +1366,11 @@ impl Session { Ok(()) } - fn close_turn_scope(&mut self, output: Value) -> Result<(), CliError> { + fn close_turn_scope( + &mut self, + output: Value, + boundary_metadata: Option, + ) -> Result<(), CliError> { let Some(scope) = self.turn_scope.take() else { return Ok(()); }; @@ -1368,6 +1379,7 @@ impl Session { PopScopeParams::builder() .handle_uuid(&scope.uuid) .output(output) + .metadata_opt(boundary_metadata) .build(), )?; Ok(()) diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs new file mode 100644 index 000000000..8e953117a --- /dev/null +++ b/crates/cli/src/sidecar.rs @@ -0,0 +1,788 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared native gateway sidecar lifecycle. + +mod health; +mod process; +mod state; + +use std::env; +use std::ffi::OsString; +use std::fs::{self, OpenOptions}; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::{OnceLock, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::config::{CodingAgent, ServerArgs, resolve_persistent_server_config}; +use crate::error::CliError; +use crate::file_io::{LockAttempt, try_lock_exclusive}; +#[cfg(test)] +pub(crate) use health::healthz_compatible; +use health::{ + RelayHealth, probe as probe_relay_health, probe_after_lock as probe_relay_health_after_lock, +}; +pub(crate) use health::{healthz, loopback_authority, loopback_bind, parse_loopback_url}; +use process::DetachedSidecarProcess; +#[cfg(all(windows, not(test)))] +use process::SidecarJob; +#[cfg(all(test, windows))] +pub(crate) use process::SidecarJob; +pub(crate) use process::configure_detached_sidecar; +pub(crate) use process::join_sidecar_job_from_env; +#[cfg(all(test, unix))] +pub(crate) use process::terminate_sidecar_process_tree; +#[cfg(test)] +pub(crate) use process::{ + WINDOWS_CREATE_BREAKAWAY_FROM_JOB, WINDOWS_CREATE_NEW_PROCESS_GROUP, WINDOWS_CREATE_NO_WINDOW, + WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK, WINDOWS_JOB_OBJECT_LIMIT_KILL_ON_CLOSE, + WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, terminate_unready_sidecar, + windows_sidecar_creation_flags, +}; +pub(crate) use state::{BOOTSTRAP_AGENT_ENV, BOOTSTRAP_STATE_DIR_ENV}; +use state::{ + create_private_runtime_dir, open_lock as open_sidecar_lock, read_owner_record, + read_ready_file as read_sidecar_ready_file, runtime_dir, +}; +pub(crate) use state::{ + lock_endpoint as lock_sidecar_endpoint, lock_path as sidecar_lock_path, + owner_path as sidecar_owner_path, owner_paths as sidecar_owner_paths, + pid_path as sidecar_pid_path, state_dir as sidecar_state_dir, + validate_owner as validate_sidecar_owner, +}; +#[cfg(test)] +pub(crate) use state::{ + lock_endpoint_for as lock_sidecar_endpoint_for, lock_name as sidecar_lock_name, + runtime_dir_for, stop_owned_record as stop_owned_sidecar_record, + write_owner as write_sidecar_owner, +}; +pub(crate) use state::{ + publish_owner_from_env as publish_sidecar_owner_from_env, stop_owned as stop_owned_sidecar, +}; + +pub(crate) const DEFAULT_BIND: &str = "127.0.0.1:47632"; +pub(crate) const DEFAULT_URL: &str = "http://127.0.0.1:47632"; +pub(crate) const HEALTHZ_TIMEOUT: Duration = Duration::from_millis(500); + +pub(super) const SIDECAR_LOCK_TIMEOUT: Duration = Duration::from_secs(20); +const SIDECAR_START_TIMEOUT: Duration = Duration::from_secs(10); +pub(crate) const BOOTSTRAP_PROTOCOL_VERSION: u64 = 1; +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GatewayEndpoint { + pub(crate) address: SocketAddr, + pub(crate) url: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GatewayBootstrap { + pub(crate) endpoint: GatewayEndpoint, + pub(crate) started: bool, +} + +/// Complete launch and compatibility contract for one shared gateway. +/// +/// Callers construct this once and use the same value for discovery, startup, health checks, and +/// recovery. Keeping those inputs together prevents a gateway from being started with one +/// identity and later adopted under another. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GatewaySpec { + agent: CodingAgent, + bind: SocketAddr, + sidecar_args: Vec, + bootstrap_fingerprint: Option, +} + +impl GatewaySpec { + pub(crate) fn new(agent: CodingAgent, bind: SocketAddr) -> Self { + Self { + agent, + bind, + sidecar_args: Vec::new(), + bootstrap_fingerprint: None, + } + } + + pub(crate) fn with_launch_args(mut self, args: Vec) -> Self { + self.sidecar_args = args; + self + } + + pub(crate) fn with_fingerprint(mut self, fingerprint: impl Into) -> Self { + self.bootstrap_fingerprint = Some(fingerprint.into()); + self + } + + pub(crate) fn bind(&self) -> SocketAddr { + self.bind + } + + pub(crate) fn ensure(&self) -> Result { + ensure_gateway(self) + } + + pub(crate) fn is_healthy(&self, url: &str) -> bool { + probe_relay_health(url, self.bootstrap_fingerprint.as_deref()) == RelayHealth::Compatible + } +} + +/// Persistent Codex gateway settings shared by MCP bootstrap and hook recovery. +pub(crate) struct CodexGatewaySpec { + pub(crate) gateway: GatewaySpec, + pub(crate) max_hook_payload_bytes: usize, +} + +pub(crate) fn resolve_codex_gateway( + server_args: &ServerArgs, + bind: SocketAddr, +) -> Result { + let mut persistent_args = server_args.clone(); + persistent_args.bind = Some(bind); + let resolved = resolve_persistent_server_config(&persistent_args)?; + let bootstrap_fingerprint = resolved + .bootstrap_fingerprint + .expect("persistent gateway resolution sets a bootstrap fingerprint"); + let max_hook_payload_bytes = resolved.gateway.max_hook_payload_bytes; + let sidecar_args = [ + ("--openai-base-url", resolved.gateway.openai_base_url), + ("--anthropic-base-url", resolved.gateway.anthropic_base_url), + ( + "--max-hook-payload-bytes", + resolved.gateway.max_hook_payload_bytes.to_string(), + ), + ( + "--max-passthrough-body-bytes", + resolved.gateway.max_passthrough_body_bytes.to_string(), + ), + ] + .into_iter() + .flat_map(|(flag, value)| [OsString::from(flag), OsString::from(value)]) + .collect(); + Ok(CodexGatewaySpec { + gateway: GatewaySpec::new(CodingAgent::Codex, bind) + .with_launch_args(sidecar_args) + .with_fingerprint(bootstrap_fingerprint), + max_hook_payload_bytes, + }) +} + +#[cfg(test)] +pub(crate) fn ensure_sidecar_bind( + agent: CodingAgent, + bind: SocketAddr, +) -> Result { + GatewaySpec::new(agent, bind) + .ensure() + .map(|bootstrap| bootstrap.endpoint) +} + +fn ensure_gateway(spec: &GatewaySpec) -> Result { + let agent = spec.agent; + let bind = spec.bind; + let sidecar_args = &spec.sidecar_args; + let bootstrap_fingerprint = spec.bootstrap_fingerprint.as_deref(); + if !bind.ip().is_loopback() { + return Err(format!( + "plugin sidecars require a loopback bind address, got {bind}" + )); + } + let url = format!("http://{bind}"); + let runtime = runtime_dir(); + create_private_runtime_dir(&runtime).map_err(|error| { + sidecar_start_error( + agent, + &url, + &runtime, + &format!("failed to create {}: {error}", runtime.display()), + ) + })?; + let state = sidecar_state_dir()?; + create_private_runtime_dir(&state).map_err(|error| { + sidecar_start_error( + agent, + &url, + &runtime, + &format!("failed to create {}: {error}", state.display()), + ) + })?; + if bind.port() == 0 { + return start_sidecar_bind( + agent, + bind, + &runtime, + &state, + sidecar_args, + bootstrap_fingerprint, + None, + ) + .map_err(|error| sidecar_start_error(agent, &url, &runtime, &error)); + } + let lock_path = sidecar_lock_path(&state, &url); + let initial_health = probe_relay_health(&url, bootstrap_fingerprint); + match initial_health { + RelayHealth::Compatible => { + return Ok(GatewayBootstrap { + endpoint: GatewayEndpoint { address: bind, url }, + started: false, + }); + } + RelayHealth::Incompatible | RelayHealth::Foreign | RelayHealth::Unavailable => {} + } + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path) + .map_err(|error| { + sidecar_start_error( + agent, + &url, + &runtime, + &format!( + "failed to open sidecar lock {}: {error}", + lock_path.display() + ), + ) + })?; + let lock_deadline = Instant::now() + SIDECAR_LOCK_TIMEOUT; + loop { + match try_lock_exclusive(&lock) { + Ok(LockAttempt::Acquired) => break, + Ok(LockAttempt::Contended) => { + if probe_relay_health(&url, bootstrap_fingerprint) == RelayHealth::Compatible { + return Ok(GatewayBootstrap { + endpoint: GatewayEndpoint { address: bind, url }, + started: false, + }); + } + if Instant::now() >= lock_deadline { + return Err(sidecar_start_error( + agent, + &url, + &runtime, + "sidecar lock timed out", + )); + } + thread::sleep(Duration::from_millis(50)); + } + Err(error) => { + return Err(sidecar_start_error( + agent, + &url, + &runtime, + &format!("failed to acquire sidecar lock: {error}"), + )); + } + } + } + match probe_relay_health_after_lock(&url, bootstrap_fingerprint) { + RelayHealth::Compatible => { + return Ok(GatewayBootstrap { + endpoint: GatewayEndpoint { address: bind, url }, + started: false, + }); + } + RelayHealth::Incompatible => return Err(incompatible_relay_error(&url)), + RelayHealth::Foreign => return Err(foreign_listener_error(&url)), + RelayHealth::Unavailable => {} + } + let result = start_sidecar_bind( + agent, + bind, + &runtime, + &state, + sidecar_args, + bootstrap_fingerprint, + Some(lock), + ); + result.map_err(|error| sidecar_start_error(agent, &url, &runtime, &error)) +} + +fn foreign_listener_error(url: &str) -> String { + format!( + "{url} is occupied by a service that is not a compatible NeMo Relay gateway; stop that service or configure another port" + ) +} + +fn incompatible_relay_error(url: &str) -> String { + format!( + "{url} is occupied by NeMo Relay with a different version or persistent configuration; stop it, wait for its idle shutdown, or run `nemo-relay install codex --force` before retrying" + ) +} + +fn sidecar_start_error(agent: CodingAgent, url: &str, runtime: &Path, error: &str) -> String { + let log_path = runtime.join(format!("{}-sidecar.log", agent.as_arg())); + let manual = parse_loopback_url(url) + .map(|(host, port)| format!("nemo-relay --bind {}", loopback_authority(&host, port))) + .unwrap_or_else(|_| "nemo-relay --bind 127.0.0.1:47632".into()); + format!( + "{error}; inspect {}; or start the gateway manually with `{manual}`", + log_path.display() + ) +} + +#[cfg(all(test, unix))] +pub(super) fn start_sidecar(agent: CodingAgent, url: &str, runtime: &Path) -> Result<(), String> { + start_sidecar_bind( + agent, + loopback_bind(url)?, + runtime, + runtime, + &[], + None, + None, + ) + .map(|_| ()) +} + +struct ArmedSidecarChild { + process: Option, + startup_pid_path: PathBuf, + state: PathBuf, + agent: CodingAgent, + pid: u32, +} + +impl ArmedSidecarChild { + fn new( + child: Child, + startup_pid_path: PathBuf, + state: &Path, + agent: CodingAgent, + #[cfg(windows)] prepared_job: Option, + ) -> Self { + let pid = child.id(); + Self { + process: Some(DetachedSidecarProcess::new( + child, + #[cfg(windows)] + prepared_job, + )), + startup_pid_path, + state: state.to_path_buf(), + agent, + pid, + } + } + + fn id(&self) -> u32 { + self.pid + } + + fn try_wait(&mut self) -> std::io::Result> { + self.process + .as_mut() + .expect("armed sidecar process is present") + .try_wait() + } + + fn disarm(mut self) -> DetachedSidecarProcess { + self.process + .take() + .expect("armed sidecar process is present") + } +} + +impl Drop for ArmedSidecarChild { + fn drop(&mut self) { + if let Some(mut process) = self.process.take() { + // The launcher may exit before readiness while leaving descendants behind. Always + // target the detached process group even when the direct child has already exited. + process.terminate(); + cleanup_sidecar_records_for_pid(&self.state, self.agent, self.pid); + } + let _ = fs::remove_file(&self.startup_pid_path); + } +} + +fn cleanup_sidecar_records_for_pid(runtime: &Path, agent: CodingAgent, pid: u32) { + let Ok(paths) = sidecar_owner_paths(runtime, agent) else { + return; + }; + for owner_path in paths { + let Ok(Some(owner)) = read_owner_record(&owner_path) else { + continue; + }; + if owner.pid != pid { + continue; + } + let _ = fs::remove_file(&owner_path); + let _ = fs::remove_file(sidecar_pid_path(runtime, agent, &owner.url)); + } +} + +fn start_sidecar_bind( + agent: CodingAgent, + bind: SocketAddr, + runtime: &Path, + state: &Path, + sidecar_args: &[OsString], + bootstrap_fingerprint: Option<&str>, + mut startup_lock: Option, +) -> Result { + let requested_url = format!("http://{bind}"); + if bind.port() != 0 { + match probe_relay_health(&requested_url, bootstrap_fingerprint) { + RelayHealth::Compatible => { + return Ok(GatewayBootstrap { + endpoint: GatewayEndpoint { + address: bind, + url: requested_url, + }, + started: false, + }); + } + RelayHealth::Incompatible => { + return Err(incompatible_relay_error(&requested_url)); + } + RelayHealth::Foreign => return Err(foreign_listener_error(&requested_url)), + RelayHealth::Unavailable => {} + } + } + let relay = relay_binary()?; + let log_path = runtime.join(format!("{}-sidecar.log", agent.as_arg())); + let ready_path = runtime.join(format!( + "{}-sidecar-{}-{}.ready.json", + agent.as_arg(), + std::process::id(), + uuid::Uuid::now_v7() + )); + let _ = fs::remove_file(&ready_path); + let log = OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .map_err(|error| format!("failed to open {}: {error}", log_path.display()))?; + let err_log = log + .try_clone() + .map_err(|error| format!("failed to clone sidecar log handle: {error}"))?; + let idle_timeout = plugin_idle_timeout()?; + let shutdown_token = uuid::Uuid::now_v7().to_string(); + #[cfg(windows)] + let sidecar_job = SidecarJob::create()?; + let mut command = Command::new(relay); + command + .arg("--bind") + .arg(bind.to_string()) + .arg("--ready-file") + .arg(&ready_path) + .args(sidecar_args) + .env( + "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", + idle_timeout.as_secs().to_string(), + ) + .env(BOOTSTRAP_AGENT_ENV, agent.as_arg()) + .env( + crate::config::BOOTSTRAP_FINGERPRINT_ENV, + bootstrap_fingerprint.unwrap_or_default(), + ) + .env(BOOTSTRAP_STATE_DIR_ENV, state) + .env("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", &shutdown_token) + .stdin(Stdio::null()) + .stdout(Stdio::from(log)) + .stderr(Stdio::from(err_log)); + #[cfg(windows)] + sidecar_job.configure_child(&mut command); + if matches!(agent, CodingAgent::Codex) { + command.env("NEMO_RELAY_CONFIG_SCOPE", "user"); + if let Some(config_dir) = crate::config::user_config_dir() { + fs::create_dir_all(&config_dir).map_err(|error| { + format!( + "failed to create Codex sidecar working directory {}: {error}", + config_dir.display() + ) + })?; + command.current_dir(config_dir); + } + } + configure_detached_sidecar(&mut command); + let child = command + .spawn() + .map_err(|error| format!("failed to spawn nemo-relay sidecar: {error}"))?; + let startup_pid_path = ready_path.with_extension("pid"); + let _ = fs::write(&startup_pid_path, child.id().to_string()); + let mut child = ArmedSidecarChild::new( + child, + startup_pid_path, + state, + agent, + #[cfg(windows)] + Some(sidecar_job), + ); + let deadline = Instant::now() + SIDECAR_START_TIMEOUT; + while Instant::now() < deadline { + match read_sidecar_ready_file(&ready_path) { + Ok(Some(endpoint)) + if (bind.port() == 0 || endpoint.address == bind) + && probe_relay_health(&endpoint.url, bootstrap_fingerprint) + == RelayHealth::Compatible => + { + let ownership_lock = match startup_lock.take() { + Some(lock) => lock, + None => lock_sidecar_endpoint(state, &endpoint.url)?, + }; + let owner_path = sidecar_owner_path(state, agent, &endpoint.url); + let pid_path = sidecar_pid_path(state, agent, &endpoint.url); + let pid = child.id(); + validate_sidecar_owner( + &owner_path, + &pid_path, + pid, + &endpoint.url, + &shutdown_token, + bootstrap_fingerprint, + )?; + if let Err(error) = handoff_detached_sidecar_to_reaper( + child.disarm(), + owner_path.clone(), + pid_path.clone(), + sidecar_lock_path(state, &endpoint.url), + ) { + let _ = fs::remove_file(&owner_path); + let _ = fs::remove_file(&pid_path); + let _ = fs::remove_file(&ready_path); + return Err(error); + } + drop(ownership_lock); + let _ = fs::remove_file(&ready_path); + return Ok(GatewayBootstrap { + endpoint, + started: true, + }); + } + Ok(_) => {} + Err(error) => { + let _ = fs::remove_file(&ready_path); + return Err(error); + } + } + match child.try_wait() { + Ok(Some(status)) => { + let _ = fs::remove_file(&ready_path); + if bind.port() != 0 + && probe_relay_health(&requested_url, bootstrap_fingerprint) + == RelayHealth::Compatible + { + return Ok(GatewayBootstrap { + endpoint: GatewayEndpoint { + address: bind, + url: requested_url, + }, + started: false, + }); + } + return Err(format!( + "nemo-relay sidecar exited before becoming ready at {requested_url}: {status}" + )); + } + Ok(None) => {} + Err(error) => { + let _ = fs::remove_file(&ready_path); + return Err(format!( + "failed to inspect nemo-relay sidecar process: {error}" + )); + } + } + thread::sleep(Duration::from_millis(50)); + } + let _ = fs::remove_file(&ready_path); + Err(format!( + "nemo-relay sidecar did not become ready at {requested_url}; terminated startup process" + )) +} + +struct SidecarReapRequest { + process: DetachedSidecarProcess, + exited: bool, + owner_path: PathBuf, + pid_path: PathBuf, + lock_path: PathBuf, +} + +static SIDECAR_REAPER: OnceLock, String>> = OnceLock::new(); + +fn sidecar_reaper_sender() -> Result<&'static mpsc::Sender, String> { + match SIDECAR_REAPER.get_or_init(|| { + let (sender, receiver) = mpsc::channel(); + thread::Builder::new() + .name("nemo-relay-sidecar-reaper".into()) + .spawn(move || run_sidecar_reaper(receiver)) + .map(|_| sender) + .map_err(|error| format!("failed to start nemo-relay sidecar reaper: {error}")) + }) { + Ok(sender) => Ok(sender), + Err(error) => Err(error.clone()), + } +} + +#[cfg(test)] +pub(super) fn handoff_sidecar_to_reaper( + child: Child, + owner_path: PathBuf, + pid_path: PathBuf, + lock_path: PathBuf, +) -> Result<(), String> { + let process = DetachedSidecarProcess::new( + child, + #[cfg(windows)] + None, + ); + handoff_detached_sidecar_to_reaper(process, owner_path, pid_path, lock_path) +} + +fn handoff_detached_sidecar_to_reaper( + process: DetachedSidecarProcess, + owner_path: PathBuf, + pid_path: PathBuf, + lock_path: PathBuf, +) -> Result<(), String> { + let sender = match sidecar_reaper_sender() { + Ok(sender) => sender, + Err(error) => { + return terminate_reaper_handoff(process, &pid_path, error); + } + }; + let request = SidecarReapRequest { + process, + exited: false, + owner_path, + pid_path, + lock_path, + }; + match sender.send(request) { + Ok(()) => Ok(()), + Err(error) => terminate_reaper_handoff( + error.0.process, + &error.0.pid_path, + "nemo-relay sidecar reaper stopped unexpectedly".into(), + ), + } +} + +fn terminate_reaper_handoff( + mut process: DetachedSidecarProcess, + pid_path: &Path, + error: String, +) -> Result<(), String> { + process.terminate(); + let _ = fs::remove_file(pid_path); + Err(error) +} + +fn run_sidecar_reaper(receiver: mpsc::Receiver) { + let mut children = Vec::new(); + loop { + match receiver.recv_timeout(Duration::from_millis(100)) { + Ok(request) => children.push(request), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) if children.is_empty() => return, + Err(mpsc::RecvTimeoutError::Disconnected) => {} + } + while let Ok(request) = receiver.try_recv() { + children.push(request); + } + let mut index = 0; + while index < children.len() { + if !children[index].exited { + match children[index].process.try_wait() { + Ok(Some(_)) => children[index].exited = true, + Ok(None) => { + index += 1; + continue; + } + Err(error) => { + eprintln!("failed to inspect nemo-relay sidecar process: {error}"); + index += 1; + continue; + } + } + } + children[index].process.terminate_retained_descendants(); + if cleanup_reaped_sidecar(&children[index]) { + let request = children.swap_remove(index); + drop(request); + } else { + index += 1; + } + } + } +} + +fn cleanup_reaped_sidecar(request: &SidecarReapRequest) -> bool { + let lock = match open_sidecar_lock(&request.lock_path) { + Ok(lock) => lock, + Err(error) => { + eprintln!("failed to clean up nemo-relay sidecar ownership: {error}"); + return true; + } + }; + match try_lock_exclusive(&lock) { + Ok(LockAttempt::Acquired) => {} + Ok(LockAttempt::Contended) => return false, + Err(error) => { + eprintln!("failed to lock nemo-relay sidecar ownership for cleanup: {error}"); + return true; + } + } + let pid = request.process.id(); + if read_owner_record(&request.owner_path) + .ok() + .flatten() + .is_some_and(|owner| owner.pid == pid) + { + let _ = fs::remove_file(&request.owner_path); + } + if fs::read_to_string(&request.pid_path) + .ok() + .is_some_and(|value| value.trim() == pid.to_string()) + { + let _ = fs::remove_file(&request.pid_path); + } + true +} + +pub(super) fn relay_binary() -> Result { + if let Ok(path) = env::var("NEMO_RELAY_PLUGIN_BINARY") { + let path = PathBuf::from(path); + if path.exists() { + return Ok(path); + } + return Err(format!( + "NEMO_RELAY_PLUGIN_BINARY does not exist: {}", + path.display() + )); + } + current_exe() +} + +pub(super) fn current_exe() -> Result { + env::current_exe().map_err(|error| format!("failed to resolve current executable: {error}")) +} + +pub(crate) fn plugin_idle_timeout() -> Result { + let raw = env::var(crate::config::PLUGIN_IDLE_TIMEOUT_ENV).unwrap_or_else(|_| "300".into()); + let seconds = raw.parse::().map_err(|error| { + format!( + "{} must be a positive integer: {error}", + crate::config::PLUGIN_IDLE_TIMEOUT_ENV + ) + })?; + if seconds == 0 { + return Err(format!( + "{} must be greater than 0", + crate::config::PLUGIN_IDLE_TIMEOUT_ENV + )); + } + Ok(Duration::from_secs(seconds)) +} + +pub(crate) fn plugin_heartbeat_interval() -> Result { + Ok((plugin_idle_timeout()? / 3).clamp(Duration::from_millis(100), Duration::from_secs(30))) +} + +#[cfg(test)] +#[path = "../tests/coverage/sidecar_tests.rs"] +mod tests; diff --git a/crates/cli/src/sidecar/health.rs b/crates/cli/src/sidecar/health.rs new file mode 100644 index 000000000..31f7ee2ff --- /dev/null +++ b/crates/cli/src/sidecar/health.rs @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Authenticated health and shutdown transport for loopback sidecars. + +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpStream, ToSocketAddrs}; +use std::thread; +use std::time::Duration; + +use reqwest::Url; +use ring::rand::{SecureRandom, SystemRandom}; +use serde_json::Value; + +use crate::config::BootstrapChallengeKey; + +use super::{BOOTSTRAP_PROTOCOL_VERSION, HEALTHZ_TIMEOUT}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum RelayHealth { + Compatible, + Incompatible, + Foreign, + Unavailable, +} + +pub(crate) fn healthz(url: &str) -> bool { + probe(url, None) == RelayHealth::Compatible +} + +#[cfg(test)] +pub(crate) fn healthz_compatible(url: &str, bootstrap_fingerprint: &str) -> bool { + probe(url, Some(bootstrap_fingerprint)) == RelayHealth::Compatible +} + +pub(super) fn probe_after_lock(url: &str, bootstrap_fingerprint: Option<&str>) -> RelayHealth { + let mut health = probe(url, bootstrap_fingerprint); + for _ in 1..3 { + if health != RelayHealth::Foreign { + break; + } + thread::sleep(Duration::from_millis(50)); + health = probe(url, bootstrap_fingerprint); + } + health +} + +pub(super) fn probe(url: &str, bootstrap_fingerprint: Option<&str>) -> RelayHealth { + let Ok((host, port)) = parse_loopback_url(url) else { + return RelayHealth::Unavailable; + }; + let Ok(addrs) = (host.as_str(), port).to_socket_addrs() else { + return RelayHealth::Unavailable; + }; + let mut stream = None; + for addr in addrs { + match TcpStream::connect_timeout(&addr, HEALTHZ_TIMEOUT) { + Ok(candidate) => { + stream = Some(candidate); + break; + } + Err(_) => continue, + } + } + let Some(mut stream) = stream else { + return RelayHealth::Unavailable; + }; + if stream.set_read_timeout(Some(HEALTHZ_TIMEOUT)).is_err() + || stream.set_write_timeout(Some(HEALTHZ_TIMEOUT)).is_err() + { + return RelayHealth::Foreign; + } + let challenge = bootstrap_fingerprint.map(|fingerprint| { + let key = BootstrapChallengeKey::load().map_err(|_| ())?; + let mut nonce = [0_u8; 32]; + SystemRandom::new().fill(&mut nonce).map_err(|_| ())?; + let nonce = nonce + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Ok::<_, ()>((fingerprint, nonce, key)) + }); + let challenge = match challenge.transpose() { + Ok(challenge) => challenge, + Err(()) => return RelayHealth::Foreign, + }; + let fingerprint_headers = challenge + .as_ref() + .map(|(fingerprint, nonce, _)| { + format!( + "X-NeMo-Relay-Bootstrap-Fingerprint: {fingerprint}\r\nX-NeMo-Relay-Bootstrap-Nonce: {nonce}\r\n" + ) + }) + .unwrap_or_default(); + let request = format!( + "GET /healthz HTTP/1.1\r\nHost: {}\r\n{fingerprint_headers}Connection: close\r\n\r\n", + loopback_authority(&host, port) + ); + if stream.write_all(request.as_bytes()).is_err() { + return RelayHealth::Foreign; + } + let mut response = Vec::new(); + if stream.take(16 * 1024).read_to_end(&mut response).is_err() { + return RelayHealth::Foreign; + } + let Some((headers, body)) = split_http_response(&response) else { + return RelayHealth::Foreign; + }; + let Ok(body) = serde_json::from_slice::(body) else { + return RelayHealth::Foreign; + }; + if body.get("service").and_then(Value::as_str) != Some("nemo-relay") + || body.get("bootstrap_protocol").and_then(Value::as_u64) + != Some(BOOTSTRAP_PROTOCOL_VERSION) + { + return RelayHealth::Foreign; + } + if body.get("version").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION")) + || headers.starts_with(b"HTTP/1.1 409") + || headers.starts_with(b"HTTP/1.0 409") + { + return RelayHealth::Incompatible; + } + if (headers.starts_with(b"HTTP/1.1 200") || headers.starts_with(b"HTTP/1.0 200")) + && body.get("status").and_then(Value::as_str) == Some("ok") + { + if let Some((fingerprint, nonce, key)) = challenge { + let Some(proof) = http_header(headers, "x-nemo-relay-bootstrap-proof") else { + return RelayHealth::Foreign; + }; + if !key.verify(fingerprint, &nonce, proof) { + return RelayHealth::Foreign; + } + } + return RelayHealth::Compatible; + } + RelayHealth::Foreign +} + +pub(super) fn request_shutdown(url: &str, token: &str) -> Result<(), String> { + let (host, port) = parse_loopback_url(url)?; + let address = (host.as_str(), port) + .to_socket_addrs() + .map_err(|error| format!("failed to resolve managed sidecar {url}: {error}"))? + .next() + .ok_or_else(|| format!("managed sidecar {url} has no socket address"))?; + let mut stream = TcpStream::connect_timeout(&address, HEALTHZ_TIMEOUT) + .map_err(|error| format!("failed to connect to managed sidecar {url}: {error}"))?; + stream + .set_read_timeout(Some(HEALTHZ_TIMEOUT)) + .map_err(|error| format!("failed to configure sidecar shutdown read timeout: {error}"))?; + stream + .set_write_timeout(Some(HEALTHZ_TIMEOUT)) + .map_err(|error| format!("failed to configure sidecar shutdown write timeout: {error}"))?; + let request = format!( + "POST /bootstrap/shutdown HTTP/1.1\r\nHost: {}\r\nX-NeMo-Relay-Bootstrap-Token: {token}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + loopback_authority(&host, port) + ); + stream + .write_all(request.as_bytes()) + .map_err(|error| format!("failed to request managed sidecar shutdown: {error}"))?; + let mut response = Vec::new(); + stream + .take(16 * 1024) + .read_to_end(&mut response) + .map_err(|error| format!("failed to read managed sidecar shutdown response: {error}"))?; + let Some((headers, _)) = split_http_response(&response) else { + return Err("managed sidecar returned a malformed shutdown response".into()); + }; + if headers.starts_with(b"HTTP/1.1 204") || headers.starts_with(b"HTTP/1.0 204") { + Ok(()) + } else { + Err(format!( + "managed sidecar rejected shutdown: {}", + String::from_utf8_lossy(headers) + .lines() + .next() + .unwrap_or("unknown response") + )) + } +} + +fn http_header<'a>(headers: &'a [u8], name: &str) -> Option<&'a str> { + headers.split(|byte| *byte == b'\n').find_map(|line| { + let line = std::str::from_utf8(line).ok()?.trim_end_matches('\r'); + let (candidate, value) = line.split_once(':')?; + candidate.eq_ignore_ascii_case(name).then(|| value.trim()) + }) +} + +fn split_http_response(response: &[u8]) -> Option<(&[u8], &[u8])> { + response + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| (&response[..index], &response[index + 4..])) +} + +pub(crate) fn parse_loopback_url(url: &str) -> Result<(String, u16), String> { + let parsed = Url::parse(url) + .map_err(|error| format!("invalid plugin shim loopback URL {url}: {error}"))?; + if parsed.scheme() != "http" { + return Err(format!( + "plugin shim only supports http loopback URLs: {url}" + )); + } + let host = parsed + .host_str() + .ok_or_else(|| format!("missing host in gateway URL: {url}"))? + .trim_start_matches('[') + .trim_end_matches(']'); + let loopback = host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()); + if !loopback { + return Err(format!( + "plugin shim only supports loopback gateway URLs: {url}" + )); + } + let port = parsed + .port() + .ok_or_else(|| format!("missing port in gateway URL: {url}"))?; + Ok((host.to_string(), port)) +} + +pub(crate) fn loopback_bind(url: &str) -> Result { + let (host, port) = parse_loopback_url(url)?; + let address = if host.eq_ignore_ascii_case("localhost") { + std::net::IpAddr::V4(Ipv4Addr::LOCALHOST) + } else { + host.parse::() + .map_err(|error| format!("invalid loopback address in gateway URL {url}: {error}"))? + }; + Ok(SocketAddr::new(address, port)) +} + +pub(crate) fn loopback_authority(host: &str, port: u16) -> String { + if host.contains(':') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +#[cfg(test)] +#[path = "../../tests/coverage/sidecar_health_tests.rs"] +mod tests; diff --git a/crates/cli/src/sidecar/process.rs b/crates/cli/src/sidecar/process.rs new file mode 100644 index 000000000..0f768a9b8 --- /dev/null +++ b/crates/cli/src/sidecar/process.rs @@ -0,0 +1,381 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Cross-platform process detachment and tree cleanup for sidecars. + +#[cfg(windows)] +use std::env; +use std::process::{Child, Command}; +#[cfg(windows)] +use std::sync::OnceLock; + +#[cfg(windows)] +const SIDECAR_JOB_NAME_ENV: &str = "NEMO_RELAY_SIDECAR_JOB_NAME"; +#[cfg(windows)] +static RETAINED_SIDECAR_JOB: OnceLock = OnceLock::new(); + +pub(super) struct DetachedSidecarProcess { + child: Child, + #[cfg(windows)] + job: Option, +} + +impl DetachedSidecarProcess { + pub(super) fn new(child: Child, #[cfg(windows)] prepared_job: Option) -> Self { + #[cfg(windows)] + let job = prepared_job; + Self { + child, + #[cfg(windows)] + job, + } + } + + pub(super) fn id(&self) -> u32 { + self.child.id() + } + + pub(super) fn try_wait(&mut self) -> std::io::Result> { + self.child.try_wait() + } + + pub(super) fn terminate(&mut self) { + #[cfg(windows)] + if let Some(job) = self.job.as_ref() { + job.terminate(); + } + terminate_sidecar_process_tree(&mut self.child); + } + + pub(super) fn terminate_retained_descendants(&self) { + #[cfg(windows)] + if let Some(job) = self.job.as_ref() { + job.terminate(); + } + } +} + +#[cfg(windows)] +pub(crate) struct SidecarJob { + handle: windows_sys::Win32::Foundation::HANDLE, + name: String, +} + +// SAFETY: Job Object handles can be used from any thread, and this wrapper uniquely owns it. +#[cfg(windows)] +unsafe impl Send for SidecarJob {} +// SAFETY: Windows Job Object operations are thread-safe for a live kernel handle. +#[cfg(windows)] +unsafe impl Sync for SidecarJob {} + +#[cfg(windows)] +impl SidecarJob { + pub(crate) fn create() -> Result { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::{ + CreateJobObjectW, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JobObjectExtendedLimitInformation, SetInformationJobObject, + }; + + let name = format!("Local\\NeMoRelaySidecar-{}", uuid::Uuid::now_v7().simple()); + let wide = std::ffi::OsStr::new(&name) + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // SAFETY: Null security attributes select defaults and `wide` is NUL-terminated. + let handle = unsafe { CreateJobObjectW(std::ptr::null(), wide.as_ptr()) }; + if handle.is_null() { + return Err(format!( + "failed to create detached sidecar Job Object: {}", + std::io::Error::last_os_error() + )); + } + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = WINDOWS_JOB_OBJECT_LIMIT_KILL_ON_CLOSE; + // SAFETY: `handle` is live and `limits` is correctly sized for the requested class. + let configured = unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + std::ptr::from_ref(&limits).cast(), + std::mem::size_of::() as u32, + ) + }; + if configured == 0 { + let error = std::io::Error::last_os_error(); + // SAFETY: `handle` was created above and has not been transferred. + unsafe { CloseHandle(handle) }; + return Err(format!( + "failed to configure detached sidecar Job Object cleanup: {error}" + )); + } + Ok(Self { handle, name }) + } + + #[cfg(test)] + pub(crate) fn name(&self) -> &str { + &self.name + } + + pub(super) fn configure_child(&self, command: &mut Command) { + command.env(SIDECAR_JOB_NAME_ENV, &self.name); + } + + #[cfg(test)] + pub(crate) fn assign(&self, child: &Child) -> Result<(), String> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; + + // SAFETY: Both handles are live kernel handles owned by this process. + let assigned = + unsafe { AssignProcessToJobObject(self.handle, child.as_raw_handle().cast()) }; + if assigned == 0 { + return Err(format!( + "failed to assign detached sidecar {} to its Job Object: {}", + child.id(), + std::io::Error::last_os_error() + )); + } + Ok(()) + } + + pub(crate) fn terminate(&self) { + use windows_sys::Win32::System::JobObjects::TerminateJobObject; + + // SAFETY: The retained handle owns the Job Object assigned to this sidecar process tree. + let _ = unsafe { TerminateJobObject(self.handle, 1) }; + } +} + +#[cfg(windows)] +pub(crate) fn join_sidecar_job_from_env() -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::{AssignProcessToJobObject, OpenJobObjectW}; + use windows_sys::Win32::System::SystemServices::{ + JOB_OBJECT_ASSIGN_PROCESS, JOB_OBJECT_TERMINATE, + }; + use windows_sys::Win32::System::Threading::GetCurrentProcess; + + let Some(name) = env::var_os(SIDECAR_JOB_NAME_ENV) else { + return Ok(()); + }; + // SAFETY: Windows environment mutation is synchronized by the operating system. Remove the + // private handoff value before any plugin worker can inherit it. + unsafe { env::remove_var(SIDECAR_JOB_NAME_ENV) }; + let wide = name + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // SAFETY: `wide` is NUL-terminated and requests only assignment/termination rights. + let job = unsafe { + OpenJobObjectW( + JOB_OBJECT_ASSIGN_PROCESS | JOB_OBJECT_TERMINATE, + 0, + wide.as_ptr(), + ) + }; + if job.is_null() { + return Err(format!( + "failed to open detached sidecar Job Object; persistent bootstrap cannot guarantee process-tree cleanup: {}", + std::io::Error::last_os_error() + )); + } + // SAFETY: `job` is live and the pseudo current-process handle is valid. + let assigned = unsafe { AssignProcessToJobObject(job, GetCurrentProcess()) }; + if assigned == 0 { + let error = std::io::Error::last_os_error(); + // SAFETY: `job` was opened above and has not been transferred. + unsafe { CloseHandle(job) }; + return Err(format!( + "failed to join detached sidecar Job Object; the current Windows Job Object may reject nested assignment, so persistent bootstrap cannot guarantee process-tree cleanup: {error}" + )); + } + let retained = SidecarJob { + handle: job, + name: name.to_string_lossy().into_owned(), + }; + RETAINED_SIDECAR_JOB.set(retained).map_err(|_| { + "detached sidecar Job Object was initialized more than once in one process".to_string() + }) +} + +#[cfg(not(windows))] +pub(crate) fn join_sidecar_job_from_env() -> Result<(), String> { + Ok(()) +} + +#[cfg(windows)] +impl Drop for SidecarJob { + fn drop(&mut self) { + use windows_sys::Win32::Foundation::CloseHandle; + + // SAFETY: `handle` is uniquely owned by this wrapper and closed exactly once. + unsafe { CloseHandle(self.handle) }; + } +} + +#[cfg(unix)] +pub(crate) fn terminate_sidecar_process_tree(child: &mut Child) { + let process_group = -(child.id() as i32); + // SAFETY: The detached sidecar calls `setsid` before exec, so its PID is also the process-group + // ID. A negative PID targets that complete group and does not dereference memory. + if unsafe { libc::kill(process_group, libc::SIGKILL) } == -1 { + let _ = child.kill(); + } + let _ = child.wait(); +} + +#[cfg(windows)] +pub(crate) fn terminate_sidecar_process_tree(child: &mut Child) { + let status = Command::new("taskkill") + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .status(); + if !status.is_ok_and(|status| status.success()) { + let _ = child.kill(); + } + let _ = child.wait(); +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn terminate_sidecar_process_tree(child: &mut Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +#[cfg(unix)] +pub(crate) fn configure_detached_sidecar(command: &mut Command) { + use std::os::unix::process::CommandExt; + + // SAFETY: `setsid` is async-signal-safe and has no memory-safety preconditions. It runs in the + // post-fork child before exec so the shared sidecar is outside the MCP client's session and + // process group. + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } +} + +#[cfg(any(test, windows))] +pub(crate) const WINDOWS_CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; +#[cfg(any(test, windows))] +pub(crate) const WINDOWS_CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000; +#[cfg(any(test, windows))] +pub(crate) const WINDOWS_CREATE_NO_WINDOW: u32 = 0x0800_0000; +#[cfg(any(test, windows))] +pub(crate) const WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK: u32 = 0x0000_0800; +#[cfg(any(test, windows))] +pub(crate) const WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK: u32 = 0x0000_1000; +#[cfg(any(test, windows))] +pub(crate) const WINDOWS_JOB_OBJECT_LIMIT_KILL_ON_CLOSE: u32 = 0x0000_2000; + +#[cfg(any(test, windows))] +pub(crate) fn windows_sidecar_creation_flags( + in_job: bool, + job_limit_flags: Option, +) -> (u32, bool) { + let base = WINDOWS_CREATE_NEW_PROCESS_GROUP | WINDOWS_CREATE_NO_WINDOW; + if !in_job { + return (base, false); + } + match job_limit_flags { + Some(flags) if flags & WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK != 0 => { + (base | WINDOWS_CREATE_BREAKAWAY_FROM_JOB, false) + } + Some(flags) if flags & WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK != 0 => (base, false), + Some(_) | None => (base, true), + } +} + +#[cfg(windows)] +fn current_windows_job_limits() -> (bool, Option) { + use windows_sys::Win32::System::JobObjects::{ + IsProcessInJob, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + QueryInformationJobObject, + }; + use windows_sys::Win32::System::Threading::GetCurrentProcess; + + let mut in_job = 0; + // SAFETY: The pseudo current-process handle and null current-job handle are valid for this + // query, and `in_job` points to writable storage for the result. + if unsafe { IsProcessInJob(GetCurrentProcess(), std::ptr::null_mut(), &mut in_job) } == 0 { + return (true, None); + } + if in_job == 0 { + return (false, Some(0)); + } + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + // SAFETY: A null job handle queries the job associated with the current process. The buffer is + // correctly sized and aligned for the requested information class. + let queried = unsafe { + QueryInformationJobObject( + std::ptr::null_mut(), + JobObjectExtendedLimitInformation, + std::ptr::from_mut(&mut limits).cast(), + std::mem::size_of::() as u32, + std::ptr::null_mut(), + ) + }; + if queried == 0 { + (true, None) + } else { + (true, Some(limits.BasicLimitInformation.LimitFlags)) + } +} + +#[cfg(windows)] +pub(crate) fn configure_detached_sidecar(command: &mut Command) { + use std::os::windows::process::CommandExt; + + let (in_job, limits) = current_windows_job_limits(); + let (flags, limited_lifetime) = windows_sidecar_creation_flags(in_job, limits); + if limited_lifetime { + eprintln!( + "warning: the current Windows Job Object does not permit process breakaway; the shared Relay gateway lifetime is limited to the host job" + ); + } + command.creation_flags(flags); +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn configure_detached_sidecar(_command: &mut Command) {} + +#[cfg(test)] +pub(crate) fn terminate_unready_sidecar( + mut child: Child, + pid_path: &std::path::Path, + url: &str, +) -> Result<(), String> { + match child.try_wait() { + Ok(Some(status)) => { + let _ = std::fs::remove_file(pid_path); + return Err(format!( + "nemo-relay sidecar exited before becoming ready at {url}: {status}" + )); + } + Ok(None) => {} + Err(error) => { + let _ = std::fs::remove_file(pid_path); + return Err(format!( + "failed to inspect nemo-relay sidecar process: {error}" + )); + } + } + if let Err(error) = child.kill() { + let _ = std::fs::remove_file(pid_path); + return Err(format!( + "nemo-relay sidecar did not become ready at {url}; failed to terminate startup process: {error}" + )); + } + let _ = child.wait(); + let _ = std::fs::remove_file(pid_path); + Err(format!( + "nemo-relay sidecar did not become ready at {url}; terminated startup process" + )) +} diff --git a/crates/cli/src/sidecar/state.rs b/crates/cli/src/sidecar/state.rs new file mode 100644 index 000000000..0edb27200 --- /dev/null +++ b/crates/cli/src/sidecar/state.rs @@ -0,0 +1,495 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Per-user ownership, locking, and readiness state for managed sidecars. + +use std::env; +use std::fs::{self, OpenOptions}; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use reqwest::Url; +use serde::{Deserialize, Serialize}; + +use crate::config::CodingAgent; +use crate::file_io::{LockAttempt, atomic_write, try_lock_exclusive}; + +use super::health::{RelayHealth, probe, request_shutdown}; +use super::{BOOTSTRAP_PROTOCOL_VERSION, GatewayEndpoint, SIDECAR_LOCK_TIMEOUT}; + +pub(crate) const BOOTSTRAP_AGENT_ENV: &str = "NEMO_RELAY_BOOTSTRAP_AGENT"; +pub(crate) const BOOTSTRAP_STATE_DIR_ENV: &str = "NEMO_RELAY_BOOTSTRAP_STATE_DIR"; + +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct OwnerRecord { + service: String, + version: String, + bootstrap_protocol: u64, + pub(super) pid: u32, + pub(super) url: String, + shutdown_token: String, + bootstrap_fingerprint: Option, +} + +impl OwnerRecord { + fn new(pid: u32, url: &str, shutdown_token: &str, bootstrap_fingerprint: Option<&str>) -> Self { + Self { + service: "nemo-relay".into(), + version: env!("CARGO_PKG_VERSION").into(), + bootstrap_protocol: BOOTSTRAP_PROTOCOL_VERSION, + pid, + url: url.into(), + shutdown_token: shutdown_token.into(), + bootstrap_fingerprint: bootstrap_fingerprint.map(str::to_owned), + } + } + + fn matches( + &self, + pid: u32, + url: &str, + shutdown_token: &str, + bootstrap_fingerprint: Option<&str>, + ) -> bool { + self.service == "nemo-relay" + && self.version == env!("CARGO_PKG_VERSION") + && self.bootstrap_protocol == BOOTSTRAP_PROTOCOL_VERSION + && self.pid == pid + && self.url == url + && self.shutdown_token == shutdown_token + && self.bootstrap_fingerprint.as_deref() == bootstrap_fingerprint + } +} + +#[derive(Debug, Deserialize)] +struct ReadyRecord { + service: String, + version: String, + bootstrap_protocol: u64, + address: String, +} + +pub(super) fn read_owner_record(path: &Path) -> Result, String> { + let raw = match fs::read(path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "failed to read sidecar ownership {}: {error}", + path.display() + )); + } + }; + serde_json::from_slice(&raw) + .map(Some) + .map_err(|error| format!("invalid sidecar ownership file {}: {error}", path.display())) +} + +pub(super) fn read_ready_file(path: &Path) -> Result, String> { + let raw = match fs::read(path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "failed to read sidecar readiness file {}: {error}", + path.display() + )); + } + }; + let record = serde_json::from_slice::(&raw) + .map_err(|error| format!("invalid sidecar readiness file {}: {error}", path.display()))?; + if record.service != "nemo-relay" + || record.version != env!("CARGO_PKG_VERSION") + || record.bootstrap_protocol != BOOTSTRAP_PROTOCOL_VERSION + { + return Err(format!( + "incompatible sidecar readiness file {}", + path.display() + )); + } + let address = record + .address + .parse::() + .map_err(|error| format!("invalid sidecar address in {}: {error}", path.display()))?; + Ok(Some(GatewayEndpoint { + address, + url: format!("http://{address}"), + })) +} + +pub(crate) fn owner_path(runtime: &Path, agent: CodingAgent, url: &str) -> PathBuf { + runtime.join(format!( + "{}-sidecar-{}.owner.json", + agent.as_arg(), + lock_name(url) + )) +} + +pub(crate) fn pid_path(runtime: &Path, agent: CodingAgent, url: &str) -> PathBuf { + runtime.join(format!("{}-sidecar-{}.pid", agent.as_arg(), lock_name(url))) +} + +pub(crate) fn lock_path(runtime: &Path, url: &str) -> PathBuf { + runtime.join(format!("{}-sidecar.lock", lock_name(url))) +} + +pub(crate) fn state_dir() -> Result { + crate::config::user_config_dir() + .map(|path| path.join("bootstrap")) + .ok_or_else(|| { + "cannot determine the per-user NeMo Relay bootstrap state directory; set HOME or USERPROFILE" + .into() + }) +} + +pub(crate) fn lock_endpoint(runtime: &Path, url: &str) -> Result { + lock_endpoint_for(runtime, url, SIDECAR_LOCK_TIMEOUT) +} + +pub(crate) fn lock_endpoint_for( + runtime: &Path, + url: &str, + timeout: Duration, +) -> Result { + let path = lock_path(runtime, url); + let lock = open_lock(&path)?; + let deadline = Instant::now() + timeout; + loop { + match try_lock_exclusive(&lock) { + Ok(LockAttempt::Acquired) => return Ok(lock), + Ok(LockAttempt::Contended) => { + if Instant::now() >= deadline { + return Err(format!( + "timed out waiting for sidecar lock {}", + path.display() + )); + } + thread::sleep(Duration::from_millis(50)); + } + Err(error) => { + return Err(format!( + "failed to acquire sidecar lock {}: {error}", + path.display() + )); + } + } + } +} + +pub(super) fn open_lock(path: &Path) -> Result { + OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(path) + .map_err(|error| format!("failed to open sidecar lock {}: {error}", path.display())) +} + +pub(crate) fn write_owner( + path: &Path, + pid: u32, + url: &str, + shutdown_token: &str, + bootstrap_fingerprint: Option<&str>, +) -> Result<(), String> { + let record = OwnerRecord::new(pid, url, shutdown_token, bootstrap_fingerprint); + let bytes = serde_json::to_vec(&record) + .map_err(|error| format!("failed to encode sidecar ownership: {error}"))?; + atomic_write(path, &bytes) +} + +pub(crate) fn publish_owner_from_env(address: SocketAddr) -> Result<(), String> { + let state = env::var_os(BOOTSTRAP_STATE_DIR_ENV); + let agent = env::var(BOOTSTRAP_AGENT_ENV).ok(); + let token = env::var("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN").ok(); + let bootstrap_fingerprint = env::var(crate::config::BOOTSTRAP_FINGERPRINT_ENV) + .ok() + .filter(|fingerprint| !fingerprint.is_empty()); + if state.is_none() && agent.is_none() && token.is_none() { + return Ok(()); + } + let state = state + .map(PathBuf::from) + .ok_or_else(|| format!("{BOOTSTRAP_STATE_DIR_ENV} is required for managed bootstrap"))?; + if !state.is_absolute() { + return Err(format!( + "{BOOTSTRAP_STATE_DIR_ENV} must be an absolute path, got {}", + state.display() + )); + } + let agent = match agent.as_deref() { + Some("codex") => CodingAgent::Codex, + Some("claude-code") => CodingAgent::ClaudeCode, + Some(other) => return Err(format!("unsupported bootstrap agent {other}")), + None => { + return Err(format!( + "{BOOTSTRAP_AGENT_ENV} is required for managed bootstrap" + )); + } + }; + let token = token.filter(|token| !token.is_empty()).ok_or_else(|| { + "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN is required for managed bootstrap".to_string() + })?; + if !address.ip().is_loopback() { + return Err(format!( + "managed bootstrap ownership requires a loopback address, got {address}" + )); + } + create_private_runtime_dir(&state).map_err(|error| { + format!( + "failed to create bootstrap state directory {}: {error}", + state.display() + ) + })?; + let url = format!("http://{address}"); + let pid = std::process::id(); + let owner_path = owner_path(&state, agent, &url); + let pid_path = pid_path(&state, agent, &url); + atomic_write(&pid_path, pid.to_string().as_bytes())?; + if let Err(error) = write_owner( + &owner_path, + pid, + &url, + &token, + bootstrap_fingerprint.as_deref(), + ) { + let _ = fs::remove_file(&pid_path); + return Err(error); + } + Ok(()) +} + +pub(crate) fn validate_owner( + owner_path: &Path, + pid_path: &Path, + pid: u32, + url: &str, + shutdown_token: &str, + bootstrap_fingerprint: Option<&str>, +) -> Result<(), String> { + let owner = read_owner_record(owner_path)?.ok_or_else(|| { + format!( + "failed to read sidecar ownership {}: file does not exist", + owner_path.display() + ) + })?; + let valid = owner.matches(pid, url, shutdown_token, bootstrap_fingerprint) + && fs::read_to_string(pid_path) + .ok() + .is_some_and(|value| value.trim() == pid.to_string()); + if valid { + Ok(()) + } else { + Err(format!( + "sidecar ownership {} does not match the ready process", + owner_path.display() + )) + } +} + +pub(crate) fn stop_owned(agent: CodingAgent) -> Result<(), String> { + let runtime = state_dir()?; + let mut errors = Vec::new(); + for owner_path in owner_paths(&runtime, agent)? { + if let Err(error) = stop_owned_record(agent, &runtime, &owner_path) { + errors.push(error); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + +pub(crate) fn owner_paths(runtime: &Path, agent: CodingAgent) -> Result, String> { + let entries = match fs::read_dir(runtime) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "failed to enumerate sidecar ownership in {}: {error}", + runtime.display() + )); + } + }; + let prefix = format!("{}-sidecar-", agent.as_arg()); + let legacy = format!("{}-sidecar.owner.json", agent.as_arg()); + let mut paths = entries + .filter_map(Result::ok) + .filter_map(|entry| { + let name = entry.file_name(); + let name = name.to_str()?; + ((name == legacy || (name.starts_with(&prefix) && name.ends_with(".owner.json"))) + && entry.file_type().ok()?.is_file()) + .then(|| entry.path()) + }) + .collect::>(); + paths.sort(); + Ok(paths) +} + +pub(crate) fn stop_owned_record( + agent: CodingAgent, + runtime: &Path, + owner_path: &Path, +) -> Result<(), String> { + let Some(initial_owner) = read_owner_record(owner_path)? else { + return Ok(()); + }; + let _lock = lock_endpoint(runtime, &initial_owner.url)?; + let Some(owner) = read_owner_record(owner_path)? else { + return Ok(()); + }; + let url = owner.url.as_str(); + let token = (!owner.shutdown_token.is_empty()) + .then_some(owner.shutdown_token.as_str()) + .ok_or_else(|| { + format!( + "sidecar ownership {} has no shutdown token", + owner_path.display() + ) + })?; + let bootstrap_fingerprint = owner + .bootstrap_fingerprint + .as_deref() + .filter(|fingerprint| !fingerprint.is_empty()) + .ok_or_else(|| { + format!( + "sidecar ownership {} has no authenticated bootstrap fingerprint", + owner_path.display() + ) + })?; + let legacy = owner_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == format!("{}-sidecar.owner.json", agent.as_arg())); + let pid_path = if legacy { + runtime.join(format!("{}-sidecar.pid", agent.as_arg())) + } else { + pid_path(runtime, agent, url) + }; + match probe(url, Some(bootstrap_fingerprint)) { + RelayHealth::Unavailable => { + let _ = fs::remove_file(owner_path); + let _ = fs::remove_file(&pid_path); + return Ok(()); + } + RelayHealth::Compatible => {} + RelayHealth::Incompatible | RelayHealth::Foreign => { + return Err(format!( + "refusing to stop {url}: sidecar ownership points to a foreign listener" + )); + } + } + request_shutdown(url, token)?; + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if probe(url, Some(bootstrap_fingerprint)) == RelayHealth::Unavailable { + let _ = fs::remove_file(owner_path); + let _ = fs::remove_file(&pid_path); + return Ok(()); + } + thread::sleep(Duration::from_millis(50)); + } + Err(format!( + "timed out waiting for managed sidecar at {url} to stop" + )) +} + +pub(super) fn runtime_dir() -> PathBuf { + runtime_dir_for( + env::var_os("XDG_RUNTIME_DIR"), + env::var_os("TMPDIR"), + env::var_os("TEMP"), + env::temp_dir(), + verified_runtime_user(), + None, + ) +} + +#[cfg(unix)] +fn verified_runtime_user() -> Option { + // SAFETY: `geteuid` has no preconditions and does not dereference pointers. + Some(format!("uid-{}", unsafe { libc::geteuid() }).into()) +} + +#[cfg(not(unix))] +fn verified_runtime_user() -> Option { + env::var_os("USERNAME").or_else(|| env::var_os("USER")) +} + +pub(crate) fn runtime_dir_for( + xdg_runtime_dir: Option, + tmpdir: Option, + temp: Option, + temp_dir: PathBuf, + user: Option, + username: Option, +) -> PathBuf { + if let Some(base) = xdg_runtime_dir { + return PathBuf::from(base).join("nemo-relay-plugin"); + } + PathBuf::from(tmpdir.or(temp).unwrap_or_else(|| temp_dir.into_os_string())) + .join(runtime_user_segment(user, username)) + .join("nemo-relay-plugin") +} + +pub(super) fn create_private_runtime_dir(path: &Path) -> std::io::Result<()> { + fs::create_dir_all(path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +pub(crate) fn lock_name(url: &str) -> String { + let raw = Url::parse(url) + .ok() + .and_then(|parsed| { + let host = parsed.host_str()?; + let port = parsed.port_or_known_default()?; + Some(format!("{host}-{port}")) + }) + .unwrap_or_else(|| url.to_string()); + sanitize_filesystem_segment(&raw) +} + +fn runtime_user_segment( + user: Option, + username: Option, +) -> String { + let raw = user + .or(username) + .and_then(|value| value.into_string().ok()) + .unwrap_or_else(|| "unknown-user".into()); + sanitize_filesystem_segment(&raw) +} + +fn sanitize_filesystem_segment(raw: &str) -> String { + let sanitized: String = raw + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { + character + } else { + '_' + } + }) + .collect(); + if sanitized.is_empty() { + "unknown".into() + } else { + sanitized + } +} + +#[cfg(test)] +#[path = "../../tests/coverage/sidecar_state_tests.rs"] +mod tests; diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 91dfa0b71..575c1c1b3 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -3,13 +3,17 @@ //! CLI-level gateway coverage tests. -use std::io::{Read, Write}; -use std::net::TcpListener; -use std::process::{Command, Stdio}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::process::{Child, ChildStdin, Command, ExitStatus, Output, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; +use std::sync::{Arc, Mutex}; use std::thread; +use std::time::{Duration, Instant}; use base64::Engine; +use ring::hmac; use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; use sha2::{Digest, Sha256}; @@ -161,6 +165,646 @@ fn cli_version_exits_successfully() { assert!(String::from_utf8_lossy(&output.stdout).contains("nemo-relay ")); } +#[test] +fn cli_mcp_help_describes_lifecycle_bound_native_gateway() { + let output = Command::new(gateway_bin()) + .args(["mcp", "--help"]) + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Multiple MCP clients share the gateway")); + assert!(stdout.contains("127.0.0.1:47632")); +} + +#[test] +fn cli_mcp_initializes_and_exits_cleanly_when_stdio_closes() { + let temp = tempfile::tempdir().unwrap(); + let mut child = Command::new(gateway_bin()) + .args(["--bind", "127.0.0.1:0", "mcp"]) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all( + b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\"}}\n", + ) + .unwrap(); + + let output = wait_child_with_output(child); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let response = serde_json::from_slice::(&output.stdout).unwrap(); + assert_eq!(response["id"], serde_json::json!(1)); + assert_eq!( + response["result"]["serverInfo"]["name"], + serde_json::json!("nemo-relay") + ); + let log = std::fs::read_to_string( + find_runtime_file(temp.path(), "codex-sidecar.log") + .expect("Codex sidecar log should exist"), + ) + .unwrap(); + assert!(log.contains("Gateway http://127.0.0.1:")); +} + +#[test] +fn cli_mcp_does_not_launch_gateway_when_stdio_closes_before_request() { + let temp = tempfile::tempdir().unwrap(); + let mut child = Command::new(gateway_bin()) + .args(["mcp"]) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + drop(child.stdin.take()); + + let output = wait_child_with_output(child); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stdout.is_empty()); + assert!(find_runtime_file(temp.path(), "codex-sidecar.log").is_none()); +} + +fn start_mcp_client(temp: &std::path::Path, bind: SocketAddr) -> (Child, ChildStdin) { + start_mcp_client_with_idle_timeout(temp, bind, "1") +} + +fn start_mcp_client_with_idle_timeout( + temp: &std::path::Path, + bind: SocketAddr, + idle_timeout_secs: &str, +) -> (Child, ChildStdin) { + let mut child = Command::new(gateway_bin()) + .args(["--bind", &bind.to_string(), "mcp"]) + .env("HOME", temp) + .env("XDG_CONFIG_HOME", temp.join("xdg")) + .env("XDG_RUNTIME_DIR", temp.join("runtime")) + .env("TMPDIR", temp) + .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", idle_timeout_secs) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + stdin + .write_all( + b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\"}}\n", + ) + .unwrap(); + let (response_tx, response_rx) = mpsc::channel(); + thread::spawn(move || { + let mut response = String::new(); + let result = BufReader::new(stdout) + .read_line(&mut response) + .map(|_| response); + let _ = response_tx.send(result); + }); + let response = match response_rx.recv_timeout(Duration::from_secs(5)) { + Ok(response) => response.unwrap(), + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + panic!("MCP initialization response timed out: {error}"); + } + }; + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["result"]["serverInfo"]["name"], "nemo-relay"); + (child, stdin) +} + +#[test] +fn cli_codex_hook_cold_recovery_uses_the_mcp_persistent_identity() { + let temp = tempfile::tempdir().unwrap(); + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = probe.local_addr().unwrap(); + drop(probe); + let gateway_url = format!("http://{address}"); + let mut hook = Command::new(gateway_bin()) + .args([ + "plugin-shim", + "hook", + "codex", + "--gateway-url", + &gateway_url, + ]) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "10") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + hook.stdin + .take() + .unwrap() + .write_all(b"{\"session_id\":\"cold-hook\",\"hook_event_name\":\"SessionStart\"}") + .unwrap(); + let hook_output = wait_child_with_output(hook); + assert!( + hook_output.status.success(), + "cold hook recovery failed: {}", + String::from_utf8_lossy(&hook_output.stderr) + ); + + let (mut mcp, mcp_stdin) = start_mcp_client_with_idle_timeout(temp.path(), address, "10"); + drop(mcp_stdin); + assert!(wait_child(&mut mcp).success()); + + let owner = wait_for_owned_sidecar(temp.path(), None); + assert_eq!(owner["url"], gateway_url); + stop_owned_sidecar(&owner); + wait_for_port_closed(address); +} + +#[derive(Clone, Copy)] +enum FakeBootstrapProof { + Missing, + Wrong, + Valid, +} + +fn bootstrap_request_header<'a>(request: &'a str, name: &str) -> Option<&'a str> { + request.lines().find_map(|line| { + let (candidate, value) = line.split_once(':')?; + candidate.eq_ignore_ascii_case(name).then(|| value.trim()) + }) +} + +fn fake_bootstrap_proof(key: &[u8], fingerprint: &str, nonce: &str) -> String { + let key = hmac::Key::new(hmac::HMAC_SHA256, key); + let mut context = hmac::Context::with_key(&key); + context.update(b"nemo-relay/bootstrap-health/v1\0"); + context.update(fingerprint.as_bytes()); + context.update(&[0]); + context.update(nonce.as_bytes()); + format!( + "hmac-sha256:{}", + context + .sign() + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) +} + +fn run_fake_bootstrap_listener(proof: FakeBootstrapProof) -> (Output, Vec) { + let temp = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let stopped = Arc::new(AtomicBool::new(false)); + let requests = Arc::new(Mutex::new(Vec::new())); + let server_stopped = stopped.clone(); + let server_requests = requests.clone(); + let key_path = temp + .path() + .join("xdg") + .join("nemo-relay") + .join("bootstrap") + .join("fingerprint-hmac.key"); + let server = thread::spawn(move || { + while !server_stopped.load(Ordering::Relaxed) { + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(5)); + continue; + } + Err(error) => panic!("fake bootstrap listener failed: {error}"), + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let request = read_http_request(&mut stream); + server_requests.lock().unwrap().push(request.clone()); + if request.starts_with("GET /healthz ") { + let fingerprint = + bootstrap_request_header(&request, "x-nemo-relay-bootstrap-fingerprint") + .unwrap(); + let nonce = + bootstrap_request_header(&request, "x-nemo-relay-bootstrap-nonce").unwrap(); + let proof_header = match proof { + FakeBootstrapProof::Missing => String::new(), + FakeBootstrapProof::Wrong => { + "X-NeMo-Relay-Bootstrap-Proof: hmac-sha256:0000000000000000000000000000000000000000000000000000000000000000\r\n".into() + } + FakeBootstrapProof::Valid => { + let key = std::fs::read(&key_path).unwrap(); + format!( + "X-NeMo-Relay-Bootstrap-Proof: {}\r\n", + fake_bootstrap_proof(&key, fingerprint, nonce) + ) + } + }; + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":1}}"#, + env!("CARGO_PKG_VERSION") + ); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\n{proof_header}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + } else { + let body = r#"{"continue":true}"#; + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + } + } + }); + + let mut child = Command::new(gateway_bin()) + .args([ + "plugin-shim", + "hook", + "codex", + "--gateway-url", + &format!("http://{address}"), + ]) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .env("NEMO_RELAY_FAIL_CLOSED", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"{\"session_id\":\"challenge\",\"hook_event_name\":\"SessionStart\"}") + .unwrap(); + let output = wait_child_with_output(child); + stopped.store(true, Ordering::Relaxed); + server.join().unwrap(); + let requests = Arc::try_unwrap(requests).unwrap().into_inner().unwrap(); + (output, requests) +} + +#[test] +fn cli_codex_hook_rejects_compatible_json_without_bootstrap_proof() { + let (output, requests) = run_fake_bootstrap_listener(FakeBootstrapProof::Missing); + assert!(!output.status.success()); + assert!(requests.iter().all(|request| !request.starts_with("POST "))); +} + +#[test] +fn cli_codex_hook_rejects_an_invalid_bootstrap_proof() { + let (output, requests) = run_fake_bootstrap_listener(FakeBootstrapProof::Wrong); + assert!(!output.status.success()); + assert!(requests.iter().all(|request| !request.starts_with("POST "))); +} + +#[test] +fn cli_codex_hook_reuses_a_listener_with_a_valid_bootstrap_proof() { + let (output, requests) = run_fake_bootstrap_listener(FakeBootstrapProof::Valid); + assert!( + output.status.success(), + "authenticated hook failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + requests + .iter() + .any(|request| request.starts_with("POST /hooks/codex ")) + ); +} + +fn run_codex_hook_with_launch_resolution_error( + temp: &std::path::Path, + fail_closed: bool, + payload: &[u8], +) -> Output { + let mut command = Command::new(gateway_bin()); + command + .args([ + "plugin-shim", + "hook", + "codex", + "--gateway-url", + "http://127.0.0.1:1", + ]) + .env("HOME", temp) + .env("XDG_CONFIG_HOME", temp.join("xdg")) + .env("XDG_RUNTIME_DIR", temp.join("runtime")) + .env("TMPDIR", temp) + .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "not-a-number") + .env_remove("NEMO_RELAY_FAIL_CLOSED") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if fail_closed { + command.env("NEMO_RELAY_FAIL_CLOSED", "1"); + } + let mut child = command.spawn().unwrap(); + child.stdin.take().unwrap().write_all(payload).unwrap(); + wait_child_with_output(child) +} + +#[test] +fn cli_codex_hook_launch_resolution_error_respects_forwarding_policy() { + let temp = tempfile::tempdir().unwrap(); + + for fail_closed in [false, true] { + let output = run_codex_hook_with_launch_resolution_error(temp.path(), fail_closed, b"{}"); + assert_eq!(output.status.success(), !fail_closed); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("gateway identity preflight failed")); + assert!(stderr.contains("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS")); + assert!(output.stdout.is_empty()); + } +} + +#[test] +fn cli_codex_hook_launch_resolution_error_retains_default_payload_cap() { + const DEFAULT_HOOK_PAYLOAD_BYTES: usize = 20 * 1024 * 1024; + let temp = tempfile::tempdir().unwrap(); + let payload = vec![b'x'; DEFAULT_HOOK_PAYLOAD_BYTES + 1]; + + for fail_closed in [false, true] { + let output = + run_codex_hook_with_launch_resolution_error(temp.path(), fail_closed, &payload); + + assert_eq!(output.status.success(), !fail_closed); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("hook payload exceeds the 20971520-byte limit")); + assert!(!stderr.contains("gateway identity preflight failed")); + assert!(output.stdout.is_empty()); + } +} + +fn sidecar_address(temp: &std::path::Path) -> SocketAddr { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let log_path = find_runtime_file(temp, "codex-sidecar.log"); + if let Some(log_path) = log_path.as_ref() + && let Ok(log) = std::fs::read_to_string(log_path) + && let Some(address) = log.lines().find_map(|line| { + line.split("Gateway http://") + .nth(1) + .and_then(|value| value.split_whitespace().next()) + .and_then(|value| value.parse().ok()) + }) + { + return address; + } + assert!( + Instant::now() < deadline, + "sidecar address was not logged under {}; log: {:?}", + temp.display(), + log_path.and_then(|path| std::fs::read_to_string(path).ok()) + ); + thread::sleep(Duration::from_millis(20)); + } +} + +fn find_runtime_file(root: &std::path::Path, name: &str) -> Option { + let mut pending = vec![root.to_path_buf()]; + while let Some(directory) = pending.pop() { + let entries = std::fs::read_dir(directory).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.file_name().and_then(|value| value.to_str()) == Some(name) { + return Some(path); + } + if path.is_dir() { + pending.push(path); + } + } + } + None +} + +fn find_runtime_files_matching( + root: &std::path::Path, + prefix: &str, + suffix: &str, +) -> Vec { + let mut matches = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(directory) = pending.pop() { + let Ok(entries) = std::fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + pending.push(path); + continue; + } + if path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|name| name.starts_with(prefix) && name.ends_with(suffix)) + { + matches.push(path); + } + } + } + matches +} + +fn wait_child(child: &mut Child) -> ExitStatus { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = child.try_wait().unwrap() { + return status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + let mut stderr = String::new(); + if let Some(mut child_stderr) = child.stderr.take() { + let _ = child_stderr.read_to_string(&mut stderr); + } + panic!("child process did not exit within 10 seconds; stderr: {stderr}"); + } + thread::sleep(Duration::from_millis(20)); + } +} + +fn wait_child_with_output(mut child: Child) -> Output { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if child.try_wait().unwrap().is_some() { + return child.wait_with_output().unwrap(); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let output = child.wait_with_output().unwrap(); + panic!( + "child process did not exit within 10 seconds; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + thread::sleep(Duration::from_millis(20)); + } +} + +fn wait_for_port_closed(address: SocketAddr) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if TcpStream::connect_timeout(&address, Duration::from_millis(100)).is_err() { + return; + } + assert!( + Instant::now() < deadline, + "shared gateway remained bound after the final MCP client and idle timeout" + ); + thread::sleep(Duration::from_millis(20)); + } +} + +fn wait_for_owned_sidecar(temp: &std::path::Path, previous_pid: Option) -> serde_json::Value { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + for path in find_runtime_files_matching(temp, "codex-sidecar", ".owner.json") { + if let Ok(raw) = std::fs::read(path) + && let Ok(owner) = serde_json::from_slice::(&raw) + && owner["pid"] + .as_u64() + .is_some_and(|pid| Some(pid) != previous_pid) + { + return owner; + } + } + assert!( + Instant::now() < deadline, + "owned Codex sidecar was not published under {}", + temp.display() + ); + thread::sleep(Duration::from_millis(20)); + } +} + +fn stop_owned_sidecar(owner: &serde_json::Value) { + let address = owner["url"] + .as_str() + .unwrap() + .strip_prefix("http://") + .unwrap() + .parse::() + .unwrap(); + let token = owner["shutdown_token"].as_str().unwrap(); + let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(2)).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream + .write_all( + format!( + "POST /bootstrap/shutdown HTTP/1.1\r\nHost: {address}\r\nX-NeMo-Relay-Bootstrap-Token: {token}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .as_bytes(), + ) + .unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + assert!(response.starts_with("HTTP/1.1 204"), "{response}"); +} + +fn relay_health(address: SocketAddr) -> serde_json::Value { + let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(2)).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream + .write_all( + format!("GET /healthz HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n") + .as_bytes(), + ) + .unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + serde_json::from_str(response.split("\r\n\r\n").nth(1).unwrap()).unwrap() +} + +#[test] +fn cli_mcp_clients_share_gateway_until_final_idle_shutdown() { + let temp = tempfile::tempdir().unwrap(); + let (mut first, first_stdin) = start_mcp_client(temp.path(), "127.0.0.1:0".parse().unwrap()); + let address = sidecar_address(temp.path()); + let (mut second, second_stdin) = start_mcp_client(temp.path(), address); + + drop(first_stdin); + assert!(wait_child(&mut first).success()); + let health = relay_health(address); + assert_eq!(health["service"], "nemo-relay"); + assert_eq!(health["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(health["bootstrap_protocol"], 1); + + drop(second_stdin); + assert!(wait_child(&mut second).success()); + wait_for_port_closed(address); +} + +#[test] +fn cli_mcp_restarts_one_stopped_gateway_then_fails_after_the_second_stop() { + let temp = tempfile::tempdir().unwrap(); + let (mut client, _stdin) = start_mcp_client(temp.path(), "127.0.0.1:0".parse().unwrap()); + let first = wait_for_owned_sidecar(temp.path(), None); + let first_pid = first["pid"].as_u64().unwrap(); + + stop_owned_sidecar(&first); + let second = wait_for_owned_sidecar(temp.path(), Some(first_pid)); + assert_ne!(second["pid"], first["pid"]); + + stop_owned_sidecar(&second); + let status = wait_child(&mut client); + assert!( + !status.success(), + "MCP client unexpectedly restarted the shared gateway twice" + ); +} + #[test] fn cli_agents_json_emits_supported_agent_shapes() { let temp = tempfile::tempdir().unwrap(); @@ -1272,7 +1916,7 @@ fn cli_install_dry_run_plans_local_codex_marketplace() { "stdout was:\n{stdout}" ); assert!( - stdout.contains("configure Codex provider and hook-supervised lazy startup"), + stdout.contains("configure Codex provider and trust plugin-owned hooks"), "stdout was:\n{stdout}" ); } diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index d1a3fc3d3..d56bfdc84 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -25,6 +25,10 @@ struct PluginConfigDiscoveryScope { _guard: MutexGuard<'static, ()>, previous_cwd: PathBuf, previous_xdg_config_home: Option, + previous_config_scope: Option, + previous_openai_api_key: Option, + previous_bootstrap_fingerprint: Option, + previous_plugin_idle_timeout: Option, } impl PluginConfigDiscoveryScope { @@ -34,14 +38,40 @@ impl PluginConfigDiscoveryScope { .unwrap_or_else(|error| error.into_inner()); let previous_cwd = std::env::current_dir().unwrap(); let previous_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME"); + let previous_config_scope = std::env::var_os("NEMO_RELAY_CONFIG_SCOPE"); + let previous_openai_api_key = std::env::var_os("OPENAI_API_KEY"); + let previous_bootstrap_fingerprint = std::env::var_os(BOOTSTRAP_FINGERPRINT_ENV); + let previous_plugin_idle_timeout = std::env::var_os(PLUGIN_IDLE_TIMEOUT_ENV); unsafe { std::env::set_var("XDG_CONFIG_HOME", xdg_config_home); + std::env::remove_var("NEMO_RELAY_CONFIG_SCOPE"); + std::env::remove_var("OPENAI_API_KEY"); + std::env::remove_var(BOOTSTRAP_FINGERPRINT_ENV); + std::env::remove_var(PLUGIN_IDLE_TIMEOUT_ENV); } std::env::set_current_dir(cwd).unwrap(); Self { _guard: guard, previous_cwd, previous_xdg_config_home, + previous_config_scope, + previous_openai_api_key, + previous_bootstrap_fingerprint, + previous_plugin_idle_timeout, + } + } + + fn enable_user_scope(&self) { + // SAFETY: This scope holds the process-wide environment mutex. + unsafe { + std::env::set_var("NEMO_RELAY_CONFIG_SCOPE", "user"); + } + } + + fn set_bootstrap_fingerprint(&self, fingerprint: &str) { + // SAFETY: This scope holds the process-wide environment mutex. + unsafe { + std::env::set_var(BOOTSTRAP_FINGERPRINT_ENV, fingerprint); } } } @@ -54,6 +84,22 @@ impl Drop for PluginConfigDiscoveryScope { Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), None => std::env::remove_var("XDG_CONFIG_HOME"), } + match self.previous_config_scope.take() { + Some(value) => std::env::set_var("NEMO_RELAY_CONFIG_SCOPE", value), + None => std::env::remove_var("NEMO_RELAY_CONFIG_SCOPE"), + } + match self.previous_openai_api_key.take() { + Some(value) => std::env::set_var("OPENAI_API_KEY", value), + None => std::env::remove_var("OPENAI_API_KEY"), + } + match self.previous_bootstrap_fingerprint.take() { + Some(value) => std::env::set_var(BOOTSTRAP_FINGERPRINT_ENV, value), + None => std::env::remove_var(BOOTSTRAP_FINGERPRINT_ENV), + } + match self.previous_plugin_idle_timeout.take() { + Some(value) => std::env::set_var(PLUGIN_IDLE_TIMEOUT_ENV, value), + None => std::env::remove_var(PLUGIN_IDLE_TIMEOUT_ENV), + } } } } @@ -113,6 +159,27 @@ fn isolated_config_path(temp: &tempfile::TempDir) -> std::path::PathBuf { temp.path().join("config.toml") } +fn write_attested_python_environment(path: &std::path::Path, manifest_path: &std::path::Path) { + let interpreter = if cfg!(windows) { + path.join("Scripts/python.exe") + } else { + path.join("bin/python") + }; + std::fs::create_dir_all(interpreter.parent().unwrap()).unwrap(); + std::fs::write(interpreter, b"fixture interpreter").unwrap(); + let installed = path.join("site-packages/fixture.py"); + std::fs::create_dir_all(installed.parent().unwrap()).unwrap(); + std::fs::write(installed, b"fixture = True\n").unwrap(); + let (manifest, _) = DynamicPluginManifest::load_from_path(manifest_path).unwrap(); + let source_artifact_sha256 = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.sha256.as_deref()) + .unwrap(); + crate::plugins::lifecycle::attest_test_python_environment(path, source_artifact_sha256) + .unwrap(); +} + fn write_dynamic_manifest(dir: &std::path::Path, plugin_id: &str) -> std::path::PathBuf { write_dynamic_manifest_with_options(dir, plugin_id, &["plugin_worker"], None) } @@ -162,6 +229,7 @@ enabled = false items = [{capabilities}] [source] +manifest_root = "." artifact = "plugin.py" [integrity] @@ -170,7 +238,7 @@ sha256 = "{digest}" [load] runtime = "python" -entrypoint = "{plugin_id}.plugin:register" +entrypoint = "plugin:register" "#, capabilities = capabilities, signature_line = signature_line, @@ -597,6 +665,35 @@ fn plugins_toml_path_resolution_tracks_config_scope() { ); } +#[test] +fn persistent_user_scope_excludes_project_gateway_and_plugin_layers() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("workspace"); + let nested = project.join("nested"); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(project.join(".nemo-relay")).unwrap(); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(project.join(".nemo-relay/config.toml"), "").unwrap(); + std::fs::write(project.join(".nemo-relay/plugins.toml"), "version = 1\n").unwrap(); + let scope = PluginConfigDiscoveryScope::enter(&nested, &xdg); + scope.enable_user_scope(); + + assert_eq!( + config_paths(None), + vec![ + PathBuf::from("/etc/nemo-relay/config.toml"), + xdg.join("nemo-relay/config.toml"), + ] + ); + assert_eq!( + plugin_config_paths(None, None), + vec![ + PathBuf::from("/etc/nemo-relay/plugins.toml"), + xdg.join("nemo-relay/plugins.toml"), + ] + ); +} + #[test] fn discovered_plugins_toml_upserts_components_by_kind() { let temp = tempfile::tempdir().unwrap(); @@ -1425,6 +1522,9 @@ openai_base_url = "http://file-openai" #[test] fn server_resolution_applies_all_server_overrides() { let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&xdg).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); let config_path = isolated_config_path(&temp); std::fs::write(&config_path, "").unwrap(); let args = ServerArgs { @@ -1433,6 +1533,7 @@ fn server_resolution_applies_all_server_overrides() { openai_base_url: Some("http://cli-openai".into()), anthropic_base_url: Some("http://cli-anthropic".into()), plugin_config_path: None, + ready_file: None, max_hook_payload_bytes: Some(222), max_passthrough_body_bytes: Some(333), }; @@ -1445,9 +1546,526 @@ fn server_resolution_applies_all_server_overrides() { assert_eq!(resolved.gateway.max_hook_payload_bytes, 222); assert_eq!(resolved.gateway.max_passthrough_body_bytes, 333); assert_eq!(resolved.gateway.plugin_config, None); + assert_eq!(resolved.bootstrap_fingerprint, None); + assert!( + !xdg.join("nemo-relay/bootstrap/fingerprint-hmac.key") + .exists() + ); assert!(args.requested_daemon_mode()); } +#[test] +fn ordinary_server_ignores_managed_bootstrap_fingerprint_environment() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&xdg).unwrap(); + let scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + scope.set_bootstrap_fingerprint("opaque-parent-fingerprint"); + let config_path = isolated_config_path(&temp); + std::fs::write(&config_path, "").unwrap(); + + let args = ServerArgs { + config: Some(config_path), + bind: Some("127.0.0.1:0".parse().unwrap()), + ..ServerArgs::default() + }; + let resolved = resolve_server_config(&args).unwrap(); + + assert_eq!(resolved.bootstrap_fingerprint, None); + assert!( + managed_bootstrap_identity(&args, &resolved, &[]) + .unwrap() + .is_none() + ); + assert!( + !xdg.join("nemo-relay/bootstrap/fingerprint-hmac.key") + .exists() + ); +} + +#[test] +fn managed_bootstrap_environment_is_not_forwarded_from_codex() { + let names = crate::mcp_environment::forwarded_names( + [ + "NEMO_RELAY_BOOTSTRAP_AGENT".to_string(), + "NEMO_RELAY_BOOTSTRAP_FINGERPRINT".to_string(), + "NEMO_RELAY_BOOTSTRAP_STATE_DIR".to_string(), + "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN".to_string(), + ], + None, + ); + + assert!(!names.iter().any(|name| name.contains("BOOTSTRAP"))); +} + +#[test] +fn persistent_server_resolution_excludes_project_config_and_fingerprints_credentials() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(project.join(".nemo-relay")).unwrap(); + std::fs::create_dir_all(&xdg).unwrap(); + std::fs::write( + project.join(".nemo-relay/config.toml"), + "[upstream]\nopenai_base_url = \"http://project-only\"\n", + ) + .unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(&project, &xdg); + let args = ServerArgs { + bind: Some("127.0.0.1:47632".parse().unwrap()), + ..ServerArgs::default() + }; + + unsafe { std::env::set_var("OPENAI_API_KEY", "credential-one") }; + let first = resolve_persistent_server_config(&args).unwrap(); + assert_ne!(first.gateway.openai_base_url, "http://project-only"); + assert!( + first + .bootstrap_fingerprint + .as_deref() + .unwrap() + .starts_with("hmac-sha256:") + ); + let key_path = xdg + .join("nemo-relay") + .join("bootstrap") + .join("fingerprint-hmac.key"); + assert_eq!(std::fs::metadata(&key_path).unwrap().len(), 32); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + unsafe { std::env::set_var("OPENAI_API_KEY", "credential-two") }; + let second = resolve_persistent_server_config(&args).unwrap(); + assert_ne!(first.bootstrap_fingerprint, second.bootstrap_fingerprint); +} + +#[test] +fn managed_bootstrap_canonicalizes_unset_and_zero_padded_default_idle_timeout() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&xdg).unwrap(); + let scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + let parent = resolve_persistent_server_config(&ServerArgs::default()).unwrap(); + let expected = parent.bootstrap_fingerprint.unwrap(); + scope.set_bootstrap_fingerprint(&expected); + unsafe { + std::env::set_var(PLUGIN_IDLE_TIMEOUT_ENV, "0300"); + } + let child_args = ServerArgs { + ready_file: Some(temp.path().join("managed.ready.json")), + ..ServerArgs::default() + }; + let child = resolve_server_config(&child_args).unwrap(); + let active = active_dynamic_plugin_components(None, &child).unwrap(); + let identity = managed_bootstrap_identity(&child_args, &child, &active) + .unwrap() + .unwrap(); + + assert_eq!(identity.fingerprint(), expected); + identity.verify_current().unwrap(); +} + +#[test] +fn codex_launch_carries_effective_hook_limit_below_and_above_default() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let user_config = xdg.join("nemo-relay/config.toml"); + std::fs::create_dir_all(user_config.parent().unwrap()).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + let bind = "127.0.0.1:47632".parse().unwrap(); + + for limit in [1024, DEFAULT_MAX_HOOK_PAYLOAD_BYTES + 4096] { + std::fs::write( + &user_config, + format!("[gateway]\nmax_hook_payload_bytes = {limit}\n"), + ) + .unwrap(); + let launch = crate::sidecar::resolve_codex_gateway(&ServerArgs::default(), bind).unwrap(); + assert_eq!(launch.max_hook_payload_bytes, limit); + } +} + +#[test] +fn bootstrap_hmac_key_creation_is_concurrency_safe_and_stable() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state/fingerprint-hmac.key"); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(8)); + let handles = (0..8) + .map(|_| { + let path = path.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + load_or_create_bootstrap_hmac_key_at(&path).unwrap() + }) + }) + .collect::>(); + let keys = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect::>(); + + assert!(keys.windows(2).all(|pair| pair[0] == pair[1])); + assert_eq!(std::fs::metadata(path).unwrap().len(), 32); +} + +#[test] +fn bootstrap_hmac_key_lock_wait_is_bounded_under_synchronized_contention() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state/fingerprint-hmac.key"); + load_or_create_bootstrap_hmac_key_at(&path).unwrap(); + let owner = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + fs2::FileExt::lock_exclusive(&owner).unwrap(); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let waiter = { + let barrier = barrier.clone(); + let path = path.clone(); + std::thread::spawn(move || { + barrier.wait(); + load_or_create_bootstrap_hmac_key_at_with_timeout(&path, Duration::from_millis(75)) + .unwrap_err() + }) + }; + barrier.wait(); + + let error = waiter.join().unwrap(); + + assert!(error.to_string().contains("timed out waiting")); + drop(owner); +} + +#[test] +fn persistent_fingerprint_tracks_active_dynamic_plugin_and_file_identity() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let plugin_dir = temp.path().join("plugin"); + std::fs::create_dir_all(&xdg).unwrap(); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.bootstrap-identity"); + let environment_a = temp.path().join("managed/environment-a"); + let environment_b = temp.path().join("managed/environment-b"); + write_attested_python_environment(&environment_a, &manifest_path); + write_attested_python_environment(&environment_b, &manifest_path); + let resolved = ResolvedConfig { + gateway: config(), + ..ResolvedConfig::default() + }; + let active = ActiveDynamicPluginComponent { + plugin_id: "acme.bootstrap-identity".into(), + kind: DynamicPluginKind::Worker, + lifecycle_generation: 7, + manifest_ref: Some(manifest_path.to_string_lossy().into_owned()), + environment_ref: Some(environment_a.to_string_lossy().into_owned()), + config: Map::new(), + activation_snapshot: None, + }; + + let inactive = persistent_bootstrap_fingerprint(&resolved, &[]).unwrap(); + let enabled = + persistent_bootstrap_fingerprint(&resolved, std::slice::from_ref(&active)).unwrap(); + assert_ne!( + inactive, enabled, + "enable/disable or tombstone must conflict" + ); + + std::fs::write(plugin_dir.join("plugin.py"), b"changed artifact").unwrap(); + let artifact_changed = + persistent_bootstrap_fingerprint(&resolved, std::slice::from_ref(&active)).unwrap(); + assert_ne!(enabled, artifact_changed); + + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + std::fs::write( + &manifest_path, + manifest.replace( + "id = \"acme.bootstrap-identity\"", + "id = \"acme.bootstrap-identity\"\nname = \"changed manifest\"", + ), + ) + .unwrap(); + let manifest_changed = + persistent_bootstrap_fingerprint(&resolved, std::slice::from_ref(&active)).unwrap(); + assert_ne!(artifact_changed, manifest_changed); + + let mut rebuilt_environment = active.clone(); + rebuilt_environment.lifecycle_generation += 1; + let rebuilt_environment_fingerprint = + persistent_bootstrap_fingerprint(&resolved, &[rebuilt_environment]).unwrap(); + assert_ne!( + manifest_changed, rebuilt_environment_fingerprint, + "a same-path managed environment rebuild must conflict through lifecycle generation" + ); + + let mut environment_changed = active; + environment_changed.environment_ref = Some(environment_b.to_string_lossy().into_owned()); + let environment_changed = + persistent_bootstrap_fingerprint(&resolved, &[environment_changed]).unwrap(); + assert_ne!(manifest_changed, environment_changed); +} + +#[test] +fn persistent_hook_identity_authenticates_python_marker_without_rehashing_environment() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let xdg = temp.path().join("xdg"); + let user_config = xdg.join("nemo-relay"); + let plugin_dir = temp.path().join("python-plugin"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&user_config).unwrap(); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(&project, &xdg); + let plugin_id = "acme.read-only-hook-identity"; + let manifest_path = write_dynamic_manifest(&plugin_dir, plugin_id); + let plugins_toml = user_config.join("plugins.toml"); + std::fs::write( + &plugins_toml, + format!( + "version = 1\n\n[[plugins.dynamic]]\nmanifest = {:?}\n", + manifest_path.to_string_lossy() + ), + ) + .unwrap(); + let environment_name = Sha256::digest(plugin_id.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let environment = user_config + .join(".dynamic-plugin-environments") + .join(environment_name); + write_attested_python_environment(&environment, &manifest_path); + let (manifest, manifest_ref) = DynamicPluginManifest::load_from_path(&manifest_path).unwrap(); + let mut record = manifest.into_record(Some(manifest_ref)).unwrap(); + record.spec.enabled = true; + record.source.environment_ref = Some(environment.to_string_lossy().into_owned()); + std::fs::write( + user_config.join(".dynamic-plugins.json"), + serde_json::to_vec_pretty(&json!({ + "schema_version": 1, + "records": [record], + })) + .unwrap(), + ) + .unwrap(); + + crate::plugins::lifecycle::reset_test_python_environment_digest_calls(); + let before = resolve_persistent_server_config(&ServerArgs::default()).unwrap(); + assert_eq!( + crate::plugins::lifecycle::test_python_environment_digest_calls(), + 0, + "persistent hook identity must trust only the authenticated environment marker" + ); + + std::fs::write( + environment.join("site-packages/fixture.py"), + b"fixture = 'mutated'\n", + ) + .unwrap(); + let after = resolve_persistent_server_config(&ServerArgs::default()).unwrap(); + assert_eq!( + crate::plugins::lifecycle::test_python_environment_digest_calls(), + 0, + "mutating environment content must not make hook preflight traverse it" + ); + assert_eq!(before.bootstrap_fingerprint, after.bootstrap_fingerprint); + + let resolved = load_shared_config_scoped(None, None, true).unwrap(); + let error = active_dynamic_plugin_components(None, &resolved) + .unwrap_err() + .to_string(); + assert!(error.contains("changed after provisioning"), "{error}"); + assert!( + crate::plugins::lifecycle::test_python_environment_digest_calls() > 0, + "sidecar activation must still perform the full environment verification" + ); +} + +#[test] +fn managed_server_rejects_config_and_artifact_changes_after_parent_resolution() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let plugin_dir = temp.path().join("plugin"); + std::fs::create_dir_all(&xdg).unwrap(); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.bootstrap-race"); + let environment = temp.path().join("managed/environment"); + write_attested_python_environment(&environment, &manifest_path); + let resolved = ResolvedConfig { + gateway: config(), + ..ResolvedConfig::default() + }; + let active = ActiveDynamicPluginComponent { + plugin_id: "acme.bootstrap-race".into(), + kind: DynamicPluginKind::Worker, + lifecycle_generation: 3, + manifest_ref: Some(manifest_path.to_string_lossy().into_owned()), + environment_ref: Some(environment.to_string_lossy().into_owned()), + config: Map::new(), + activation_snapshot: None, + }; + let expected = + persistent_bootstrap_fingerprint(&resolved, std::slice::from_ref(&active)).unwrap(); + scope.set_bootstrap_fingerprint(&expected); + let args = ServerArgs { + ready_file: Some(temp.path().join("managed.ready.json")), + ..ServerArgs::default() + }; + + let identity = managed_bootstrap_identity(&args, &resolved, std::slice::from_ref(&active)) + .unwrap() + .unwrap(); + assert_eq!(identity.fingerprint(), expected); + + let mut changed_config = resolved.clone(); + changed_config.gateway.openai_base_url = "https://changed.invalid/v1".into(); + let config_error = + managed_bootstrap_identity(&args, &changed_config, std::slice::from_ref(&active)) + .unwrap_err(); + assert!(config_error.to_string().contains("identity changed")); + + std::fs::write(plugin_dir.join("plugin.py"), b"changed during bootstrap").unwrap(); + let artifact_error = identity.verify_current().unwrap_err(); + assert!(artifact_error.to_string().contains("identity changed")); +} + +#[test] +fn bootstrap_file_digest_streams_across_internal_buffer_boundaries() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("large-artifact.bin"); + let bytes = (0..(128 * 1024 + 17)) + .map(|index| (index % 251) as u8) + .collect::>(); + std::fs::write(&path, &bytes).unwrap(); + let expected = Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + + assert_eq!( + bootstrap_file_digest(&path, "test artifact").unwrap(), + expected + ); +} + +#[test] +fn bootstrap_file_digest_rejects_non_regular_and_oversized_inputs() { + let temp = tempfile::tempdir().unwrap(); + let non_regular = bootstrap_file_digest(temp.path(), "test artifact").unwrap_err(); + assert!(non_regular.to_string().contains("must be a regular file")); + + let oversized = temp.path().join("oversized-artifact.bin"); + let file = std::fs::File::create(&oversized).unwrap(); + file.set_len(MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1).unwrap(); + let oversized = bootstrap_file_digest(&oversized, "test artifact").unwrap_err(); + assert!(oversized.to_string().contains("identity budget")); +} + +#[test] +fn persistent_server_resolution_rejects_oversized_sparse_dynamic_plugin_manifest() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let user_config_dir = xdg.join("nemo-relay"); + let plugin_dir = user_config_dir.join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.worker"); + std::fs::write( + user_config_dir.join("plugins.toml"), + r#" +[[plugins.dynamic]] +manifest = "plugins/acme/relay-plugin.toml" +"#, + ) + .unwrap(); + std::fs::File::options() + .write(true) + .open(&manifest_path) + .unwrap() + .set_len(MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1) + .unwrap(); + + let error = resolve_persistent_server_config(&ServerArgs::default()) + .unwrap_err() + .to_string(); + + assert!(error.contains("dynamic plugin manifest")); + assert!(error.contains("identity budget")); +} + +#[test] +fn persistent_server_resolution_rejects_oversized_sparse_dynamic_plugin_artifact() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let user_config_dir = xdg.join("nemo-relay"); + let plugin_dir = user_config_dir.join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + write_dynamic_manifest(&plugin_dir, "acme.worker"); + let plugins_toml_path = user_config_dir.join("plugins.toml"); + std::fs::write( + &plugins_toml_path, + r#" +[[plugins.dynamic]] +manifest = "plugins/acme/relay-plugin.toml" + +[plugins.policy.defaults] +startup = "required" +"#, + ) + .unwrap(); + write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); + std::fs::File::options() + .write(true) + .open(plugin_dir.join("plugin.py")) + .unwrap() + .set_len(MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1) + .unwrap(); + + let error = resolve_persistent_server_config(&ServerArgs::default()) + .unwrap_err() + .to_string(); + + assert!(error.contains("dynamic plugin artifact")); + assert!(error.contains("identity budget")); +} + +#[test] +fn bootstrap_hmac_key_rejects_corrupt_persistent_state() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state/fingerprint-hmac.key"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"short").unwrap(); + + let error = load_or_create_bootstrap_hmac_key_at(&path).unwrap_err(); + + assert!(error.to_string().contains("invalid length 5")); +} + +#[test] +fn persistent_server_resolution_rejects_project_specific_flags() { + let args = ServerArgs { + config: Some(PathBuf::from("project-config.toml")), + ..ServerArgs::default() + }; + + assert!( + resolve_persistent_server_config(&args) + .unwrap_err() + .to_string() + .contains("nemo-relay run") + ); +} + #[test] fn server_resolution_fails_when_required_enabled_dynamic_plugin_is_blocked_by_policy() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/file_io_tests.rs b/crates/cli/tests/coverage/file_io_tests.rs new file mode 100644 index 000000000..47fa6c93b --- /dev/null +++ b/crates/cli/tests/coverage/file_io_tests.rs @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fs::OpenOptions; + +use tempfile::tempdir; + +use super::*; + +#[test] +fn lock_attempts_distinguish_contention_from_errors() { + let directory = tempdir().unwrap(); + let path = directory.path().join("advisory.lock"); + let owner = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&path) + .unwrap(); + let waiter = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + + assert_eq!(try_lock_exclusive(&owner).unwrap(), LockAttempt::Acquired); + assert_eq!(try_lock_exclusive(&waiter).unwrap(), LockAttempt::Contended); + assert_eq!(try_lock_shared(&waiter).unwrap(), LockAttempt::Contended); + + fs2::FileExt::unlock(&owner).unwrap(); + assert_eq!(try_lock_shared(&waiter).unwrap(), LockAttempt::Acquired); + fs2::FileExt::unlock(&waiter).unwrap(); +} + +#[cfg(windows)] +#[test] +fn windows_lock_violation_is_normalized_as_contention() { + let error = std::io::Error::from_raw_os_error( + windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION as i32, + ); + + assert_eq!( + normalize_lock_attempt(Err(error)).unwrap(), + LockAttempt::Contended + ); +} diff --git a/crates/cli/tests/coverage/gateway_tests.rs b/crates/cli/tests/coverage/gateway_tests.rs index 17d55f8ca..c81871457 100644 --- a/crates/cli/tests/coverage/gateway_tests.rs +++ b/crates/cli/tests/coverage/gateway_tests.rs @@ -745,9 +745,12 @@ async fn passthrough_rejects_unsupported_provider_path_directly() { }; let state = AppState { config: config.clone(), + bootstrap_fingerprint: None, + bootstrap_challenge_key: None, http: test_http_client(), sessions: SessionManager::new(config), last_activity: std::sync::Arc::new(std::sync::Mutex::new(std::time::Instant::now())), + bootstrap_shutdown: None, }; let request = Request::builder() .method(Method::POST) @@ -774,9 +777,12 @@ async fn models_rejects_non_get_requests_directly() { }; let state = AppState { config: config.clone(), + bootstrap_fingerprint: None, + bootstrap_challenge_key: None, http: test_http_client(), sessions: SessionManager::new(config), last_activity: std::sync::Arc::new(std::sync::Mutex::new(std::time::Instant::now())), + bootstrap_shutdown: None, }; let request = Request::builder() .method(Method::POST) diff --git a/crates/cli/tests/coverage/install_generation_tests.rs b/crates/cli/tests/coverage/install_generation_tests.rs new file mode 100644 index 000000000..257c883c7 --- /dev/null +++ b/crates/cli/tests/coverage/install_generation_tests.rs @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fs::{File, OpenOptions}; + +use tempfile::tempdir; + +use super::*; + +#[test] +fn generation_markers_have_one_canonical_encoding() { + let active = GenerationMarker::Active("generation-a".into()); + let retired = GenerationMarker::Retired("generation-a".into()); + + assert_eq!(active.token(), "generation-a"); + assert_eq!(retired.token(), "generation-a"); + assert_eq!(active.encoded(), "generation-a\n"); + assert_eq!(retired.encoded(), "retired:generation-a\n"); + assert!(!active.is_retired()); + assert!(retired.is_retired()); +} + +#[test] +fn retirement_without_invalidation_only_releases_the_lock() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let before = std::fs::read(&path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + + retirement.restore_after_rollback().unwrap(); + + assert!(retirement.lock.is_none()); + assert!(!retirement.changed); + assert_eq!(std::fs::read(&path).unwrap(), before); +} + +#[test] +fn rollback_can_restore_with_the_original_lock_still_held() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + std::fs::write(&path, "retired:generation-a\n").unwrap(); + let lock = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + let mut retirement = GenerationRetirement { + lock: Some(lock), + path: path.clone(), + original: GenerationMarker::Active("generation-a".into()), + changed: true, + }; + + retirement.restore_after_rollback().unwrap(); + + assert_eq!(std::fs::read_to_string(path).unwrap(), "generation-a\n"); + assert!(!retirement.changed); +} + +#[test] +fn invalidation_requires_a_live_exclusive_lock() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + let mut retirement = GenerationRetirement { + lock: None, + path: path.clone(), + original: GenerationMarker::Active("generation-a".into()), + changed: false, + }; + + let error = retirement.invalidate_for_replacement().unwrap_err(); + + assert!(error.contains("is not locked"), "{error}"); + assert!(error.contains(&path.display().to_string()), "{error}"); +} + +#[test] +fn rollback_reports_when_an_invalidated_marker_cannot_be_reopened() { + let dir = tempdir().unwrap(); + let path = dir.path().join("missing-generation"); + let mut retirement = GenerationRetirement { + lock: None, + path: path.clone(), + original: GenerationMarker::Active("generation-a".into()), + changed: true, + }; + + let error = retirement.restore_after_rollback().unwrap_err(); + + assert!(error.contains("failed to reopen"), "{error}"); + assert!(error.contains(&path.display().to_string()), "{error}"); +} + +#[test] +fn marker_replacement_preserves_the_operation_in_io_errors() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + std::fs::write(&path, "generation-a\n").unwrap(); + let mut read_only = File::open(&path).unwrap(); + + let error = replace_generation_marker( + &mut read_only, + &path, + &GenerationMarker::Retired("generation-a".into()), + "invalidate", + ) + .unwrap_err(); + + assert!(error.contains("failed to invalidate"), "{error}"); + assert!(error.contains(&path.display().to_string()), "{error}"); +} + +#[test] +fn malformed_retirement_requires_a_token() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + std::fs::write(&path, "retired:\n").unwrap(); + + let error = match GenerationRetirement::acquire(&path) { + Err(error) => error, + Ok(_) => panic!("a retired marker without a token must be rejected"), + }; + + assert!(error.contains("retired marker without a token"), "{error}"); +} + +#[test] +fn generation_creation_reports_an_invalid_parent() { + let dir = tempdir().unwrap(); + let parent = dir.path().join("not-a-directory"); + std::fs::write(&parent, "file").unwrap(); + + let error = write_new_generation(&parent.join(GENERATION_FILE_NAME)).unwrap_err(); + + assert!(error.contains("failed to create"), "{error}"); +} + +#[test] +fn generation_creation_reports_an_unwritable_target_shape() { + let dir = tempdir().unwrap(); + + let error = write_new_generation(dir.path()).unwrap_err(); + + assert!(error.contains("failed to write"), "{error}"); +} + +#[cfg(unix)] +#[test] +fn generation_reader_reports_directory_read_errors() { + let dir = tempdir().unwrap(); + let directory = File::open(dir.path()).unwrap(); + + let error = read_generation_marker(&directory, dir.path()).unwrap_err(); + + assert!( + error.contains("failed to read MCP install generation"), + "{error}" + ); +} diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs index 5d3020176..45189645d 100644 --- a/crates/cli/tests/coverage/installer_tests.rs +++ b/crates/cli/tests/coverage/installer_tests.rs @@ -73,7 +73,7 @@ fn merge_hooks_is_idempotent_and_preserves_existing_entries() { "Stop": [{ "hooks": [{ "type": "command", "command": "existing" }] }] } }); - let generated = codex_hooks("nemo-relay hook-forward codex"); + let generated = claude_hooks("nemo-relay hook-forward claude"); let once = merge_hooks(existing, generated.clone()).unwrap(); let twice = merge_hooks(once.clone(), generated).unwrap(); assert_eq!(once, twice); @@ -144,6 +144,36 @@ fn generated_hook_dispatch_covers_all_agents() { ); } +#[test] +fn codex_generation_uses_exactly_the_supported_hook_schema() { + let generated = generated_hooks(CodingAgent::Codex, "cmd"); + let events = generated["hooks"] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(); + + assert_eq!( + events, + std::collections::BTreeSet::from([ + "PermissionRequest", + "PostCompact", + "PostToolUse", + "PreCompact", + "PreToolUse", + "SessionStart", + "Stop", + "SubagentStart", + "SubagentStop", + "UserPromptSubmit", + ]) + ); + for unsupported in ["PostToolUseFailure", "Notification", "SessionEnd"] { + assert!(generated["hooks"].get(unsupported).is_none()); + } +} + #[test] fn packaged_hook_configs_are_valid_json() { let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -175,6 +205,15 @@ fn packaged_plugin_hooks_use_expected_shim_commands() { ) .unwrap(); + assert_eq!( + codex["description"], + json!("SPDX-License-Identifier: Apache-2.0") + ); + assert_eq!( + codex.as_object().unwrap().keys().collect::>(), + vec!["description", "hooks"] + ); + assert_eq!( claude["hooks"]["SessionStart"][0]["hooks"][0]["command"], json!("nemo-relay plugin-shim hook claude") @@ -183,6 +222,10 @@ fn packaged_plugin_hooks_use_expected_shim_commands() { codex["hooks"]["SessionStart"][0]["hooks"][0]["command"], json!("nemo-relay plugin-shim hook codex") ); + assert_eq!( + codex["hooks"], + generated_hooks(CodingAgent::Codex, "nemo-relay plugin-shim hook codex")["hooks"] + ); assert!( claude["hooks"] .as_object() @@ -223,6 +266,24 @@ fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { serde_json::from_str::(&std::fs::read_to_string(&codex_path).unwrap()).unwrap(); assert_eq!(codex["name"], json!("nemo-relay-plugin")); assert_eq!(codex["version"], json!(env!("CARGO_PKG_VERSION"))); + assert!(codex.get("hooks").is_none()); + assert_eq!(codex["mcpServers"], json!("./.mcp.json")); + + let codex_mcp_path = root.join("codex/.mcp.json"); + let codex_mcp = + serde_json::from_str::(&std::fs::read_to_string(&codex_mcp_path).unwrap()).unwrap(); + let server = &codex_mcp["nemo-relay"]; + assert_eq!(server["command"], json!("nemo-relay")); + assert_eq!(server["args"], json!(["mcp"])); + assert_eq!( + server["env"], + json!({"NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632"}) + ); + assert_eq!(server["required"], json!(true)); + assert_eq!(server["startup_timeout_sec"], json!(20)); + let env_vars = server["env_vars"].as_array().unwrap(); + assert!(env_vars.contains(&json!("OPENAI_API_KEY"))); + assert!(env_vars.contains(&json!("XDG_CONFIG_HOME"))); let codex_marketplace_path = root.join("../../.agents/plugins/marketplace.json"); let codex_marketplace = @@ -260,6 +321,7 @@ fn packaged_plugin_helpers_are_present() { for path in [ root.join("claude-code/hooks/hooks.json"), root.join("codex/hooks/hooks.json"), + root.join("codex/.mcp.json"), ] { let metadata = std::fs::metadata(&path) .unwrap_or_else(|error| panic!("{} missing: {error}", path.display())); diff --git a/crates/cli/tests/coverage/main_tests.rs b/crates/cli/tests/coverage/main_tests.rs index 85d512952..134f586ef 100644 --- a/crates/cli/tests/coverage/main_tests.rs +++ b/crates/cli/tests/coverage/main_tests.rs @@ -83,6 +83,17 @@ fn completions_helper_reports_missing_shell_and_generates_requested_shell() { assert!(script.contains("_nemo-relay")); } +#[test] +fn cli_parses_native_mcp_subcommand_and_bind_override() { + let cli = Cli::try_parse_from(["nemo-relay", "mcp"]).unwrap(); + assert!(matches!(cli.command, Some(Command::Mcp))); + assert!(cli.server.bind.is_none()); + + let cli = Cli::try_parse_from(["nemo-relay", "--bind", "127.0.0.1:4041", "mcp"]).unwrap(); + assert!(matches!(cli.command, Some(Command::Mcp))); + assert_eq!(cli.server.bind.unwrap().to_string(), "127.0.0.1:4041"); +} + #[test] fn safe_dispatch_helpers_cover_completions_and_plugins_paths() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/mcp_gateway_tests.rs b/crates/cli/tests/coverage/mcp_gateway_tests.rs new file mode 100644 index 000000000..fde004199 --- /dev/null +++ b/crates/cli/tests/coverage/mcp_gateway_tests.rs @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +#[test] +fn recovery_requires_three_consecutive_failures() { + let mut recovery = RecoveryState::default(); + + assert!(!recovery.record_failure().unwrap()); + assert!(!recovery.record_failure().unwrap()); + recovery.record_healthy(); + assert!(!recovery.record_failure().unwrap()); + assert!(!recovery.record_failure().unwrap()); + assert!(recovery.record_failure().unwrap()); +} + +#[test] +fn rediscovery_preserves_the_single_restart_allowance() { + let mut recovery = RecoveryState::default(); + for _ in 0..2 { + assert!(!recovery.record_failure().unwrap()); + } + assert!(recovery.record_failure().unwrap()); + recovery.record_recovery(false); + + for _ in 0..2 { + assert!(!recovery.record_failure().unwrap()); + } + assert!(recovery.record_failure().unwrap()); + recovery.record_recovery(true); + + for _ in 0..2 { + assert!(!recovery.record_failure().unwrap()); + } + let error = recovery.record_failure().unwrap_err(); + assert!(error.to_string().contains("after its coordinated restart")); +} + +#[tokio::test] +async fn dropping_gateway_lease_aborts_its_monitor() { + struct NotifyOnDrop(Option>); + + impl Drop for NotifyOnDrop { + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } + } + + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel(); + let monitor = tokio::spawn(async move { + let _notify = NotifyOnDrop(Some(dropped_tx)); + let _ = started_tx.send(()); + std::future::pending::<()>().await; + #[allow(unreachable_code)] + Ok(()) + }); + started_rx.await.unwrap(); + + drop(GatewayLease { monitor }); + + tokio::time::timeout(Duration::from_secs(1), dropped_rx) + .await + .expect("gateway monitor was not aborted when its lease dropped") + .unwrap(); +} diff --git a/crates/cli/tests/coverage/mcp_tests.rs b/crates/cli/tests/coverage/mcp_tests.rs new file mode 100644 index 000000000..01d6fb364 --- /dev/null +++ b/crates/cli/tests/coverage/mcp_tests.rs @@ -0,0 +1,703 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use serde_json::{Value, json}; +use std::ffi::OsString; +use std::io::{BufReader as StdBufReader, Cursor}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + +use super::*; +use crate::config::CodingAgent; +use crate::install_generation::{ + GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration, write_new_generation, +}; + +struct BootstrapConfigHome { + _guard: std::sync::MutexGuard<'static, ()>, + previous: Option, +} + +impl BootstrapConfigHome { + fn enter(path: &std::path::Path) -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = std::env::var_os("XDG_CONFIG_HOME"); + unsafe { std::env::set_var("XDG_CONFIG_HOME", path) }; + Self { + _guard: guard, + previous, + } + } +} + +impl Drop for BootstrapConfigHome { + fn drop(&mut self) { + if let Some(previous) = self.previous.take() { + unsafe { std::env::set_var("XDG_CONFIG_HOME", previous) }; + } else { + unsafe { std::env::remove_var("XDG_CONFIG_HOME") }; + } + } +} + +#[test] +fn bounded_mcp_reader_accepts_the_limit_and_preserves_following_frames() { + let mut input = vec![b'a'; MAX_MCP_FRAME_BYTES - 1]; + input.push(b'\n'); + input.extend_from_slice(b"{}\n"); + let mut reader = StdBufReader::new(Cursor::new(input)); + let mut frame = Vec::new(); + + assert_eq!( + read_bounded_frame(&mut reader, &mut frame, MAX_MCP_FRAME_BYTES).unwrap(), + MAX_MCP_FRAME_BYTES + ); + assert_eq!(frame.last(), Some(&b'\n')); + frame.clear(); + assert_eq!( + read_bounded_frame(&mut reader, &mut frame, MAX_MCP_FRAME_BYTES).unwrap(), + 3 + ); + assert_eq!(frame, b"{}\n"); +} + +#[test] +fn bounded_mcp_reader_rejects_one_oversized_unterminated_frame() { + let input = vec![b'a'; MAX_MCP_FRAME_BYTES + 1]; + let mut reader = StdBufReader::new(Cursor::new(input)); + let mut frame = Vec::new(); + + let error = read_bounded_frame(&mut reader, &mut frame, MAX_MCP_FRAME_BYTES).unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!(error.to_string().contains("MCP frame exceeds")); +} + +#[test] +fn initialize_reports_native_server_and_supported_protocol() { + let response = response_for(&json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "initialize", + "params": { "protocolVersion": "2024-11-05" } + })) + .unwrap(); + + assert_eq!(response["jsonrpc"], json!("2.0")); + assert_eq!(response["id"], json!(7)); + assert_eq!( + response["result"]["protocolVersion"], + json!(MCP_PROTOCOL_VERSION) + ); + assert_eq!(response["result"]["capabilities"], json!({ "tools": {} })); + assert_eq!( + response["result"]["serverInfo"]["name"], + json!("nemo-relay") + ); + assert_eq!( + response["result"]["serverInfo"]["version"], + json!(env!("CARGO_PKG_VERSION")) + ); +} + +#[test] +fn supported_requests_and_notifications_have_minimal_mcp_behavior() { + assert_eq!( + response_for(&json!({"jsonrpc":"2.0", "id":"tools", "method":"tools/list"})), + Some(json!({"jsonrpc":"2.0", "id":"tools", "result":{"tools":[]}})) + ); + assert_eq!( + response_for(&json!({"jsonrpc":"2.0", "id":2, "method":"ping"})), + Some(json!({"jsonrpc":"2.0", "id":2, "result":{}})) + ); + assert_eq!( + response_for(&json!({ + "jsonrpc":"2.0", + "method":"notifications/initialized" + })), + None + ); +} + +#[test] +fn invalid_and_unknown_requests_return_jsonrpc_errors() { + assert_eq!( + response_for(&json!({"jsonrpc":"2.0", "id":3})), + Some(jsonrpc_error(json!(3), -32600, "Invalid Request")) + ); + assert_eq!( + response_for(&json!({"jsonrpc":"2.0", "id":4, "method":"resources/list"})), + Some(jsonrpc_error(json!(4), -32601, "Method not found")) + ); + assert_eq!( + response_for(&json!({"jsonrpc":"2.0", "id":5, "method":"initialize", "params":{}})), + Some(jsonrpc_error(json!(5), -32602, "Missing protocolVersion")) + ); +} + +#[test] +fn gateway_bootstrap_is_deferred_until_initialize() { + assert_eq!(default_mcp_bind(), "127.0.0.1:47632".parse().unwrap()); + assert!(!request_requires_gateway("not-json\n")); + assert!(!request_requires_gateway( + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"# + )); + assert!(!request_requires_gateway( + r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"# + )); + assert!(!request_requires_gateway( + r#"{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-06-18"}}"# + )); + assert!(!request_requires_gateway( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"# + )); + assert!(!request_requires_gateway( + r#"{"jsonrpc":"1.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"# + )); + assert!(request_requires_gateway( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"# + )); +} + +#[test] +fn invalid_jsonrpc_never_bootstraps_and_returns_invalid_request() { + let action = crate::mcp::protocol::evaluate_frame( + r#"{"jsonrpc":"1.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#, + ); + + assert!(!action.requires_gateway); + assert_eq!( + action.response, + Some(jsonrpc_error(json!(1), -32600, "Invalid Request")) + ); +} + +#[tokio::test] +async fn stdio_loop_recovers_from_parse_errors_and_ignores_notifications() { + let (mut client, server) = tokio::io::duplex(4096); + let (server_reader, server_writer) = tokio::io::split(server); + let task = tokio::spawn(serve_stdio(BufReader::new(server_reader), server_writer)); + + client + .write_all( + b"not-json\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"ping\"}\n", + ) + .await + .unwrap(); + client.shutdown().await.unwrap(); + let mut output = String::new(); + client.read_to_string(&mut output).await.unwrap(); + task.await.unwrap().unwrap(); + + let responses = output + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!(responses.len(), 2); + assert_eq!( + responses[0], + jsonrpc_error(Value::Null, -32700, "Parse error") + ); + assert_eq!(responses[1], json!({"jsonrpc":"2.0", "id":5, "result":{}})); +} + +#[tokio::test] +async fn mcp_session_serves_stdio_and_stops_heartbeat_on_eof() { + let (client, server_io) = tokio::io::duplex(4096); + let (client_reader, mut client_writer) = tokio::io::split(client); + let (server_reader, server_writer) = tokio::io::split(server_io); + let task = tokio::spawn(run_session( + "127.0.0.1:9".parse().unwrap(), + "http://127.0.0.1:9".into(), + Vec::new(), + "test-fingerprint".into(), + Duration::from_secs(60), + BufReader::new(server_reader), + server_writer, + )); + + client_writer + .write_all( + b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\"}}\n", + ) + .await + .unwrap(); + let mut client_reader = BufReader::new(client_reader); + let mut response = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + client_reader.read_line(&mut response), + ) + .await + .expect("MCP initialization response timed out") + .unwrap(); + assert_eq!( + serde_json::from_str::(&response).unwrap()["result"]["serverInfo"]["name"], + json!("nemo-relay") + ); + + client_writer.shutdown().await.unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), task) + .await + .expect("MCP session did not stop after stdin EOF") + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn heartbeat_keeps_a_compatible_gateway_session_alive() { + let _plugin_guard = crate::test_support::PLUGIN_CONFIG_TEST_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let _bootstrap_home = BootstrapConfigHome::enter(&temp.path().join("xdg")); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bind = listener.local_addr().unwrap(); + let config = crate::config::GatewayConfig { + bind, + ..crate::config::GatewayConfig::default() + }; + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let fingerprint = "test-fingerprint"; + let gateway = tokio::spawn(crate::server::serve_listener_with_bootstrap( + listener, + config, + fingerprint.into(), + Some(shutdown_rx), + )); + let url = format!("http://{bind}"); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let probe_url = url.clone(); + if tokio::task::spawn_blocking(move || { + crate::sidecar::healthz_compatible(&probe_url, fingerprint) + }) + .await + .unwrap() + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("compatible gateway did not become healthy"); + + let health_calls = Arc::new(AtomicUsize::new(0)); + let restart_calls = Arc::new(AtomicUsize::new(0)); + let (observed_tx, mut observed_rx) = tokio::sync::mpsc::unbounded_channel(); + let heartbeat = tokio::spawn(maintain_gateway_with( + bind, + url, + Duration::from_millis(10), + { + let health_calls = health_calls.clone(); + move |url| { + let health_calls = health_calls.clone(); + let observed_tx = observed_tx.clone(); + async move { + let healthy = tokio::task::spawn_blocking(move || { + crate::sidecar::healthz_compatible(&url, fingerprint) + }) + .await + .map_err(|error| { + CliError::Launch(format!("gateway heartbeat task failed: {error}")) + })?; + if healthy { + let call = health_calls.fetch_add(1, Ordering::SeqCst) + 1; + if call == 3 { + let _ = observed_tx.send(()); + } + } + Ok(healthy) + } + } + }, + { + let restart_calls = restart_calls.clone(); + move |address| { + restart_calls.fetch_add(1, Ordering::SeqCst); + async move { + Ok(crate::sidecar::GatewayBootstrap { + endpoint: crate::sidecar::GatewayEndpoint { + address, + url: "http://unexpected-restart".into(), + }, + started: false, + }) + } + } + }, + )); + + tokio::time::timeout(Duration::from_secs(5), observed_rx.recv()) + .await + .expect("heartbeat did not complete three compatible health checks") + .expect("heartbeat stopped before completing three compatible health checks"); + assert!(!heartbeat.is_finished()); + assert!(health_calls.load(Ordering::SeqCst) >= 3); + assert_eq!(restart_calls.load(Ordering::SeqCst), 0); + heartbeat.abort(); + assert!(heartbeat.await.unwrap_err().is_cancelled()); + + let _ = shutdown_tx.send(()); + tokio::time::timeout(Duration::from_secs(5), gateway) + .await + .expect("compatible gateway did not stop") + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn heartbeat_performs_one_restart_and_tracks_the_recovered_gateway() { + let bind = "127.0.0.1:47632".parse().unwrap(); + let restart_calls = Arc::new(AtomicUsize::new(0)); + let observed_urls = Arc::new(Mutex::new(Vec::new())); + let (recovered_tx, mut recovered_rx) = tokio::sync::mpsc::unbounded_channel(); + let heartbeat = tokio::spawn(maintain_gateway_with( + bind, + "http://dead-gateway".into(), + Duration::from_millis(1), + { + let observed_urls = observed_urls.clone(); + move |url| { + let observed_urls = observed_urls.clone(); + let recovered_tx = recovered_tx.clone(); + async move { + let recovered = url == "http://recovered-gateway"; + observed_urls.lock().unwrap().push(url); + if recovered { + let _ = recovered_tx.send(()); + } + Ok(recovered) + } + } + }, + { + let restart_calls = restart_calls.clone(); + move |address| { + let restart_calls = restart_calls.clone(); + async move { + restart_calls.fetch_add(1, Ordering::SeqCst); + Ok(crate::sidecar::GatewayBootstrap { + endpoint: crate::sidecar::GatewayEndpoint { + address, + url: "http://recovered-gateway".into(), + }, + started: true, + }) + } + } + }, + )); + + tokio::time::timeout(Duration::from_secs(5), recovered_rx.recv()) + .await + .expect("heartbeat did not observe the recovered gateway") + .expect("heartbeat stopped before observing the recovered gateway"); + assert!(!heartbeat.is_finished()); + assert_eq!(restart_calls.load(Ordering::SeqCst), 1); + assert!( + observed_urls + .lock() + .unwrap() + .iter() + .any(|url| url == "http://recovered-gateway") + ); + heartbeat.abort(); + assert!(heartbeat.await.unwrap_err().is_cancelled()); +} + +#[tokio::test] +async fn heartbeat_ignores_isolated_transient_health_failures() { + let health_calls = Arc::new(AtomicUsize::new(0)); + let restart_calls = Arc::new(AtomicUsize::new(0)); + let (observed_tx, mut observed_rx) = tokio::sync::mpsc::unbounded_channel(); + let heartbeat = tokio::spawn(maintain_gateway_with( + "127.0.0.1:47632".parse().unwrap(), + "http://gateway".into(), + Duration::from_millis(1), + { + let health_calls = health_calls.clone(); + move |_url| { + let observed_tx = observed_tx.clone(); + let call = health_calls.fetch_add(1, Ordering::SeqCst) + 1; + async move { + if call == 9 { + let _ = observed_tx.send(()); + } + Ok(call.is_multiple_of(3)) + } + } + }, + { + let restart_calls = restart_calls.clone(); + move |address| { + restart_calls.fetch_add(1, Ordering::SeqCst); + async move { + Ok(crate::sidecar::GatewayBootstrap { + endpoint: crate::sidecar::GatewayEndpoint { + address, + url: "http://gateway".into(), + }, + started: false, + }) + } + } + }, + )); + + tokio::time::timeout(Duration::from_secs(5), observed_rx.recv()) + .await + .expect("heartbeat did not complete three transient-failure cycles") + .expect("heartbeat stopped before completing three transient-failure cycles"); + assert!(!heartbeat.is_finished()); + assert!(health_calls.load(Ordering::SeqCst) >= 9); + assert_eq!(restart_calls.load(Ordering::SeqCst), 0); + heartbeat.abort(); + assert!(heartbeat.await.unwrap_err().is_cancelled()); +} + +#[tokio::test] +async fn heartbeat_rediscovery_does_not_consume_the_actual_restart_allowance() { + let restart_calls = Arc::new(AtomicUsize::new(0)); + let error = maintain_gateway_with( + "127.0.0.1:47632".parse().unwrap(), + "http://gateway".into(), + Duration::from_millis(1), + |_url| async { Ok(false) }, + { + let restart_calls = restart_calls.clone(); + move |address| { + let restart_calls = restart_calls.clone(); + async move { + let attempt = restart_calls.fetch_add(1, Ordering::SeqCst); + Ok(crate::sidecar::GatewayBootstrap { + endpoint: crate::sidecar::GatewayEndpoint { + address, + url: "http://gateway".into(), + }, + started: attempt > 0, + }) + } + } + }, + ) + .await + .unwrap_err(); + + assert_eq!(restart_calls.load(Ordering::SeqCst), 2); + assert!(error.to_string().contains("after its coordinated restart")); +} + +#[tokio::test] +async fn heartbeat_exits_with_the_restart_failure() { + let error = maintain_gateway_with( + "127.0.0.1:47632".parse().unwrap(), + "http://dead-gateway".into(), + Duration::from_millis(1), + |_url| async { Ok(false) }, + |_bind| async { Err(CliError::Launch("coordinated restart failed".into())) }, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("coordinated restart failed")); +} + +#[tokio::test] +async fn heartbeat_attempts_at_most_one_successful_restart() { + let restart_calls = Arc::new(AtomicUsize::new(0)); + let error = maintain_gateway_with( + "127.0.0.1:47632".parse().unwrap(), + "http://dead-gateway".into(), + Duration::from_millis(1), + |_url| async { Ok(false) }, + { + let restart_calls = restart_calls.clone(); + move |address| { + let restart_calls = restart_calls.clone(); + async move { + restart_calls.fetch_add(1, Ordering::SeqCst); + Ok(crate::sidecar::GatewayBootstrap { + endpoint: crate::sidecar::GatewayEndpoint { + address, + url: "http://still-unhealthy".into(), + }, + started: true, + }) + } + } + }, + ) + .await + .unwrap_err(); + + assert_eq!(restart_calls.load(Ordering::SeqCst), 1); + assert!(error.to_string().contains("after its coordinated restart")); +} + +#[tokio::test] +async fn old_mcp_maintenance_loop_exits_when_install_generation_is_replaced() { + let dir = tempfile::tempdir().unwrap(); + let generation_path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&generation_path).unwrap(); + let generation = InstallGeneration::capture(generation_path.clone()).unwrap(); + let health_calls = Arc::new(AtomicUsize::new(0)); + let restart_calls = Arc::new(AtomicUsize::new(0)); + let (observed_tx, observed_rx) = tokio::sync::oneshot::channel(); + let observed_tx = Arc::new(Mutex::new(Some(observed_tx))); + let heartbeat = tokio::spawn(maintain_gateway_with_generation( + "127.0.0.1:47632".parse().unwrap(), + "http://old-gateway".into(), + Duration::from_millis(100), + { + let health_calls = health_calls.clone(); + move |_url| { + health_calls.fetch_add(1, Ordering::SeqCst); + let observed_tx = observed_tx.clone(); + async move { + if let Some(sender) = observed_tx.lock().unwrap().take() { + let _ = sender.send(()); + } + Ok(false) + } + } + }, + { + let restart_calls = restart_calls.clone(); + move |address| { + restart_calls.fetch_add(1, Ordering::SeqCst); + async move { + Ok(crate::sidecar::GatewayBootstrap { + endpoint: crate::sidecar::GatewayEndpoint { + address, + url: "http://unexpected-restart".into(), + }, + started: true, + }) + } + } + }, + move || { + let generation = generation.clone(); + async move { generation.verify_current().map_err(CliError::Launch) } + }, + )); + + tokio::time::timeout(Duration::from_secs(5), observed_rx) + .await + .expect("old MCP maintenance loop did not perform its first health check") + .expect("old MCP maintenance loop stopped before its first health check"); + let mut retirement = GenerationRetirement::acquire(&generation_path) + .unwrap() + .expect("installed generation should be retired"); + retirement.invalidate_for_replacement().unwrap(); + std::fs::rename(&generation_path, dir.path().join("retired-generation")).unwrap(); + write_new_generation(&generation_path).unwrap(); + drop(retirement); + + let error = tokio::time::timeout(Duration::from_secs(5), heartbeat) + .await + .expect("old MCP maintenance loop did not observe generation replacement") + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("has been retired")); + assert_eq!(health_calls.load(Ordering::SeqCst), 1); + assert_eq!(restart_calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn invalidated_install_generation_can_be_restored_before_rollback_registration() { + let dir = tempfile::tempdir().unwrap(); + let generation_path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&generation_path).unwrap(); + let original = InstallGeneration::capture(generation_path.clone()).unwrap(); + let mut retirement = GenerationRetirement::acquire(&generation_path) + .unwrap() + .expect("installed generation should be retired"); + + retirement.invalidate_for_replacement().unwrap(); + let error = InstallGeneration::capture(generation_path.clone()).unwrap_err(); + assert!(error.contains("has been retired"), "{error}"); + + retirement.restore_after_rollback().unwrap(); + original.verify_current().unwrap(); + InstallGeneration::capture(generation_path).unwrap(); +} + +#[test] +fn retired_install_generation_remains_retryable_but_not_adoptable() { + let dir = tempfile::tempdir().unwrap(); + let generation_path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&generation_path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&generation_path) + .unwrap() + .expect("installed generation should be retired"); + retirement.invalidate_for_replacement().unwrap(); + drop(retirement); + + let mut resumed = GenerationRetirement::acquire(&generation_path) + .unwrap() + .expect("a retired generation should support cleanup retry"); + resumed.invalidate_for_replacement().unwrap(); + drop(resumed); + + let error = InstallGeneration::capture(generation_path).unwrap_err(); + assert!(error.contains("has been retired"), "{error}"); +} + +#[test] +fn retired_mcp_postcheck_does_not_stop_a_replacement_gateway() { + assert_retired_mcp_postcheck_preserves_gateway(2002, "replacement-token"); +} + +#[test] +fn retired_mcp_postcheck_does_not_stop_the_exact_reused_gateway() { + assert_retired_mcp_postcheck_preserves_gateway(1001, "retired-token"); +} + +fn assert_retired_mcp_postcheck_preserves_gateway(gateway_pid: u32, gateway_token: &str) { + let dir = tempfile::tempdir().unwrap(); + let generation_path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&generation_path).unwrap(); + let generation = InstallGeneration::capture(generation_path.clone()).unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let url = format!("http://{address}"); + let state = dir.path().join("bootstrap-state"); + std::fs::create_dir(&state).unwrap(); + let owner_path = crate::sidecar::sidecar_owner_path(&state, CodingAgent::Codex, &url); + let pid_path = crate::sidecar::sidecar_pid_path(&state, CodingAgent::Codex, &url); + crate::sidecar::write_sidecar_owner( + &owner_path, + gateway_pid, + &url, + gateway_token, + Some("same-bootstrap-fingerprint"), + ) + .unwrap(); + std::fs::write(&pid_path, gateway_pid.to_string()).unwrap(); + write_new_generation(&generation_path).unwrap(); + + let error = verify_bootstrap_generation(&generation).unwrap_err(); + + assert!(error.contains("has been retired"), "{error}"); + assert!(owner_path.exists()); + assert_eq!( + std::fs::read_to_string(pid_path).unwrap(), + gateway_pid.to_string() + ); + assert_eq!( + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); +} + +#[test] +fn default_mcp_gateway_uses_plugin_provider_port() { + assert_eq!(default_mcp_bind().to_string(), "127.0.0.1:47632"); +} diff --git a/crates/cli/tests/coverage/plugin_install_setup_tests.rs b/crates/cli/tests/coverage/plugin_install_setup_tests.rs new file mode 100644 index 000000000..9381b3c66 --- /dev/null +++ b/crates/cli/tests/coverage/plugin_install_setup_tests.rs @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use serde_json::{Value, json}; + +use super::*; + +struct DefaultsOnlyRunner; + +impl PluginSetupRunner for DefaultsOnlyRunner { + fn setup( + &self, + _host: PluginHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn uninstall( + &self, + _host: PluginHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor( + &self, + _host: PluginHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor_json( + &self, + _host: PluginHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result { + Ok(json!({})) + } +} + +#[test] +fn setup_runner_defaults_are_explicit_no_ops() { + let runner = DefaultsOnlyRunner; + + assert!(runner.snapshot(PluginHost::Codex).unwrap().is_none()); + runner.restore_snapshot(&PluginSetupSnapshot::Mock).unwrap(); + runner.refresh_gateway(PluginHost::Codex).unwrap(); +} + +#[test] +fn real_runner_has_no_claude_snapshot_or_gateway_to_restore() { + let runner = RealPluginSetupRunner; + + assert!(runner.snapshot(PluginHost::ClaudeCode).unwrap().is_none()); + runner.restore_snapshot(&PluginSetupSnapshot::Mock).unwrap(); + runner.refresh_gateway(PluginHost::ClaudeCode).unwrap(); +} + +#[test] +fn setup_descriptions_reject_unexpanded_hosts_and_unknown_actions() { + assert!( + std::panic::catch_unwind(|| setup_action_description(PluginHost::All, "configure")) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| setup_action_description(PluginHost::Codex, "unknown")) + .is_err() + ); + + let runner = RealPluginSetupRunner; + let root = Path::new("unused"); + assert!(std::panic::catch_unwind(|| runner.snapshot(PluginHost::All)).is_err()); + assert!(std::panic::catch_unwind(|| runner.refresh_gateway(PluginHost::All)).is_err()); + assert!( + std::panic::catch_unwind(|| runner.setup(PluginHost::All, DEFAULT_GATEWAY_URL, root)) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| runner.uninstall(PluginHost::All, DEFAULT_GATEWAY_URL, root)) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| runner.doctor(PluginHost::All, DEFAULT_GATEWAY_URL, root)) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| runner.doctor_json(PluginHost::All, DEFAULT_GATEWAY_URL, root)) + .is_err() + ); +} diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index 5a0776dd8..463a9af65 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -2,10 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::ffi::OsString; use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; use std::sync::Mutex; +use std::thread; +use std::time::{Duration, Instant}; use serde_json::json; use tempfile::tempdir; @@ -13,18 +16,225 @@ use tempfile::tempdir; use super::host::{ CommandOutput, HostRegistrationReport, format_command, host_registration_report, require_host_cli, require_relay, run_capture_command, run_command, run_path_command, - validate_host_registration, validate_relay_plugin_shim, + validate_codex_version, validate_host_registration, validate_relay_mcp, + validate_relay_plugin_shim, }; use super::*; +use crate::plugin_shim::strip_windows_verbatim_prefix; + +const OPERATION_LOCK_HELPER_DIR_ENV: &str = "NEMO_RELAY_TEST_OPERATION_LOCK_DIR"; +const OPERATION_LOCK_HELPER_GLOBAL_DIR_ENV: &str = "NEMO_RELAY_TEST_OPERATION_LOCK_GLOBAL_DIR"; +const GENERATION_LOCK_HELPER_PATH_ENV: &str = "NEMO_RELAY_TEST_GENERATION_LOCK_PATH"; +const LOCK_HELPER_READY_ENV: &str = "NEMO_RELAY_TEST_LOCK_READY"; +const LOCK_HELPER_RELEASE_ENV: &str = "NEMO_RELAY_TEST_LOCK_RELEASE"; + +fn force_snapshot_with_backups( + backup_marketplace_root: PathBuf, + backup_plugin_root: Option, +) -> ForceInstallSnapshot { + ForceInstallSnapshot { + state_bytes: None, + setup_snapshot: None, + original_marketplace_root: PathBuf::from("original-marketplace"), + original_plugin_root: PathBuf::from("separate-original-plugin"), + original_generation_fence: PathBuf::from("original-generation"), + plugin_registered: false, + marketplace_registered: false, + backup_marketplace_root, + backup_plugin_root, + marketplace_moved: true, + plugin_moved: true, + replacement_promoted: false, + generation_retirement: None, + } +} fn plugin_install_env_lock() -> &'static Mutex<()> { &crate::test_support::ENV_TEST_LOCK } +#[test] +fn windows_verbatim_relay_paths_are_normalized_for_mcp_config() { + let normalize = |path: &str| { + let encoded = path.encode_utf16().collect::>(); + strip_windows_verbatim_prefix(&encoded) + .map(|normalized| String::from_utf16(&normalized).unwrap()) + }; + + assert_eq!( + normalize(r"\\?\C:\Program Files\NVIDIA\nemo-relay.exe"), + Some(r"C:\Program Files\NVIDIA\nemo-relay.exe".into()) + ); + assert_eq!( + normalize(r"\\?\UNC\server\share\nemo-relay.exe"), + Some(r"\\server\share\nemo-relay.exe".into()) + ); + assert_eq!(normalize(r"C:\nemo-relay.exe"), None); +} + +#[test] +fn readiness_worker_returns_a_report_and_handles_channel_disconnects() { + let dir = tempdir().unwrap(); + let pending = spawn_default_host_plugin_readiness(PluginHost::Codex, dir.path().to_path_buf()); + let readiness = receive_host_plugin_readiness(pending, Duration::from_secs(5)); + assert_eq!(readiness.host, "codex"); + assert!(!readiness.checks.is_empty()); + + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + drop(sender); + let readiness = receive_host_plugin_readiness( + PendingHostPluginReadiness { + host: PluginHost::ClaudeCode, + state_path: dir.path().join("claude-state.json"), + receiver, + }, + Duration::from_secs(1), + ); + assert!(!readiness.ok()); + assert!( + readiness.checks[0] + .details + .contains("collector stopped unexpectedly") + ); +} + +#[test] +fn committed_force_snapshot_removes_all_backup_trees_best_effort() { + let dir = tempdir().unwrap(); + let marketplace = dir.path().join("marketplace-backup"); + let plugin = dir.path().join("plugin-backup"); + std::fs::create_dir_all(&marketplace).unwrap(); + std::fs::create_dir_all(&plugin).unwrap(); + + force_snapshot_with_backups(marketplace.clone(), Some(plugin.clone())).commit(); + + assert!(!marketplace.exists()); + assert!(!plugin.exists()); + + let missing_marketplace = dir.path().join("missing-marketplace"); + let missing_plugin = dir.path().join("missing-plugin"); + force_snapshot_with_backups(missing_marketplace, Some(missing_plugin)).commit(); + + let marketplace_file = dir.path().join("marketplace-file"); + let plugin_file = dir.path().join("plugin-file"); + std::fs::write(&marketplace_file, "file").unwrap(); + std::fs::write(&plugin_file, "file").unwrap(); + force_snapshot_with_backups(marketplace_file.clone(), Some(plugin_file.clone())).commit(); + assert!(marketplace_file.exists()); + assert!(plugin_file.exists()); +} + +#[test] +fn dry_run_cleanup_and_rollback_cover_absent_install_state() { + let dir = tempdir().unwrap(); + let mut dry_run = options(dir.path()); + dry_run.dry_run = true; + let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + let runner = MockRunner::default(); + let setup_runner = MockSetupRunner::default(); + + force_cleanup_existing_install( + PluginHost::ClaudeCode, + &layout, + &dry_run, + &runner, + &setup_runner, + ) + .unwrap(); + rollback_install( + PluginHost::ClaudeCode, + &layout, + HostRegistrationProgress::default(), + false, + &dry_run, + &runner, + &setup_runner, + ) + .unwrap(); +} + +#[test] +fn staged_marketplace_promotion_reports_the_source_and_target() { + let dir = tempdir().unwrap(); + let staged_parent = dir.path().join("stage"); + let target_parent = dir.path().join("target"); + let staged = StagedPluginMarketplace { + layout: PluginLayout::new(PluginHost::Codex, &staged_parent), + parent: staged_parent, + }; + let target = PluginLayout::new(PluginHost::Codex, &target_parent); + + let error = staged.promote(&target).unwrap_err(); + + assert!(error.contains("failed to promote staged marketplace")); + assert!( + error.contains(&staged.layout.marketplace_root.display().to_string()), + "{error}" + ); + assert!( + error.contains(&target.marketplace_root.display().to_string()), + "{error}" + ); +} + +#[test] +fn codex_plugin_requires_version_with_complete_hook_support() { + let dir = tempdir().unwrap(); + let normal = options(dir.path()); + let supported = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output("/bin/codex --version", "codex-cli 0.143.0\n"); + validate_codex_version(&normal, &supported).unwrap(); + + let old = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output("/bin/codex --version", "codex-cli 0.142.9\n"); + assert!( + validate_codex_version(&normal, &old) + .unwrap_err() + .contains("requires codex-cli 0.143.0") + ); + + let invalid = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output("/bin/codex --version", "codex nightly\n"); + assert!( + validate_codex_version(&normal, &invalid) + .unwrap_err() + .contains("could not parse") + ); + + let prerelease = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output("/bin/codex --version", "codex-cli 0.143.0-alpha.1\n"); + assert!( + validate_codex_version(&normal, &prerelease) + .unwrap_err() + .contains("codex-cli 0.143.0-alpha.1 is unsupported") + ); + + for malformed in [ + "codex-cli 0.143\n", + "codex-cli v0.143.0\n", + "warning 1.2.3\ncodex-cli 0.143.0\n", + ] { + let runner = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output("/bin/codex --version", malformed); + assert!( + validate_codex_version(&normal, &runner) + .unwrap_err() + .contains("could not parse"), + "unexpectedly parsed {malformed:?}" + ); + } +} + struct HomeScope<'a> { _guard: std::sync::MutexGuard<'a, ()>, prev_home: Option, prev_userprofile: Option, + prev_codex_home: Option, } impl<'a> HomeScope<'a> { @@ -34,15 +244,18 @@ impl<'a> HomeScope<'a> { .unwrap_or_else(|error| error.into_inner()); let prev_home = std::env::var_os("HOME"); let prev_userprofile = std::env::var_os("USERPROFILE"); + let prev_codex_home = std::env::var_os("CODEX_HOME"); // SAFETY: This test holds a process-wide mutex for the lifetime of the env override. unsafe { std::env::set_var("HOME", path); std::env::remove_var("USERPROFILE"); + std::env::remove_var("CODEX_HOME"); } Self { _guard: guard, prev_home, prev_userprofile, + prev_codex_home, } } } @@ -59,6 +272,10 @@ impl Drop for HomeScope<'_> { Some(value) => std::env::set_var("USERPROFILE", value), None => std::env::remove_var("USERPROFILE"), } + match self.prev_codex_home.take() { + Some(value) => std::env::set_var("CODEX_HOME", value), + None => std::env::remove_var("CODEX_HOME"), + } } } } @@ -66,21 +283,29 @@ impl Drop for HomeScope<'_> { struct PathScope<'a> { _guard: std::sync::MutexGuard<'a, ()>, previous: Option, + previous_home: Option, + previous_codex_home: Option, } impl<'a> PathScope<'a> { - fn set(path: &Path) -> Self { + fn set_isolated(path: &Path, home: &Path) -> Self { let guard = plugin_install_env_lock() .lock() .unwrap_or_else(|error| error.into_inner()); let previous = std::env::var_os("PATH"); + let previous_home = std::env::var_os("HOME"); + let previous_codex_home = std::env::var_os("CODEX_HOME"); // SAFETY: This test holds the process-wide environment mutex for the override lifetime. unsafe { std::env::set_var("PATH", path); + std::env::set_var("HOME", home); + std::env::remove_var("CODEX_HOME"); } Self { _guard: guard, previous, + previous_home, + previous_codex_home, } } } @@ -93,23 +318,38 @@ impl Drop for PathScope<'_> { Some(value) => std::env::set_var("PATH", value), None => std::env::remove_var("PATH"), } + match self.previous_home.take() { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match self.previous_codex_home.take() { + Some(value) => std::env::set_var("CODEX_HOME", value), + None => std::env::remove_var("CODEX_HOME"), + } } } } #[derive(Default)] struct MockRunner { + current_executable: Option, executables: HashMap, commands: RefCell>, quiet_commands: RefCell>, capture_commands: RefCell>, capture_outputs: HashMap, + capture_output_sequences: RefCell>>, failing_suffix: Option, failing_suffixes: Vec, failing_quiet_suffix: Option, } impl MockRunner { + fn with_current_executable(mut self, path: &str) -> Self { + self.current_executable = Some(PathBuf::from(path)); + self + } + fn with_executable(mut self, name: &str, path: &str) -> Self { self.executables.insert(name.into(), PathBuf::from(path)); self @@ -139,6 +379,61 @@ impl MockRunner { self } + fn with_codex_registration(mut self, plugin: bool, marketplace: bool) -> Self { + let plugin_output = if plugin { + "nemo-relay-plugin@nemo-relay-local installed, enabled\n" + } else { + "" + }; + let marketplace_output = if marketplace { + "nemo-relay-local /tmp/nemo-relay-local\n" + } else { + "" + }; + self.capture_outputs.insert( + "/bin/codex plugin list".into(), + CommandOutput::success(plugin_output.into()), + ); + self.capture_outputs.insert( + "/bin/codex plugin marketplace list".into(), + CommandOutput::success(marketplace_output.into()), + ); + self + } + + fn with_codex_registration_sequence(mut self, states: &[(bool, bool)]) -> Self { + let plugin_outputs = states + .iter() + .map(|(plugin, _)| { + CommandOutput::success( + plugin + .then_some("nemo-relay-plugin@nemo-relay-local installed, enabled\n") + .unwrap_or_default() + .into(), + ) + }) + .collect(); + let marketplace_outputs = states + .iter() + .map(|(_, marketplace)| { + CommandOutput::success( + marketplace + .then_some("nemo-relay-local /tmp/nemo-relay-local\n") + .unwrap_or_default() + .into(), + ) + }) + .collect(); + self.capture_output_sequences + .get_mut() + .insert("/bin/codex plugin list".into(), plugin_outputs); + self.capture_output_sequences.get_mut().insert( + "/bin/codex plugin marketplace list".into(), + marketplace_outputs, + ); + self + } + fn commands(&self) -> Vec { self.commands.borrow().clone() } @@ -153,6 +448,13 @@ impl MockRunner { } impl CommandRunner for MockRunner { + fn current_executable(&self) -> Result { + self.current_executable + .clone() + .or_else(|| self.executables.get(RELAY_COMMAND).cloned()) + .ok_or_else(|| "failed to resolve current nemo-relay executable".into()) + } + fn resolve_executable(&self, command: &str) -> Result, String> { Ok(self.executables.get(command).cloned()) } @@ -210,11 +512,25 @@ impl CommandRunner for MockRunner { .join(" ") ); self.capture_commands.borrow_mut().push(rendered.clone()); + if let Some(output) = self + .capture_output_sequences + .borrow_mut() + .get_mut(&rendered) + .and_then(VecDeque::pop_front) + { + return Ok(output); + } Ok(self .capture_outputs .get(&rendered) .cloned() - .unwrap_or_else(|| CommandOutput::success(String::new()))) + .unwrap_or_else(|| { + if rendered.ends_with("codex --version") { + CommandOutput::success("codex-cli 0.143.0\n".into()) + } else { + CommandOutput::success(String::new()) + } + })) } } @@ -225,6 +541,7 @@ fn command_matches_suffix(command: &str, suffix: Option<&str>) -> bool { #[derive(Default)] struct MockSetupRunner { calls: RefCell>, + doctor_roots: RefCell>, failing_call: Option, } @@ -232,18 +549,50 @@ impl MockSetupRunner { fn calls(&self) -> Vec { self.calls.borrow().clone() } + + fn doctor_roots(&self) -> Vec { + self.doctor_roots.borrow().clone() + } } impl PluginSetupRunner for MockSetupRunner { - fn setup(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + fn snapshot(&self, host: PluginHost) -> Result, String> { + self.record(format!("snapshot {}", host_arg(host)))?; + Ok(Some(PluginSetupSnapshot::Mock)) + } + + fn restore_snapshot(&self, _snapshot: &PluginSetupSnapshot) -> Result<(), String> { + self.record("restore snapshot".into()) + } + + fn refresh_gateway(&self, host: PluginHost) -> Result<(), String> { + self.record(format!("refresh {}", host_arg(host))) + } + + fn setup( + &self, + host: PluginHost, + gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { self.record(format!("setup {} {gateway_url}", host_arg(host))) } - fn uninstall(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + fn uninstall( + &self, + host: PluginHost, + gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { self.record(format!("uninstall {} {gateway_url}", host_arg(host))) } - fn doctor(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + fn doctor( + &self, + host: PluginHost, + gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { self.record(format!("doctor {} {gateway_url}", host_arg(host))) } @@ -251,7 +600,11 @@ impl PluginSetupRunner for MockSetupRunner { &self, host: PluginHost, gateway_url: &str, + plugin_root: &Path, ) -> Result { + self.doctor_roots + .borrow_mut() + .push(plugin_root.to_path_buf()); self.record(format!("doctor-json {} {gateway_url}", host_arg(host)))?; Ok(json!({ "ok": true, @@ -274,6 +627,7 @@ impl MockSetupRunner { fn options(dir: &Path) -> PluginInstallOptions { PluginInstallOptions { install_dir: dir.to_path_buf(), + operation_lock_dir: dir.join("operation-locks"), force: false, dry_run: false, skip_doctor: true, @@ -284,13 +638,323 @@ fn relay_validation_command() -> String { "/bin/nemo-relay plugin-shim hook --help".into() } +fn relay_mcp_validation_command() -> String { + "/bin/nemo-relay mcp --help".into() +} + fn write_installed_state(host: PluginHost, dir: &Path) { let layout = PluginLayout::new(host, dir); - write_plugin_marketplace(host, &layout, &options(dir)).unwrap(); + write_plugin_marketplace(host, &layout, Path::new("/bin/nemo-relay"), &options(dir)).unwrap(); write_state(&layout, &options(dir)).unwrap(); mark_plugin_setup_installed(host, &layout, &options(dir)).unwrap(); } +fn write_relocated_codex_install(selected_dir: &Path, relocated_dir: &Path) -> PluginLayout { + let relocated = PluginLayout::new(PluginHost::Codex, relocated_dir); + write_plugin_marketplace( + PluginHost::Codex, + &relocated, + Path::new("/bin/nemo-relay"), + &options(selected_dir), + ) + .unwrap(); + write_state_for_host( + PluginHost::Codex, + &PluginState { + marketplace_root: relocated.marketplace_root.clone(), + plugin_root: relocated.plugin_root.clone(), + host_plugin_removed: false, + host_marketplace_removed: false, + plugin_setup_installed: true, + }, + selected_dir, + &options(selected_dir), + ) + .unwrap(); + relocated +} + +fn assert_no_install_stage(dir: &Path) { + assert!(std::fs::read_dir(dir).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains("install-stage") + })); +} + +fn assert_no_force_replacement_residue(dir: &Path) { + assert!(std::fs::read_dir(dir).unwrap().all(|entry| { + let name = entry.unwrap().file_name(); + let name = name.to_string_lossy(); + !name.contains("install-stage") + && !name.contains("marketplace-backup") + && !name.contains("plugin-backup") + })); +} + +fn assert_actionable_generation_error(error: &str, cause: &str) { + assert!(error.contains(cause), "{error}"); + assert!(error.contains("close all Codex clients"), "{error}"); + assert!( + error.contains("codex plugin remove nemo-relay-plugin@nemo-relay-local"), + "{error}" + ); + assert!( + error.contains("codex plugin marketplace remove nemo-relay-local"), + "{error}" + ); + assert!(error.contains("stale marketplace and state"), "{error}"); + assert!( + error.contains("nemo-relay install codex --force"), + "{error}" + ); +} + +fn corrupt_generation_fence(path: &Path, corruption: &str) { + match corruption { + "empty" => std::fs::write(path, b"").unwrap(), + "oversized" => std::fs::write(path, vec![b'x'; 129]).unwrap(), + "unreadable" => { + std::fs::remove_file(path).unwrap(); + std::fs::create_dir(path).unwrap(); + } + _ => unreachable!(), + } +} + +struct CrossProcessLockHolder { + child: Option, + release: PathBuf, +} + +impl CrossProcessLockHolder { + fn spawn( + env_name: &str, + target: &Path, + global_lock_dir: Option<&Path>, + synchronization_dir: &Path, + ) -> Self { + let ready = synchronization_dir.join("ready"); + let release = synchronization_dir.join("release"); + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "plugin_install::tests::cross_process_lock_holder", + "--nocapture", + ]) + .env(env_name, target) + .env(LOCK_HELPER_READY_ENV, &ready) + .env(LOCK_HELPER_RELEASE_ENV, &release) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if let Some(global_lock_dir) = global_lock_dir { + command.env(OPERATION_LOCK_HELPER_GLOBAL_DIR_ENV, global_lock_dir); + } + let mut child = command.spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if ready.exists() { + break; + } + if let Some(status) = child.try_wait().unwrap() { + panic!("cross-process lock holder exited before acquiring its lock: {status}"); + } + assert!( + Instant::now() < deadline, + "cross-process lock holder did not become ready" + ); + thread::sleep(Duration::from_millis(10)); + } + Self { + child: Some(child), + release, + } + } + + fn release(mut self) { + self.finish(); + } + + fn finish(&mut self) { + let Some(mut child) = self.child.take() else { + return; + }; + std::fs::write(&self.release, b"release").unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if child.try_wait().unwrap().is_some() { + return; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("cross-process lock holder did not exit after release"); + } + thread::sleep(Duration::from_millis(10)); + } + } +} + +impl Drop for CrossProcessLockHolder { + fn drop(&mut self) { + if self.child.is_some() { + self.finish(); + } + } +} + +#[test] +fn cross_process_lock_holder() { + let ready = match std::env::var_os(LOCK_HELPER_READY_ENV) { + Some(path) => PathBuf::from(path), + None => return, + }; + let release = PathBuf::from(std::env::var_os(LOCK_HELPER_RELEASE_ENV).unwrap()); + let _operation_lock; + let _generation_retirement; + if let Some(path) = std::env::var_os(OPERATION_LOCK_HELPER_DIR_ENV) { + let global_lock_dir = + PathBuf::from(std::env::var_os(OPERATION_LOCK_HELPER_GLOBAL_DIR_ENV).unwrap()); + _operation_lock = Some( + PluginOperationLock::acquire( + PluginHost::Codex, + &global_lock_dir, + Path::new(&path), + Duration::from_secs(5), + ) + .unwrap(), + ); + _generation_retirement = None; + } else if let Some(path) = std::env::var_os(GENERATION_LOCK_HELPER_PATH_ENV) { + _operation_lock = None; + _generation_retirement = GenerationRetirement::acquire(Path::new(&path)).unwrap(); + assert!(_generation_retirement.is_some()); + } else { + return; + } + std::fs::write(ready, b"ready").unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while !release.exists() { + assert!(Instant::now() < deadline, "lock holder release timed out"); + thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn concurrent_install_install_times_out_without_mutating() { + let dir = tempdir().unwrap(); + let synchronization = tempdir().unwrap(); + let install_options = options(dir.path()); + let holder = CrossProcessLockHolder::spawn( + OPERATION_LOCK_HELPER_DIR_ENV, + dir.path(), + Some(&install_options.operation_lock_dir), + synchronization.path(), + ); + let runner = MockRunner::default(); + let setup_runner = MockSetupRunner::default(); + + let error = install_host_with_operation_timeout( + PluginHost::Codex, + &install_options, + &runner, + &setup_runner, + Duration::from_millis(75), + ) + .unwrap_err(); + + assert!(error.contains("another codex plugin install or uninstall")); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + holder.release(); +} + +#[test] +fn concurrent_install_uninstall_times_out_without_mutating() { + let dir = tempdir().unwrap(); + let synchronization = tempdir().unwrap(); + let install_options = options(dir.path()); + let holder = CrossProcessLockHolder::spawn( + OPERATION_LOCK_HELPER_DIR_ENV, + dir.path(), + Some(&install_options.operation_lock_dir), + synchronization.path(), + ); + let runner = MockRunner::default(); + let setup_runner = MockSetupRunner::default(); + + let error = uninstall_host_with_operation_timeout( + PluginHost::Codex, + &install_options, + &runner, + &setup_runner, + Duration::from_millis(75), + ) + .unwrap_err(); + + assert!(error.contains("another codex plugin install or uninstall")); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + holder.release(); +} + +#[test] +fn concurrent_different_install_roots_share_the_global_host_lock() { + let root = tempdir().unwrap(); + let first_install_dir = root.path().join("first-install"); + let second_install_dir = root.path().join("second-install"); + let global_lock_dir = root.path().join("global-operation-locks"); + let synchronization = tempdir().unwrap(); + let holder = CrossProcessLockHolder::spawn( + OPERATION_LOCK_HELPER_DIR_ENV, + &first_install_dir, + Some(&global_lock_dir), + synchronization.path(), + ); + let runner = MockRunner::default(); + let setup_runner = MockSetupRunner::default(); + let mut second_options = options(&second_install_dir); + second_options.operation_lock_dir = global_lock_dir; + + let install_error = install_host_with_operation_timeout( + PluginHost::Codex, + &second_options, + &runner, + &setup_runner, + Duration::from_millis(75), + ) + .unwrap_err(); + + assert!(install_error.contains("global lock"), "{install_error}"); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + holder.release(); +} + +#[test] +fn generation_retirement_lock_contention_is_bounded_across_processes() { + let dir = tempdir().unwrap(); + let synchronization = tempdir().unwrap(); + let generation = dir.path().join(GENERATION_FILE_NAME); + crate::install_generation::write_new_generation(&generation).unwrap(); + let holder = CrossProcessLockHolder::spawn( + GENERATION_LOCK_HELPER_PATH_ENV, + &generation, + None, + synchronization.path(), + ); + + let error = GenerationRetirement::acquire_with_timeout(&generation, Duration::from_millis(75)) + .err() + .expect("contended generation retirement must time out"); + + assert!(error.contains("timed out waiting for MCP install generation lock")); + holder.release(); +} + #[test] fn default_install_dir_follows_platform_conventions() { assert_eq!( @@ -340,34 +1004,252 @@ fn plugin_manifests_and_hooks_use_path_based_relay_command() { json!(PLUGIN_NAME) ); assert_eq!( - plugin_hooks(PluginHost::Codex)["hooks"]["SessionStart"][0]["hooks"][0]["command"], - json!("nemo-relay plugin-shim hook codex") + plugin_manifest(PluginHost::Codex)["mcpServers"], + json!("./.mcp.json") ); + let generation_fence = std::env::current_dir() + .unwrap() + .join("plugins/nemo-relay-plugin/.nemo-relay-generation"); + let mcp = plugin_mcp_config( + PluginHost::Codex, + Path::new("/bin/nemo-relay"), + &generation_fence, + ) + .unwrap() + .unwrap(); + let server = &mcp["nemo-relay"]; + assert_eq!(server["command"], json!("/bin/nemo-relay")); + assert_eq!(server["args"], json!(["mcp"])); assert_eq!( - plugin_hooks(PluginHost::ClaudeCode)["hooks"]["SessionStart"][0]["hooks"][0]["command"], - json!("nemo-relay plugin-shim hook claude") + server["env"], + json!({ + "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632", + "NEMO_RELAY_MCP_GENERATION_FILE": &generation_fence + }) ); -} - -#[test] -fn plugin_setup_delegates_and_dry_run_skips_runner_calls() { - let dir = tempdir().unwrap(); - let setup_runner = MockSetupRunner::default(); - let dry_run = PluginInstallOptions { + assert_eq!(server["required"], json!(true)); + assert_eq!(server["startup_timeout_sec"], json!(20)); + assert!( + server["env_vars"] + .as_array() + .unwrap() + .contains(&json!("OPENAI_API_KEY")) + ); + assert!( + plugin_mcp_config( + PluginHost::ClaudeCode, + Path::new("/bin/nemo-relay"), + &generation_fence, + ) + .unwrap() + .is_none() + ); + assert_eq!( + plugin_hooks(PluginHost::Codex, Path::new("/bin/nemo-relay"))["hooks"]["SessionStart"][0]["hooks"] + [0]["command"], + json!(crate::plugin_shim::codex_plugin_hook_command(Path::new( + "/bin/nemo-relay" + ))) + ); + assert_eq!( + plugin_hooks(PluginHost::ClaudeCode, Path::new("/bin/nemo-relay"))["hooks"]["SessionStart"] + [0]["hooks"][0]["command"], + json!("nemo-relay plugin-shim hook claude") + ); +} + +#[test] +fn relay_identity_uses_running_executable_when_path_points_elsewhere() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_current_executable("/opt/nemo-relay/current/nemo-relay") + .with_executable("nemo-relay", "/opt/nemo-relay/stale/nemo-relay"); + + let relay = require_relay(&options(dir.path()), &runner).unwrap(); + + assert_eq!(relay, PathBuf::from("/opt/nemo-relay/current/nemo-relay")); + assert_eq!( + plugin_hooks(PluginHost::Codex, &relay)["hooks"]["SessionStart"][0]["hooks"][0]["command"], + json!(crate::plugin_shim::codex_plugin_hook_command(&relay)) + ); + assert_eq!( + plugin_mcp_config( + PluginHost::Codex, + &relay, + Path::new("/plugins/nemo-relay-plugin/.nemo-relay-generation"), + ) + .unwrap() + .unwrap()["nemo-relay"]["command"], + json!(relay) + ); +} + +#[test] +fn codex_mcp_env_vars_include_approved_dynamic_and_config_references_only() { + let config = json!({ + "components": [{ + "kind": "observability", + "config": { + "atof": { + "storage": [ + {"header_env": {"authorization": "CUSTOM_HTTP_TOKEN"}}, + {"header_env": { + "blocked": "NEMO_RELAY_PLUGIN_BINARY", + "blocked_mixed_case": "NEMO_RELAY_Plugin_Binary", + "empty": "" + }}, + { + "secret_access_key_var": "CUSTOM_AWS_SECRET", + "session_token_var": "CUSTOM_AWS_SESSION" + }, + { + "secret_access_key_var": "NEMO_RELAY_GATEWAY_BIND", + "session_token_var": "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN" + } + ] + } + } + }] + }); + let names = plugin_mcp_env_vars_from( + [ + "NEMO_RELAY_CUSTOM_SETTING", + "OTEL_CUSTOM_SETTING", + "AWS_CUSTOM_SETTING", + "NEMO_RELAY_WORKER_TOKEN", + "NEMO_RELAY_PLUGIN_BINARY", + "NEMO_RELAY_Plugin_Binary", + "NEMO_RELAY_GATEWAY_BIND", + "NEMO_RELAY_MCP_GENERATION_FILE", + "NEMO_RELAY_FAIL_CLOSED", + "NEMO_RELAY_TEST_CODEX_LOG", + "NEMO_RELAY_Test_CodeX_Log", + ] + .map(str::to_string), + Some(&config), + ); + + assert!(names.is_sorted()); + for expected in [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "NEMO_RELAY_CUSTOM_SETTING", + "OTEL_CUSTOM_SETTING", + "AWS_CUSTOM_SETTING", + "CUSTOM_HTTP_TOKEN", + "CUSTOM_AWS_SECRET", + "CUSTOM_AWS_SESSION", + ] { + assert!( + names.iter().any(|name| name == expected), + "missing {expected}" + ); + } + for excluded in [ + "NEMO_RELAY_WORKER_TOKEN", + "NEMO_RELAY_PLUGIN_BINARY", + "NEMO_RELAY_Plugin_Binary", + "NEMO_RELAY_GATEWAY_BIND", + "NEMO_RELAY_MCP_GENERATION_FILE", + "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", + "NEMO_RELAY_FAIL_CLOSED", + "NEMO_RELAY_TEST_CODEX_LOG", + "NEMO_RELAY_Test_CodeX_Log", + "", + ] { + assert!( + !names.iter().any(|name| name == excluded), + "included {excluded}" + ); + } +} + +#[test] +fn codex_mcp_env_vars_match_and_deduplicate_names_using_platform_semantics() { + let config = json!({ + "header_env": { + "role": "AWS_ROLE_ARN", + "api_key": "openai_api_key" + } + }); + let environment = ["Aws_Role_Arn", "Otel_Custom_Signal"].map(str::to_string); + + let windows = crate::mcp_environment::forwarded_names_for_platform( + environment.clone(), + Some(&config), + true, + ); + assert!(windows.iter().any(|name| name == "Otel_Custom_Signal")); + assert_eq!( + windows + .iter() + .filter(|name| name.eq_ignore_ascii_case("AWS_ROLE_ARN")) + .count(), + 1 + ); + assert_eq!( + windows + .iter() + .filter(|name| name.eq_ignore_ascii_case("OPENAI_API_KEY")) + .count(), + 1 + ); + assert!(windows.iter().any(|name| name == "OPENAI_API_KEY")); + + let unix = + crate::mcp_environment::forwarded_names_for_platform(environment, Some(&config), false); + assert!(!unix.iter().any(|name| name == "Otel_Custom_Signal")); + assert!(!unix.iter().any(|name| name == "Aws_Role_Arn")); + assert!(unix.iter().any(|name| name == "AWS_ROLE_ARN")); + assert!(unix.iter().any(|name| name == "OPENAI_API_KEY")); + assert!(unix.iter().any(|name| name == "openai_api_key")); +} + +#[test] +fn plugin_setup_delegates_and_dry_run_skips_runner_calls() { + let dir = tempdir().unwrap(); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let setup_runner = MockSetupRunner::default(); + let dry_run = PluginInstallOptions { dry_run: true, ..options(dir.path()) }; - run_plugin_setup(PluginHost::Codex, &dry_run, &setup_runner).unwrap(); - run_plugin_uninstall(PluginHost::ClaudeCode, &dry_run, &setup_runner).unwrap(); - run_plugin_doctor(PluginHost::Codex, &dry_run, &setup_runner).unwrap(); + run_plugin_setup(PluginHost::Codex, &layout, &dry_run, &setup_runner).unwrap(); + run_plugin_uninstall( + PluginHost::ClaudeCode, + &layout.plugin_root, + &dry_run, + &setup_runner, + ) + .unwrap(); + run_plugin_doctor( + PluginHost::Codex, + &layout.plugin_root, + &dry_run, + &setup_runner, + ) + .unwrap(); assert!(setup_runner.calls().is_empty()); let normal = options(dir.path()); - run_plugin_setup(PluginHost::Codex, &normal, &setup_runner).unwrap(); - run_plugin_uninstall(PluginHost::ClaudeCode, &normal, &setup_runner).unwrap(); - run_plugin_doctor(PluginHost::Codex, &normal, &setup_runner).unwrap(); - let report = run_plugin_doctor_json(PluginHost::ClaudeCode, &setup_runner).unwrap(); + run_plugin_setup(PluginHost::Codex, &layout, &normal, &setup_runner).unwrap(); + run_plugin_uninstall( + PluginHost::ClaudeCode, + &layout.plugin_root, + &normal, + &setup_runner, + ) + .unwrap(); + run_plugin_doctor( + PluginHost::Codex, + &layout.plugin_root, + &normal, + &setup_runner, + ) + .unwrap(); + let report = + run_plugin_doctor_json(PluginHost::ClaudeCode, &layout.plugin_root, &setup_runner).unwrap(); assert_eq!( setup_runner.calls(), @@ -382,45 +1264,29 @@ fn plugin_setup_delegates_and_dry_run_skips_runner_calls() { } #[test] -fn real_plugin_setup_runner_uses_temp_home_for_codex_and_claude_paths() { +fn real_plugin_setup_runner_uses_temp_home_for_claude_paths() { let dir = tempdir().unwrap(); let _home = HomeScope::enter(dir.path()); let runner = RealPluginSetupRunner; + let plugin_root = dir.path().join("plugin"); runner - .setup(PluginHost::Codex, DEFAULT_GATEWAY_URL) + .setup(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) .unwrap(); assert!( runner - .doctor(PluginHost::Codex, DEFAULT_GATEWAY_URL) - .is_ok() - ); - let codex_report = runner - .doctor_json(PluginHost::Codex, DEFAULT_GATEWAY_URL) - .unwrap(); - assert_eq!(codex_report["checks"]["codex_provider_alias"], json!(true)); - assert_eq!(codex_report["checks"]["codex_hooks"], json!(true)); - runner - .uninstall(PluginHost::Codex, DEFAULT_GATEWAY_URL) - .unwrap(); - - runner - .setup(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL) - .unwrap(); - assert!( - runner - .doctor(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL) + .doctor(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) .is_ok() ); let claude_report = runner - .doctor_json(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL) + .doctor_json(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) .unwrap(); assert_eq!( claude_report["checks"]["claude_provider_routing"], json!(true) ); runner - .uninstall(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL) + .uninstall(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) .unwrap(); } @@ -428,15 +1294,15 @@ fn real_plugin_setup_runner_uses_temp_home_for_codex_and_claude_paths() { fn setup_action_descriptions_cover_supported_hosts_and_actions() { assert_eq!( setup_action_description(PluginHost::Codex, "configure"), - "configure Codex provider and hook-supervised lazy startup" + "configure Codex provider and trust plugin-owned hooks" ); assert_eq!( setup_action_description(PluginHost::Codex, "restore"), - "restore Codex provider and generated hook configuration" + "remove Codex provider and plugin hook trust" ); assert_eq!( setup_action_description(PluginHost::Codex, "doctor"), - "check Codex provider and generated hooks" + "check Codex provider and plugin-owned hooks" ); assert_eq!( setup_action_description(PluginHost::ClaudeCode, "configure"), @@ -467,6 +1333,7 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { ); require_host_cli(PluginHost::Codex, &dry_run, &runner).unwrap(); validate_relay_plugin_shim(Path::new("nemo-relay"), &dry_run, &runner).unwrap(); + validate_relay_mcp(Path::new("nemo-relay"), &dry_run, &runner).unwrap(); run_command( "codex", &["plugin".into(), "add space".into()], @@ -521,6 +1388,12 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { .unwrap_err() .contains("plugin-shim hook") ); + runner.failing_quiet_suffix = Some("mcp --help".into()); + assert!( + validate_relay_mcp(Path::new("/bin/nemo-relay"), &normal, &runner) + .unwrap_err() + .contains("nemo-relay mcp") + ); runner.failing_suffix = Some("plugin add".into()); assert!( run_path_command( @@ -693,7 +1566,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { let dir = tempdir().unwrap(); let empty_path = dir.path().join("empty-path"); std::fs::create_dir_all(&empty_path).unwrap(); - let _path = PathScope::set(&empty_path); + let _path = PathScope::set_isolated(&empty_path, &dir.path().join("home")); let install_error = install(crate::config::InstallCommand { host: PluginHost::All, @@ -756,18 +1629,6 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { "error was: {codex_doctor_error}" ); - let codex_uninstall_error = uninstall(crate::config::UninstallCommand { - host: PluginHost::Codex, - install_dir: Some(dir.path().join("install")), - dry_run: false, - }) - .unwrap_err() - .to_string(); - assert!( - codex_uninstall_error.contains("required `codex` CLI"), - "error was: {codex_uninstall_error}" - ); - assert_eq!(host_arg(PluginHost::All), "all"); assert_eq!(host_label(PluginHost::All), "all"); print_json(&json!({"ok": true})).unwrap(); @@ -833,9 +1694,11 @@ fn install_codex_generates_marketplace_and_runs_setup() { .unwrap(); let layout = PluginLayout::new(PluginHost::Codex, dir.path()); - assert!( - !layout.hooks_path.exists(), - "generated Codex marketplace must not also install plugin hook templates" + InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + assert_eq!( + serde_json::from_str::(&std::fs::read_to_string(&layout.hooks_path).unwrap()) + .unwrap(), + plugin_hooks(PluginHost::Codex, Path::new("/bin/nemo-relay")) ); assert_eq!( runner.commands(), @@ -847,7 +1710,21 @@ fn install_codex_generates_marketplace_and_runs_setup() { "/bin/codex plugin add nemo-relay-plugin@nemo-relay-local".into(), ] ); - assert_eq!(runner.quiet_commands(), vec![relay_validation_command()]); + assert_eq!( + runner.quiet_commands(), + vec![relay_validation_command(), relay_mcp_validation_command()] + ); + assert_eq!( + serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) + .unwrap(), + plugin_mcp_config( + PluginHost::Codex, + Path::new("/bin/nemo-relay"), + &layout.generation_fence, + ) + .unwrap() + .unwrap() + ); assert_eq!( setup_runner.calls(), vec![format!("setup codex {DEFAULT_GATEWAY_URL}")] @@ -878,67 +1755,660 @@ fn install_prunes_stale_managed_plugin_root() { assert!(layout.plugin_manifest.exists()); } +#[test] +fn ordinary_codex_reinstall_refuses_a_fenced_install_without_mutating_it() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let sentinel = layout.plugin_root.join("existing-install"); + std::fs::write(&sentinel, b"preserve").unwrap(); + let state = std::fs::read(&layout.state_path).unwrap(); + let generation = std::fs::read(&layout.generation_fence).unwrap(); + + let error = install_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("existing fenced Codex plugin"), "{error}"); + assert!( + error.contains("nemo-relay install codex --force"), + "{error}" + ); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"preserve"); + assert_eq!(std::fs::read(&layout.state_path).unwrap(), state); + assert_eq!(std::fs::read(&layout.generation_fence).unwrap(), generation); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + assert_no_install_stage(dir.path()); +} + +#[test] +fn ordinary_codex_reinstall_refuses_a_legacy_install_before_staging() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::remove_file(&layout.generation_fence).unwrap(); + let state = std::fs::read(&layout.state_path).unwrap(); + + let error = install_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert_actionable_generation_error(&error, "MCP generation marker is missing"); + assert_eq!(std::fs::read(&layout.state_path).unwrap(), state); + assert!(layout.marketplace_root.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + assert_no_install_stage(dir.path()); +} + +#[test] +fn ordinary_codex_reinstall_refuses_a_registration_without_local_state() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert_actionable_generation_error(&error, "MCP generation marker is missing"); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + assert_no_install_stage(dir.path()); +} + +#[test] +fn ordinary_codex_reinstall_refuses_a_corrupt_install_before_staging() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::write(&layout.generation_fence, b"").unwrap(); + let state = std::fs::read(&layout.state_path).unwrap(); + + let error = install_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert_actionable_generation_error(&error, "is invalid or unreadable"); + assert!(error.contains("is empty"), "{error}"); + assert_eq!(std::fs::read(&layout.state_path).unwrap(), state); + assert!(layout.marketplace_root.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + assert_no_install_stage(dir.path()); +} + #[test] fn force_install_unregisters_existing_host_before_reinstall() { let dir = tempdir().unwrap(); let runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(PluginHost::Codex, dir.path()); + + install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + + let commands = runner.commands(); + let remove_index = commands + .iter() + .position(|command| { + command == "/bin/codex plugin remove nemo-relay-plugin@nemo-relay-local" + }) + .unwrap(); + let add_index = commands + .iter() + .position(|command| command.ends_with("plugin add nemo-relay-plugin@nemo-relay-local")) + .unwrap(); + assert!(remove_index < add_index); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == "snapshot codex") + ); + assert!( + setup_runner + .calls() + .iter() + .all(|call| call != &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + ); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == "refresh codex") + ); + let setup_calls = setup_runner.calls(); + let refresh_index = setup_calls + .iter() + .position(|call| call == "refresh codex") + .unwrap(); + let snapshot_index = setup_calls + .iter() + .position(|call| call == "snapshot codex") + .unwrap(); + assert!(snapshot_index < refresh_index); +} + +#[test] +fn force_install_retires_previous_mcp_generation() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); let setup_runner = MockSetupRunner::default(); let options = PluginInstallOptions { force: true, ..options(dir.path()) }; write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + + install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + + let error = previous.verify_current().unwrap_err(); + assert!(error.contains("has been retired")); + InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + let mcp = serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) + .unwrap(); + assert_eq!( + mcp["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION_FILE"], + json!(layout.generation_fence) + ); +} + +#[test] +fn force_install_replaces_a_relocated_fenced_install_without_old_residue() { + let dir = tempdir().unwrap(); + let selected_dir = dir.path().join("selected"); + let relocated_dir = dir.path().join("relocated"); + let relocated = write_relocated_codex_install(&selected_dir, &relocated_dir); + let sentinel = relocated.plugin_root.join("relocated-install"); + std::fs::write(&sentinel, "preserve-until-commit").unwrap(); + let previous = InstallGeneration::capture(relocated.generation_fence.clone()).unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + let install_options = PluginInstallOptions { + force: true, + ..options(&selected_dir) + }; + + install_host(PluginHost::Codex, &install_options, &runner, &setup_runner).unwrap(); + + let current = PluginLayout::new(PluginHost::Codex, &selected_dir); + assert!(current.marketplace_root.exists()); + assert!(!relocated.marketplace_root.exists()); + assert!(!sentinel.exists()); + assert!(previous.verify_current().unwrap_err().contains("retired")); + let state = read_state(PluginHost::Codex, &selected_dir).unwrap(); + assert_eq!(state.marketplace_root, current.marketplace_root); + assert_eq!(state.plugin_root, current.plugin_root); + assert_no_force_replacement_residue(&selected_dir); + assert_no_force_replacement_residue(&relocated_dir); +} + +#[test] +fn force_install_rollback_restores_a_relocated_fenced_install_and_registration() { + let dir = tempdir().unwrap(); + let selected_dir = dir.path().join("selected"); + let relocated_dir = dir.path().join("relocated"); + let relocated = write_relocated_codex_install(&selected_dir, &relocated_dir); + let sentinel = relocated.plugin_root.join("relocated-install"); + std::fs::write(&sentinel, "restore-exactly").unwrap(); + let original_state = std::fs::read(state_path(PluginHost::Codex, &selected_dir)).unwrap(); + let previous = InstallGeneration::capture(relocated.generation_fence.clone()).unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration_sequence(&[(true, true), (false, false), (false, false)]); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("doctor codex {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + let install_options = PluginInstallOptions { + force: true, + skip_doctor: false, + ..options(&selected_dir) + }; + + let error = + install_host(PluginHost::Codex, &install_options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("doctor codex"), "{error}"); + let current = PluginLayout::new(PluginHost::Codex, &selected_dir); + assert!(!current.marketplace_root.exists()); + assert!(relocated.marketplace_root.exists()); + assert_eq!( + std::fs::read_to_string(&sentinel).unwrap(), + "restore-exactly" + ); + previous.verify_current().unwrap(); + assert_eq!( + std::fs::read(state_path(PluginHost::Codex, &selected_dir)).unwrap(), + original_state + ); + let marketplace_adds = runner + .commands() + .into_iter() + .filter(|command| command.contains("plugin marketplace add")) + .collect::>(); + assert_eq!( + marketplace_adds.last(), + Some(&format!( + "/bin/codex plugin marketplace add {}", + relocated.marketplace_root.display() + )) + ); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == "restore snapshot") + ); + assert_no_force_replacement_residue(&selected_dir); + assert_no_force_replacement_residue(&relocated_dir); +} + +#[test] +fn force_install_rejects_registered_legacy_plugin_without_generation_fence() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_capture_output( + "/bin/codex plugin list", + "nemo-relay-plugin@nemo-relay-local installed, enabled\n", + ) + .with_capture_output( + "/bin/codex plugin marketplace list", + "nemo-relay-local /tmp/nemo-relay-local\n", + ); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + + let error = install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + + assert!( + error.contains("MCP generation marker is missing"), + "{error}" + ); + assert!(error.contains("close all Codex clients"), "{error}"); + assert!(error.contains("codex plugin remove"), "{error}"); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + assert_no_install_stage(dir.path()); +} + +#[test] +fn force_install_rejects_unregistered_legacy_plugin_without_generation_fence() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::remove_file(&layout.generation_fence).unwrap(); + + let error = install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + + assert!( + error.contains("MCP generation marker is missing"), + "{error}" + ); + assert!(layout.marketplace_root.exists()); + assert!(layout.state_path.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + assert_no_install_stage(dir.path()); +} + +#[test] +fn force_install_rejects_corrupt_generation_marker_without_mutating() { + for (corruption, cause) in [ + ("empty", "is empty"), + ("oversized", "exceeds the 128-byte limit"), + ("unreadable", "failed to open"), + ] { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + corrupt_generation_fence(&layout.generation_fence, corruption); + + let error = install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + + assert_actionable_generation_error(&error, "is invalid or unreadable"); + assert!(error.contains(cause), "{corruption}: {error}"); + assert!(layout.marketplace_root.exists()); + assert!(layout.state_path.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + assert_no_install_stage(dir.path()); + } +} + +#[test] +fn force_install_allows_a_clean_first_install_without_generation_fence() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); - let commands = runner.commands(); - let remove_index = commands - .iter() - .position(|command| { - command == "/bin/codex plugin remove nemo-relay-plugin@nemo-relay-local" - }) - .unwrap(); - let add_index = commands - .iter() - .position(|command| command.ends_with("plugin add nemo-relay-plugin@nemo-relay-local")) - .unwrap(); - assert!(remove_index < add_index); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + assert!(layout.generation_fence.exists()); + assert!(layout.state_path.exists()); +} + +#[test] +fn force_install_uses_live_absent_registration_instead_of_stale_installed_state() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(PluginHost::Codex, dir.path()); + + install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + + let commands = runner.commands(); + assert!( + commands + .iter() + .all(|command| !command.contains("plugin remove") + && !command.contains("marketplace remove")), + "unexpected removal commands: {commands:?}" + ); + assert!( + commands + .iter() + .any(|command| command.ends_with("plugin add nemo-relay-plugin@nemo-relay-local")) + ); +} + +#[test] +fn force_install_uses_live_present_registration_instead_of_stale_removed_state() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_state_for_host( + PluginHost::Codex, + &PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: layout.plugin_root.clone(), + host_plugin_removed: true, + host_marketplace_removed: true, + plugin_setup_installed: true, + }, + dir.path(), + &options, + ) + .unwrap(); + + install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + + let commands = runner.commands(); + assert!(commands.iter().any(|command| { + command == "/bin/codex plugin remove nemo-relay-plugin@nemo-relay-local" + })); + assert!( + commands + .iter() + .any(|command| { command == "/bin/codex plugin marketplace remove nemo-relay-local" }) + ); +} + +#[test] +fn force_install_commit_does_not_fail_when_backup_cleanup_errors() { + let dir = tempdir().unwrap(); + let backup = dir.path().join("codex-marketplace-backup"); + std::fs::write(&backup, "not a directory").unwrap(); + + ForceInstallSnapshot { + state_bytes: None, + setup_snapshot: None, + plugin_registered: false, + marketplace_registered: false, + original_marketplace_root: dir.path().join("original-marketplace"), + original_plugin_root: dir + .path() + .join("original-marketplace/plugins/nemo-relay-plugin"), + original_generation_fence: dir + .path() + .join("original-marketplace/plugins/nemo-relay-plugin/.nemo-relay-generation"), + backup_marketplace_root: backup.clone(), + backup_plugin_root: None, + marketplace_moved: true, + plugin_moved: false, + replacement_promoted: true, + generation_retirement: None, + } + .commit(); + + assert!(backup.is_file()); +} + +#[test] +fn force_install_keeps_existing_registration_when_gateway_refresh_fails() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner { + failing_call: Some("refresh codex".into()), + ..MockSetupRunner::default() + }; + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + + let error = install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("refresh codex failed")); + assert!(layout.state_path.exists()); + assert!(layout.plugin_root.exists()); + previous.verify_current().unwrap(); + assert_eq!( + runner.commands(), + vec![ + "/bin/codex plugin remove nemo-relay-plugin@nemo-relay-local".to_string(), + "/bin/codex plugin marketplace remove nemo-relay-local".to_string(), + ] + ); + assert_eq!( + setup_runner.calls(), + vec![ + "snapshot codex".to_string(), + "refresh codex".to_string(), + "restore snapshot".to_string(), + ] + ); +} + +#[test] +fn force_install_restores_previous_install_after_doctor_failure() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("doctor codex {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + let options = PluginInstallOptions { + force: true, + skip_doctor: false, + ..options(dir.path()) + }; + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let sentinel = layout.plugin_root.join("previous-install"); + std::fs::write(&sentinel, "preserve").unwrap(); + let original_state = std::fs::read(&layout.state_path).unwrap(); + + let error = install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("doctor codex"), "{error}"); + assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "preserve"); + assert_eq!(std::fs::read(&layout.state_path).unwrap(), original_state); assert!( setup_runner .calls() .iter() - .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + .any(|call| call == "restore snapshot") + ); + let setup_calls = setup_runner.calls(); + let refreshes = setup_calls + .iter() + .enumerate() + .filter(|(_, call)| call.as_str() == "refresh codex") + .map(|(index, _)| index) + .collect::>(); + assert_eq!( + refreshes.len(), + 2, + "the previous and replacement MCP generations must each be stopped: {setup_calls:?}" ); + let restore_index = setup_calls + .iter() + .position(|call| call == "restore snapshot") + .unwrap(); + assert!(refreshes[1] < restore_index); + assert!(std::fs::read_dir(dir.path()).unwrap().all(|entry| { + let name = entry.unwrap().file_name(); + let name = name.to_string_lossy(); + !name.contains("install-stage") && !name.contains("marketplace-backup") + })); } #[test] -fn force_install_without_state_unregisters_host_before_reinstall() { +fn force_install_does_not_uninstall_restored_setup_after_setup_failure() { let dir = tempdir().unwrap(); let runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - let setup_runner = MockSetupRunner::default(); + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("setup codex {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; let options = PluginInstallOptions { force: true, ..options(dir.path()) }; + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let sentinel = layout.plugin_root.join("previous-install"); + std::fs::write(&sentinel, "preserve").unwrap(); + let original_state = std::fs::read(&layout.state_path).unwrap(); - install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + let error = install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); - let commands = runner.commands(); - let remove_index = commands - .iter() - .position(|command| { - command == "/bin/codex plugin remove nemo-relay-plugin@nemo-relay-local" - }) - .unwrap(); - let add_index = commands - .iter() - .position(|command| command.ends_with("plugin add nemo-relay-plugin@nemo-relay-local")) - .unwrap(); - assert!(remove_index < add_index); + assert!(error.contains("setup codex"), "{error}"); + assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "preserve"); + assert_eq!(std::fs::read(&layout.state_path).unwrap(), original_state); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == "restore snapshot") + ); + assert!( + setup_runner + .calls() + .iter() + .all(|call| call != &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + ); } #[test] @@ -1022,6 +2492,31 @@ fn unsupported_relay_path_fails_before_generating_plugin() { ); } +#[test] +fn relay_without_native_mcp_fails_codex_install_before_generating_plugin() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_quiet_suffix = Some("mcp --help".into()); + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("native `nemo-relay mcp` support")); + assert!( + !PluginLayout::new(PluginHost::Codex, dir.path()) + .marketplace_root + .exists() + ); +} + #[test] fn setup_failure_rolls_back_generated_files_and_registration() { let dir = tempdir().unwrap(); @@ -1153,7 +2648,7 @@ fn plugin_registration_failure_rolls_back_marketplace_without_plugin_removal() { } #[test] -fn state_write_failure_removes_generated_marketplace() { +fn invalid_existing_state_fails_before_generating_marketplace() { let dir = tempdir().unwrap(); let runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") @@ -1170,7 +2665,7 @@ fn state_write_failure_removes_generated_marketplace() { ) .unwrap_err(); - assert!(error.contains("failed to write")); + assert!(error.contains("failed to snapshot"), "{error}"); assert!(!layout.marketplace_root.exists()); assert!(layout.state_path.exists()); assert!(runner.commands().is_empty()); @@ -1214,14 +2709,15 @@ fn retry_after_partial_registration_rollback_does_not_restore_uninstalled_setup( ) .unwrap(); - assert!( - setup_runner.calls().is_empty(), - "retry cleanup must not restore provider/hooks setup that install never reached" + assert_eq!( + setup_runner.calls(), + vec!["refresh codex"], + "retry cleanup may stop the gateway but must not restore provider/hooks setup that install never reached" ); } #[test] -fn retry_after_setup_attempted_rollback_restores_setup() { +fn retry_after_failed_codex_setup_does_not_uninstall_restored_setup() { let dir = tempdir().unwrap(); let mut runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") @@ -1244,7 +2740,7 @@ fn retry_after_setup_attempted_rollback_restores_setup() { let state = read_state(PluginHost::Codex, dir.path()).unwrap(); assert!(state.host_plugin_removed); assert!(!state.host_marketplace_removed); - assert!(state.plugin_setup_installed); + assert!(!state.plugin_setup_installed); let runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") @@ -1261,7 +2757,7 @@ fn retry_after_setup_attempted_rollback_restores_setup() { setup_runner .calls() .iter() - .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + .all(|call| call != &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) ); } @@ -1292,12 +2788,111 @@ fn uninstall_uses_installed_state_and_removes_marketplace() { assert!(!layout.marketplace_root.exists()); assert!(!layout.state_path.exists()); + let setup_calls = setup_runner.calls(); + let refresh_index = setup_calls + .iter() + .position(|call| call == "refresh codex") + .unwrap(); + let uninstall_index = setup_calls + .iter() + .position(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + .unwrap(); + assert!(refresh_index < uninstall_index); +} + +#[test] +fn uninstall_rejects_registered_legacy_plugin_without_generation_fence() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::remove_file(&layout.generation_fence).unwrap(); + + let error = uninstall_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + assert!( - setup_runner - .calls() - .iter() - .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + error.contains("MCP generation marker is missing"), + "{error}" + ); + assert!(error.contains("close all Codex clients"), "{error}"); + assert!(layout.marketplace_root.exists()); + assert!(layout.state_path.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); +} + +#[test] +fn uninstall_rejects_unregistered_legacy_plugin_without_generation_fence() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::remove_file(&layout.generation_fence).unwrap(); + + let error = uninstall_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!( + error.contains("MCP generation marker is missing"), + "{error}" ); + assert!(layout.marketplace_root.exists()); + assert!(layout.state_path.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); +} + +#[test] +fn uninstall_rejects_each_corrupt_generation_marker_actionably() { + for (corruption, cause) in [ + ("empty", "is empty"), + ("oversized", "exceeds the 128-byte limit"), + ("unreadable", "failed to open"), + ] { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + corrupt_generation_fence(&layout.generation_fence, corruption); + + let error = uninstall_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert_actionable_generation_error(&error, "is invalid or unreadable"); + assert!(error.contains(cause), "{corruption}: {error}"); + assert!(layout.marketplace_root.exists()); + assert!(layout.state_path.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + } } #[test] @@ -1358,12 +2953,92 @@ fn doctor_json_uses_quiet_plugin_report() { assert_eq!( runner.capture_commands(), vec![ + "/bin/codex --version", "/bin/codex plugin list", "/bin/codex plugin marketplace list" ] ); } +#[test] +fn doctor_uses_plugin_root_persisted_in_install_state() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + let install_options = options(dir.path()); + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let relocated_root = dir.path().join("relocated-plugin-root"); + std::fs::rename(&layout.plugin_root, &relocated_root).unwrap(); + write_state_for_host( + PluginHost::Codex, + &PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: relocated_root.clone(), + host_plugin_removed: false, + host_marketplace_removed: false, + plugin_setup_installed: true, + }, + dir.path(), + &install_options, + ) + .unwrap(); + + let _readiness = + collect_host_plugin_readiness(PluginHost::Codex, &install_options, &runner, &setup_runner); + + assert_eq!(setup_runner.doctor_roots(), vec![relocated_root]); +} + +#[test] +fn codex_doctor_reports_upgrade_remediation_for_old_and_malformed_versions() { + for (version_output, expected_detail) in [ + ("codex-cli 0.142.9\n", "requires codex-cli 0.143.0"), + ("codex nightly\n", "could not parse"), + ] { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true) + .with_capture_output("/bin/codex --version", version_output); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(PluginHost::Codex, dir.path()); + + let report = + doctor_host_json_value(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + let version_check = report["readiness_checks"] + .as_array() + .unwrap() + .iter() + .find(|check| check["name"] == "Codex version") + .unwrap(); + assert_eq!(report["ok"], json!(false)); + assert_eq!(version_check["ok"], json!(false)); + assert!( + version_check["details"] + .as_str() + .unwrap() + .contains(expected_detail) + ); + assert_eq!( + report["remediation"], + json!( + "upgrade Codex to codex-cli 0.143.0 or newer, then run `nemo-relay install codex --force`" + ) + ); + + let text_error = + doctor_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + assert!(text_error.contains("remediation: upgrade Codex")); + assert!(text_error.contains("codex-cli 0.143.0 or newer")); + } +} + #[test] fn readiness_report_marks_missing_generated_plugin_files_as_failed() { let dir = tempdir().unwrap(); @@ -1396,6 +3071,109 @@ fn readiness_report_marks_missing_generated_plugin_files_as_failed() { ); } +#[test] +fn readiness_report_rejects_missing_generated_mcp_server() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::remove_file(layout.mcp_config).unwrap(); + + let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); + + assert!(!report.ok()); + assert!(report.checks.iter().any(|check| { + check.name == "Generated MCP server" && !check.ok && check.details.contains("missing") + })); +} + +#[test] +fn readiness_report_rejects_missing_mcp_generation_fence() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::remove_file(layout.generation_fence).unwrap(); + + let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); + + assert!(!report.ok()); + assert!(report.checks.iter().any(|check| { + check.name == "MCP generation fence" + && !check.ok + && check.details.contains("failed to open") + })); +} + +#[test] +fn readiness_report_rejects_mcp_server_for_different_binary() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_json( + &layout.mcp_config, + &plugin_mcp_config( + PluginHost::Codex, + Path::new("/tmp/other-relay"), + &layout.generation_fence, + ) + .unwrap() + .unwrap(), + ) + .unwrap(); + + let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); + + assert!(!report.ok()); + assert!(report.checks.iter().any(|check| { + check.name == "Generated MCP server" && !check.ok && check.details.contains("unexpected") + })); +} + +#[test] +fn readiness_report_names_newly_required_mcp_env_vars_and_force_remediation() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let mut mcp: Value = + serde_json::from_str(&std::fs::read_to_string(&layout.mcp_config).unwrap()).unwrap(); + mcp["nemo-relay"]["env_vars"] + .as_array_mut() + .unwrap() + .retain(|name| name != "OPENAI_API_KEY"); + write_json(&layout.mcp_config, &mcp).unwrap(); + + let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); + + assert!(!report.ok()); + let check = report + .checks + .iter() + .find(|check| check.name == "Generated MCP server") + .unwrap(); + assert!(!check.ok); + assert!(check.details.contains("OPENAI_API_KEY")); + assert!(check.details.contains("nemo-relay install codex --force")); +} + #[test] fn readiness_report_rejects_invalid_generated_manifest_contents() { let dir = tempdir().unwrap(); @@ -1651,7 +3429,7 @@ fn doctor_fails_when_claude_host_marketplace_is_missing() { } #[test] -fn uninstall_host_failure_does_not_restore_plugin_setup() { +fn uninstall_cleans_up_plugin_setup_before_host_removal_failure() { let dir = tempdir().unwrap(); let mut runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") @@ -1668,14 +3446,17 @@ fn uninstall_host_failure_does_not_restore_plugin_setup() { .unwrap_err(); assert!(error.contains("plugin remove")); - assert!( - setup_runner.calls().is_empty(), - "provider/hook setup should not be restored until host unregister succeeds" + assert_eq!( + setup_runner.calls(), + vec![ + "refresh codex".to_string(), + format!("uninstall codex {DEFAULT_GATEWAY_URL}"), + ] ); } #[test] -fn uninstall_records_host_removal_phases_before_plugin_restore() { +fn uninstall_does_not_unregister_host_when_plugin_cleanup_fails() { let dir = tempdir().unwrap(); let runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") @@ -1696,8 +3477,9 @@ fn uninstall_records_host_removal_phases_before_plugin_restore() { assert!(error.contains("uninstall codex")); let state = read_state(PluginHost::Codex, dir.path()).unwrap(); - assert!(state.host_plugin_removed); - assert!(state.host_marketplace_removed); + assert!(!state.host_plugin_removed); + assert!(!state.host_marketplace_removed); + assert!(state.plugin_setup_installed); } #[test] @@ -1720,6 +3502,7 @@ fn uninstall_retry_skips_host_removal_after_prior_success() { &options(dir.path()), ) .unwrap(); + crate::install_generation::write_new_generation(&layout.generation_fence).unwrap(); uninstall_host( PluginHost::Codex, @@ -1754,6 +3537,7 @@ fn uninstall_retry_skips_plugin_removal_after_marketplace_failure() { let setup_runner = MockSetupRunner::default(); let layout = PluginLayout::new(PluginHost::Codex, dir.path()); write_state(&layout, &options(dir.path())).unwrap(); + crate::install_generation::write_new_generation(&layout.generation_fence).unwrap(); let error = uninstall_host( PluginHost::Codex, diff --git a/crates/cli/tests/coverage/plugin_shim_tests.rs b/crates/cli/tests/coverage/plugin_shim_tests.rs index 4ebbcbe19..17744c373 100644 --- a/crates/cli/tests/coverage/plugin_shim_tests.rs +++ b/crates/cli/tests/coverage/plugin_shim_tests.rs @@ -1,18 +1,283 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::collections::{BTreeSet, VecDeque}; use std::fs; use std::io::{Read, Write}; use std::net::TcpListener; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::thread; use std::time::{Duration, Instant}; use serde_json::{Value, json}; use tempfile::tempdir; +use toml_edit::{DocumentMut, Item}; use super::*; +#[derive(Default)] +struct FakeCodexHooksClient { + hook_lists: VecDeque, String>>, + trusted: Vec>, + cleared: Vec>, + restored: Vec)>>, + clear_config_path: Option, + trust_error: Option, + clear_error: Option, + restore_error: Option, +} + +impl CodexHooksClient for FakeCodexHooksClient { + fn list_hooks(&mut self, _cwd: &std::path::Path) -> Result, String> { + self.hook_lists + .pop_front() + .unwrap_or_else(|| Err("unexpected hooks/list call".into())) + } + + fn trust_hooks(&mut self, hooks: &[CodexHookMetadata]) -> Result<(), String> { + self.trusted + .push(hooks.iter().map(|hook| hook.key.clone()).collect()); + match self.trust_error.take() { + Some(error) => Err(error), + None => Ok(()), + } + } + + fn clear_hook_trust(&mut self, keys: &[String]) -> Result<(), String> { + self.cleared.push(keys.to_vec()); + if let Some(error) = self.clear_error.take() { + return Err(error); + } + if let Some(path) = &self.clear_config_path { + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let mut config = raw + .parse::() + .map_err(|error| format!("invalid TOML in {}: {error}", path.display()))?; + if let Some(state) = config + .get_mut("hooks") + .and_then(Item::as_table_mut) + .and_then(|hooks| hooks.get_mut("state")) + .and_then(Item::as_table_mut) + { + for key in keys { + state.remove(key); + } + } + fs::write(path, config.to_string()) + .map_err(|error| format!("failed to write {}: {error}", path.display()))?; + } + Ok(()) + } + + fn restore_hook_trust(&mut self, state: &[(String, Option)]) -> Result<(), String> { + self.restored.push(state.to_vec()); + match self.restore_error.take() { + Some(error) => Err(error), + None => Ok(()), + } + } +} + +fn expected_plugin_command() -> String { + expected_plugin_hook_command().unwrap() +} + +fn empty_codex_hooks_client() -> FakeCodexHooksClient { + FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(Vec::new())]), + ..FakeCodexHooksClient::default() + } +} + +fn write_plugin_hooks(plugin_root: &Path) -> PathBuf { + let path = plugin_root.join("hooks").join("hooks.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + serde_json::to_vec_pretty(&generated_hooks( + CodingAgent::Codex, + &expected_plugin_command(), + )) + .unwrap(), + ) + .unwrap(); + path +} + +fn codex_hook_metadata( + hooks_path: &std::path::Path, + event_name: &str, + key: &str, + trust_status: &str, + enabled: bool, +) -> CodexHookMetadata { + let hooks_path = if hooks_path.is_dir() { + hooks_path.join("hooks.json") + } else { + hooks_path.to_path_buf() + }; + if !hooks_path.exists() { + fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); + fs::write( + &hooks_path, + serde_json::to_vec_pretty(&generated_hooks( + CodingAgent::Codex, + &expected_plugin_command(), + )) + .unwrap(), + ) + .unwrap(); + } + CodexHookMetadata { + key: key.into(), + event_name: event_name.into(), + handler_type: "command".into(), + command: Some(expected_plugin_command()), + source_path: hooks_path.display().to_string(), + source: "plugin".into(), + plugin_id: Some(CODEX_PLUGIN_ID.into()), + enabled, + current_hash: format!("sha256:{key}"), + trust_status: trust_status.into(), + } +} + +fn required_codex_hook_metadata( + hooks_path: &std::path::Path, + trust_status: &str, + enabled: bool, +) -> Vec { + generated_codex_hook_metadata(hooks_path, trust_status, enabled) +} + +fn generated_codex_hook_metadata( + hooks_path: &std::path::Path, + trust_status: &str, + enabled: bool, +) -> Vec { + [ + "session_start", + "user_prompt_submit", + "pre_tool_use", + "post_tool_use", + "permission_request", + "subagent_start", + "subagent_stop", + "stop", + "pre_compact", + "post_compact", + ] + .into_iter() + .enumerate() + .map(|(index, event)| { + codex_hook_metadata( + hooks_path, + event, + &format!("relay-hook-{index}"), + trust_status, + enabled, + ) + }) + .collect() +} + +fn persisted_relay_hook_key(event: &str, index: usize) -> String { + format!("{CODEX_PLUGIN_HOOK_KEY_PREFIX}{event}:0:{index}") +} + +fn persisted_relay_hook_metadata(hooks_path: &Path, trust_status: &str) -> Vec { + let mut hooks = generated_codex_hook_metadata(hooks_path, trust_status, true) + .into_iter() + .enumerate() + .map(|(index, mut hook)| { + hook.key = persisted_relay_hook_key(&hook.event_name, index); + hook + }) + .collect::>(); + hooks.extend( + ["post_tool_use_failure", "notification", "session_end"] + .into_iter() + .enumerate() + .map(|(offset, event)| { + codex_hook_metadata( + hooks_path, + event, + &persisted_relay_hook_key(event, 10 + offset), + trust_status, + true, + ) + }), + ); + hooks +} + +fn write_persisted_hook_trust(config_path: &Path, keys: &[String], unrelated_key: &str) { + let mut raw = "model_provider = \"openai\"\n".to_string(); + for key in keys { + raw.push_str(&format!( + "\n[hooks.state.{key:?}]\ntrusted_hash = {hash:?}\nenabled = true\n", + hash = format!("sha256:{key}") + )); + } + raw.push_str(&format!( + "\n[hooks.state.{unrelated_key:?}]\ntrusted_hash = {hash:?}\nenabled = true\n", + hash = format!("sha256:{unrelated_key}") + )); + fs::write(config_path, raw).unwrap(); +} + +#[cfg(not(windows))] +fn fake_codex_app_server( + dir: &std::path::Path, + hooks: &[CodexHookMetadata], +) -> (EnvVarGuard, EnvVarGuard, EnvVarGuard) { + use std::os::unix::fs::PermissionsExt; + + let bin_dir = dir.join("fake-codex-bin"); + fs::create_dir_all(&bin_dir).unwrap(); + let codex = bin_dir.join("codex"); + fs::write( + &codex, + r#"#!/bin/sh +while IFS= read -r line; do + printf '%s\n' "$line" >> "$NEMO_RELAY_TEST_CODEX_LOG" + id=$(printf '%s\n' "$line" | sed -E 's/.*"id":([0-9]+).*/\1/') + case "$line" in + *'"method":"initialize"'*) + printf '{"id":%s,"result":{}}\n' "$id" + ;; + *'"method":"hooks/list"'*) + printf '{"id":%s,"result":{"data":[{"cwd":"/tmp","hooks":%s,"warnings":[],"errors":[]}]}}\n' "$id" "$NEMO_RELAY_TEST_CODEX_HOOKS" + ;; + *'"method":"config/batchWrite"'*) + printf '{"id":%s,"result":{}}\n' "$id" + ;; + esac +done +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(&codex).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&codex, permissions).unwrap(); + let existing_path = std::env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![bin_dir]; + paths.extend(std::env::split_paths(&existing_path)); + let path = std::env::join_paths(paths).unwrap(); + let log_path = dir.join("fake-codex-requests.jsonl"); + fs::write(&log_path, "").unwrap(); + ( + EnvVarGuard::set_value("PATH", &path.to_string_lossy()), + EnvVarGuard::set_value( + "NEMO_RELAY_TEST_CODEX_HOOKS", + &serde_json::to_string(hooks).unwrap(), + ), + EnvVarGuard::set_value("NEMO_RELAY_TEST_CODEX_LOG", &log_path.to_string_lossy()), + ) +} + fn read_http_request(stream: &mut std::net::TcpStream) -> Vec { stream .set_read_timeout(Some(Duration::from_secs(2))) @@ -68,6 +333,7 @@ struct HomeScope<'a> { _guard: std::sync::MutexGuard<'a, ()>, prev_home: Option, prev_userprofile: Option, + prev_codex_home: Option, } impl<'a> HomeScope<'a> { @@ -77,15 +343,18 @@ impl<'a> HomeScope<'a> { .unwrap_or_else(|error| error.into_inner()); let prev_home = std::env::var_os("HOME"); let prev_userprofile = std::env::var_os("USERPROFILE"); + let prev_codex_home = std::env::var_os("CODEX_HOME"); // SAFETY: This test holds a process-wide mutex for the lifetime of the env override. unsafe { std::env::set_var("HOME", path); std::env::remove_var("USERPROFILE"); + std::env::remove_var("CODEX_HOME"); } Self { _guard: guard, prev_home, prev_userprofile, + prev_codex_home, } } } @@ -102,6 +371,10 @@ impl<'a> Drop for HomeScope<'a> { Some(value) => std::env::set_var("USERPROFILE", value), None => std::env::remove_var("USERPROFILE"), } + match self.prev_codex_home.take() { + Some(value) => std::env::set_var("CODEX_HOME", value), + None => std::env::remove_var("CODEX_HOME"), + } } } } @@ -160,14 +433,19 @@ fn hook_with_io_defaults_blank_payload_and_writes_non_empty_response() { let seen_payload = std::cell::RefCell::new(Vec::new()); let status = hook_with_io( - CodingAgent::Codex, - Some("http://127.0.0.1:59999"), + HookInvocation { + agent: CodingAgent::Codex, + gateway_url: Some("http://127.0.0.1:59999"), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: false, + }, &mut input, &mut output, |agent, url| { ensured .borrow_mut() .push((agent.as_arg().to_string(), url.to_string())); + Ok(()) }, |agent, url, payload| { assert_eq!(agent, CodingAgent::Codex); @@ -182,10 +460,188 @@ fn hook_with_io_defaults_blank_payload_and_writes_non_empty_response() { assert_eq!(status, ExitCode::SUCCESS); assert_eq!(&*seen_payload.borrow(), b"{}"); assert_eq!(output, br#"{"decision":"allow"}"#); - assert_eq!( - ensured.into_inner(), - vec![("codex".to_string(), "http://127.0.0.1:59999".to_string())] - ); + assert!(ensured.into_inner().is_empty()); +} + +#[test] +fn hook_payload_reader_rejects_bytes_beyond_its_limit() { + let mut input = std::io::Cursor::new(b"12345".to_vec()); + let mut payload = Vec::new(); + + let error = read_hook_payload(&mut input, &mut payload, 4).unwrap_err(); + + assert!(error.contains("exceeds the 4-byte limit")); + assert_eq!(payload, b"12345"); +} + +#[test] +fn hook_with_io_applies_fail_open_and_fail_closed_to_oversized_stdin() { + for fail_closed in [false, true] { + let mut input = std::io::Cursor::new(b"12345".to_vec()); + let mut output = Vec::new(); + let result = hook_with_io( + HookInvocation { + agent: CodingAgent::Codex, + gateway_url: Some(DEFAULT_URL), + max_payload_bytes: 4, + preflight_gateway: true, + }, + &mut input, + &mut output, + |_agent, _url| panic!("oversized input must not bootstrap the gateway"), + |_agent, _url, _payload| panic!("oversized input must not be forwarded"), + || fail_closed, + ); + + if fail_closed { + assert!(result.unwrap_err().contains("exceeds the 4-byte limit")); + } else { + assert_eq!(result.unwrap(), ExitCode::SUCCESS); + } + assert!(output.is_empty()); + } +} + +#[test] +fn hook_with_io_applies_fail_open_and_fail_closed_to_stdin_read_errors() { + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("synthetic stdin failure")) + } + } + + for fail_closed in [false, true] { + let mut input = FailingReader; + let mut output = Vec::new(); + let result = hook_with_io( + HookInvocation { + agent: CodingAgent::Codex, + gateway_url: Some(DEFAULT_URL), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: true, + }, + &mut input, + &mut output, + |_agent, _url| panic!("unreadable input must not bootstrap the gateway"), + |_agent, _url, _payload| panic!("unreadable input must not be forwarded"), + || fail_closed, + ); + + if fail_closed { + let error = result.unwrap_err(); + assert!(error.contains("failed to read hook payload")); + assert!(error.contains("synthetic stdin failure")); + } else { + assert_eq!(result.unwrap(), ExitCode::SUCCESS); + } + assert!(output.is_empty()); + } +} + +#[test] +fn hook_with_io_bootstraps_and_retries_once_only_for_pre_send_connection_failure() { + let mut input = std::io::Cursor::new(br#"{"event":"prompt"}"#.to_vec()); + let mut output = Vec::new(); + let ensures = std::cell::Cell::new(0); + let posts = std::cell::Cell::new(0); + + let status = hook_with_io( + HookInvocation { + agent: CodingAgent::Codex, + gateway_url: Some("http://127.0.0.1:59997"), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: false, + }, + &mut input, + &mut output, + |_agent, _url| { + ensures.set(ensures.get() + 1); + Ok(()) + }, + |_agent, _url, _payload| { + posts.set(posts.get() + 1); + if posts.get() == 1 { + Err(HookForwardError::retryable( + "connection refused before send", + )) + } else { + Ok(b"retried".to_vec()) + } + }, + || true, + ) + .unwrap(); + + assert_eq!(status, ExitCode::SUCCESS); + assert_eq!(output, b"retried"); + assert_eq!(ensures.get(), 1); + assert_eq!(posts.get(), 2); +} + +#[test] +fn hook_with_io_does_not_bootstrap_or_retry_ambiguous_forward_failure() { + let mut input = std::io::Cursor::new(b"{}".to_vec()); + let mut output = Vec::new(); + let ensures = std::cell::Cell::new(0); + let posts = std::cell::Cell::new(0); + + let error = hook_with_io( + HookInvocation { + agent: CodingAgent::Codex, + gateway_url: Some(DEFAULT_URL), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: false, + }, + &mut input, + &mut output, + |_agent, _url| { + ensures.set(ensures.get() + 1); + Ok(()) + }, + |_agent, _url, _payload| { + posts.set(posts.get() + 1); + Err(HookForwardError::not_retryable("response read failed")) + }, + || true, + ) + .unwrap_err(); + + assert!(error.contains("response read failed")); + assert_eq!(ensures.get(), 0); + assert_eq!(posts.get(), 1); +} + +#[test] +fn hook_with_io_propagates_bootstrap_error_without_second_post() { + let mut input = std::io::Cursor::new(b"{}".to_vec()); + let mut output = Vec::new(); + let posts = std::cell::Cell::new(0); + + let error = hook_with_io( + HookInvocation { + agent: CodingAgent::Codex, + gateway_url: Some(DEFAULT_URL), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: false, + }, + &mut input, + &mut output, + |_agent, _url| Err("startup log says bind failed".into()), + |_agent, _url, _payload| { + posts.set(posts.get() + 1); + Err(HookForwardError::retryable( + "connection refused before send", + )) + }, + || true, + ) + .unwrap_err(); + + assert!(error.contains("sidecar bootstrap failed")); + assert!(error.contains("startup log says bind failed")); + assert_eq!(posts.get(), 1); } #[test] @@ -193,12 +649,16 @@ fn hook_with_io_applies_fail_open_and_fail_closed_forwarding_policies() { let mut input = std::io::Cursor::new(br#"{"event":"tool"}"#.to_vec()); let mut output = Vec::new(); let status = hook_with_io( - CodingAgent::ClaudeCode, - Some("http://127.0.0.1:59998"), + HookInvocation { + agent: CodingAgent::ClaudeCode, + gateway_url: Some("http://127.0.0.1:59998"), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: false, + }, &mut input, &mut output, - |_agent, _url| {}, - |_agent, _url, _payload| Err("forward failed open".to_string()), + |_agent, _url| Ok(()), + |_agent, _url, _payload| Err(HookForwardError::not_retryable("forward failed open")), || false, ) .unwrap(); @@ -209,12 +669,16 @@ fn hook_with_io_applies_fail_open_and_fail_closed_forwarding_policies() { let mut input = std::io::Cursor::new(br#"{"event":"tool"}"#.to_vec()); let mut output = Vec::new(); let error = hook_with_io( - CodingAgent::ClaudeCode, - Some("http://127.0.0.1:59998"), + HookInvocation { + agent: CodingAgent::ClaudeCode, + gateway_url: Some("http://127.0.0.1:59998"), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: false, + }, &mut input, &mut output, - |_agent, _url| {}, - |_agent, _url, _payload| Err("forward failed closed".to_string()), + |_agent, _url| Ok(()), + |_agent, _url, _payload| Err(HookForwardError::not_retryable("forward failed closed")), || true, ) .unwrap_err(); @@ -223,6 +687,75 @@ fn hook_with_io_applies_fail_open_and_fail_closed_forwarding_policies() { assert!(output.is_empty()); } +#[test] +fn codex_hook_preflight_rejects_healthy_wrong_fingerprint_under_both_policies() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let _config = EnvVarGuard::set_path("XDG_CONFIG_HOME", &dir.path().join("config")); + let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &dir.path().join("runtime")); + + for fail_closed in [false, true] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + for _ in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + let request = String::from_utf8_lossy(&request); + assert!(request.starts_with("GET /healthz")); + assert!( + request.contains("X-NeMo-Relay-Bootstrap-Fingerprint: expected-fingerprint") + ); + let body = format!( + r#"{{"status":"incompatible","service":"nemo-relay","version":"{}","bootstrap_protocol":1}}"#, + env!("CARGO_PKG_VERSION") + ); + stream + .write_all( + format!( + "HTTP/1.1 409 Conflict\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + } + }); + let url = format!("http://{address}"); + let mut input = std::io::Cursor::new(b"{}".to_vec()); + let mut output = Vec::new(); + let result = hook_with_io( + HookInvocation { + agent: CodingAgent::Codex, + gateway_url: Some(&url), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: true, + }, + &mut input, + &mut output, + |_agent, _url| { + GatewaySpec::new(CodingAgent::Codex, address) + .with_fingerprint("expected-fingerprint") + .ensure() + .map(|_| ()) + }, + |_agent, _url, _payload| panic!("wrong-fingerprint gateway must not receive the hook"), + || fail_closed, + ); + if fail_closed { + assert!( + result + .unwrap_err() + .contains("gateway identity preflight failed") + ); + } else { + assert_eq!(result.unwrap(), ExitCode::SUCCESS); + } + assert!(output.is_empty()); + server.join().unwrap(); + } +} + #[test] fn backup_preserves_first_snapshot() { let dir = tempdir().unwrap(); @@ -250,6 +783,636 @@ fn atomic_write_replaces_existing_destination() { assert_eq!(fs::read_to_string(&path).unwrap(), "new\n"); } +#[test] +fn codex_auto_trusts_only_exact_generated_plugin_hooks_and_verifies_them() { + let dir = tempdir().unwrap(); + let hooks_path = dir.path().join("plugin").join("hooks.json"); + let config_path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, "model = \"test\"\n").unwrap(); + let initial = generated_codex_hook_metadata(&hooks_path, "untrusted", true); + let verified = generated_codex_hook_metadata(&hooks_path, "trusted", true); + let mut decoys = Vec::new(); + let mut wrong_command = codex_hook_metadata( + &hooks_path, + "session_start", + "wrong-command", + "untrusted", + true, + ); + wrong_command.command = Some("custom hook".into()); + decoys.push(wrong_command); + let mut wrong_source = codex_hook_metadata( + &hooks_path, + "session_start", + "wrong-source", + "untrusted", + true, + ); + wrong_source.source = "project".into(); + decoys.push(wrong_source); + let mut wrong_plugin = codex_hook_metadata( + &hooks_path, + "session_start", + "wrong-plugin", + "untrusted", + true, + ); + wrong_plugin.plugin_id = Some("another-plugin@example".into()); + decoys.push(wrong_plugin); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([ + Ok(initial.iter().cloned().chain(decoys).collect()), + Ok(verified), + ]), + ..FakeCodexHooksClient::default() + }; + + auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap(); + + assert_eq!( + client.trusted, + vec![ + (0..10) + .map(|index| format!("relay-hook-{index}")) + .collect::>() + ] + ); +} + +#[test] +fn codex_auto_trust_refuses_missing_required_hook_without_writing_state() { + let dir = tempdir().unwrap(); + let hooks_path = dir.path().join("plugin").join("hooks.json"); + let config_path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, "model = \"test\"\n").unwrap(); + let mut hooks = required_codex_hook_metadata(&hooks_path, "untrusted", true); + hooks.retain(|hook| hook.event_name != "stop"); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(hooks)]), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!(error.contains("Stop")); + assert!(client.trusted.is_empty()); +} + +#[test] +fn codex_auto_trust_refuses_duplicate_discovered_handler() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("config.toml"); + fs::write(&config_path, "").unwrap(); + let mut hooks = generated_codex_hook_metadata(dir.path(), "untrusted", true); + let mut duplicate = hooks.last().unwrap().clone(); + duplicate.key = "duplicate-post-compact".into(); + hooks.push(duplicate); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(hooks)]), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!(error.contains("duplicate: PostCompact"), "{error}"); + assert!(client.trusted.is_empty()); +} + +#[test] +fn every_generated_codex_hook_is_required_exactly_once_and_trusted() { + for (event, display) in [ + ("session_start", "SessionStart"), + ("user_prompt_submit", "UserPromptSubmit"), + ("pre_tool_use", "PreToolUse"), + ("post_tool_use", "PostToolUse"), + ("permission_request", "PermissionRequest"), + ("subagent_start", "SubagentStart"), + ("subagent_stop", "SubagentStop"), + ("stop", "Stop"), + ("pre_compact", "PreCompact"), + ("post_compact", "PostCompact"), + ] { + for condition in ["missing", "duplicate", "disabled", "modified"] { + let dir = tempdir().unwrap(); + let mut hooks = generated_codex_hook_metadata(dir.path(), "trusted", true); + let target = hooks + .iter() + .position(|hook| hook.event_name == event) + .unwrap(); + let target_key = hooks[target].key.clone(); + match condition { + "missing" => { + hooks.remove(target); + } + "duplicate" => { + let mut duplicate = hooks[target].clone(); + duplicate.key = format!("duplicate-{event}"); + hooks.push(duplicate); + } + "disabled" => hooks[target].enabled = false, + "modified" => hooks[target].trust_status = "modified".into(), + _ => unreachable!(), + } + + let report = codex_hook_trust_report_for(&hooks); + let json = report.to_json(); + assert!( + !report.ready(), + "{event} unexpectedly ready while {condition}" + ); + match condition { + "missing" => assert!( + json["missing_required"] + .as_array() + .unwrap() + .iter() + .any(|value| value == display), + "{json}" + ), + "duplicate" => assert!( + json["duplicate_required"] + .as_array() + .unwrap() + .iter() + .any(|value| value == display), + "{json}" + ), + "disabled" => assert!( + json["disabled"] + .as_array() + .unwrap() + .iter() + .any(|value| value == &target_key), + "{json}" + ), + "modified" => assert!( + json["modified"] + .as_array() + .unwrap() + .iter() + .any(|value| value == &target_key), + "{json}" + ), + _ => unreachable!(), + } + } + } +} + +#[test] +fn codex_auto_trust_reverifies_every_generated_hook_after_writing() { + for event in [ + "session_start", + "user_prompt_submit", + "pre_tool_use", + "post_tool_use", + "permission_request", + "subagent_start", + "subagent_stop", + "stop", + "pre_compact", + "post_compact", + ] { + for condition in ["missing", "duplicate", "disabled", "modified"] { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("config.toml"); + fs::write(&config_path, "").unwrap(); + let initial = generated_codex_hook_metadata(dir.path(), "untrusted", true); + let mut verified = generated_codex_hook_metadata(dir.path(), "trusted", true); + let target = verified + .iter() + .position(|hook| hook.event_name == event) + .unwrap(); + match condition { + "missing" => { + verified.remove(target); + } + "duplicate" => { + let mut duplicate = verified[target].clone(); + duplicate.key = format!("duplicate-{event}"); + verified.push(duplicate); + } + "disabled" => verified[target].enabled = false, + "modified" => verified[target].trust_status = "modified".into(), + _ => unreachable!(), + } + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([ + Ok(initial.clone()), + Ok(verified), + Ok(initial.clone()), + ]), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!( + error.contains("did not enable and trust"), + "{event} {condition}: {error}" + ); + assert_eq!(client.restored.len(), 1, "{event} {condition}"); + assert_eq!(client.restored[0].len(), 10, "{event} {condition}"); + } + } +} + +#[test] +fn codex_auto_trust_rejects_targeted_hook_that_disappears_after_write() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("config.toml"); + fs::write(&config_path, "").unwrap(); + let initial = generated_codex_hook_metadata(dir.path(), "untrusted", true); + let mut verified = initial.clone(); + for hook in &mut verified { + hook.trust_status = "trusted".into(); + } + verified.retain(|hook| hook.event_name != "post_compact"); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(initial.clone()), Ok(verified), Ok(initial.clone())]), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!( + error.contains("unverified targeted hooks=relay-hook-9"), + "{error}" + ); + assert_eq!(client.restored.len(), 1); + assert_eq!(client.restored[0].len(), initial.len()); +} + +#[test] +fn codex_auto_trust_rejects_targeted_hook_that_changes_key_after_write() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("config.toml"); + fs::write(&config_path, "").unwrap(); + let initial = generated_codex_hook_metadata(dir.path(), "untrusted", true); + let mut verified = initial.clone(); + for hook in &mut verified { + hook.trust_status = "trusted".into(); + } + verified.last_mut().unwrap().key = "replacement-post-compact".into(); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(initial.clone()), Ok(verified), Ok(initial.clone())]), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!( + error.contains("unverified targeted hooks=relay-hook-9"), + "{error}" + ); + assert_eq!(client.restored.len(), 1); +} + +#[test] +fn codex_auto_trust_restores_exact_prior_state_after_verification_failure() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("config.toml"); + fs::write( + &config_path, + r#" +[hooks.state."relay-hook-0"] +trusted_hash = "sha256:original" +enabled = false +custom = "preserve" +"#, + ) + .unwrap(); + let initial = generated_codex_hook_metadata(dir.path(), "untrusted", true); + let mut failed_verification = generated_codex_hook_metadata(dir.path(), "trusted", true); + failed_verification[9].enabled = false; + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(initial.clone()), Ok(failed_verification), Ok(initial)]), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!(error.contains("did not enable and trust"), "{error}"); + assert_eq!(client.restored.len(), 1); + assert_eq!(client.restored[0].len(), 10); + assert_eq!( + client.restored[0] + .iter() + .find(|(key, _)| key == "relay-hook-0") + .unwrap() + .1, + Some(json!({ + "trusted_hash": "sha256:original", + "enabled": false, + "custom": "preserve" + })) + ); + assert!( + client.restored[0] + .iter() + .filter(|(key, _)| key != "relay-hook-0") + .all(|(_, value)| value.is_none()) + ); +} + +#[test] +fn codex_auto_trust_aggregates_original_and_rollback_errors() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("config.toml"); + fs::write(&config_path, "").unwrap(); + let initial = required_codex_hook_metadata(dir.path(), "untrusted", true); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(initial)]), + trust_error: Some("trust write failed".into()), + restore_error: Some("trust restore failed".into()), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!(error.contains("trust write failed"), "{error}"); + assert!(error.contains("trust restore failed"), "{error}"); +} + +#[test] +fn codex_auto_trust_does_not_depend_on_reported_plugin_source_path() { + let dir = tempdir().unwrap(); + let reported_hooks_path = dir.path().join("codex-cache").join("hooks.json"); + let config_path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, "").unwrap(); + let initial = required_codex_hook_metadata(&reported_hooks_path, "untrusted", true); + let verified = required_codex_hook_metadata(&reported_hooks_path, "trusted", true); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(initial), Ok(verified)]), + ..FakeCodexHooksClient::default() + }; + + auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap(); + + assert_eq!(client.trusted[0].len(), 10); +} + +#[test] +fn codex_auto_trust_rejects_modified_loaded_plugin_hook_file() { + let dir = tempdir().unwrap(); + let reported_hooks_path = dir.path().join("codex-cache").join("hooks.json"); + fs::create_dir_all(reported_hooks_path.parent().unwrap()).unwrap(); + fs::write( + &reported_hooks_path, + serde_json::to_vec_pretty(&generated_hooks(CodingAgent::Codex, "malicious-command")) + .unwrap(), + ) + .unwrap(); + let config_path = dir.path().join("config.toml"); + fs::write(&config_path, "").unwrap(); + let hooks = required_codex_hook_metadata(&reported_hooks_path, "untrusted", true); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(hooks)]), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!(error.contains("loaded modified Relay hooks"), "{error}"); + assert!(client.trusted.is_empty()); +} + +#[test] +fn codex_hook_trust_report_distinguishes_modified_disabled_and_missing_hooks() { + let dir = tempdir().unwrap(); + let hooks_path = dir.path().join(".codex").join("hooks.json"); + let hooks = vec![ + codex_hook_metadata( + &hooks_path, + "session_start", + "trusted-hook", + "trusted", + true, + ), + codex_hook_metadata( + &hooks_path, + "user_prompt_submit", + "modified-hook", + "modified", + false, + ), + ]; + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(hooks)]), + ..FakeCodexHooksClient::default() + }; + + let report = + codex_hook_trust_report_with_client(&mut client, dir.path(), &expected_plugin_command()) + .unwrap(); + let json = report.to_json(); + + assert!(!report.ready()); + assert_eq!(json["trusted"], json!(["trusted-hook"])); + assert_eq!(json["modified"], json!(["modified-hook"])); + assert_eq!(json["disabled"], json!(["modified-hook"])); + assert_eq!( + json["missing_required"], + json!([ + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "SubagentStart", + "SubagentStop", + "Stop", + "PreCompact", + "PostCompact" + ]) + ); +} + +#[test] +fn codex_hook_state_key_path_quotes_arbitrary_hook_identity() { + assert_eq!( + hook_state_key_path("path:hook.\"quoted\""), + r#"hooks.state."path:hook.\"quoted\"""# + ); +} + +#[cfg(not(windows))] +#[test] +fn codex_app_server_client_handshakes_lists_trusts_and_clears_hooks() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let hooks_path = dir.path().join(".codex").join("hooks.json"); + let hooks = required_codex_hook_metadata(&hooks_path, "trusted", true); + let (_path, _hooks, _log) = fake_codex_app_server(dir.path(), &hooks); + let mut client = CodexAppServerClient::start().unwrap(); + + let listed = client.list_hooks(dir.path()).unwrap(); + client.trust_hooks(&listed).unwrap(); + client + .clear_hook_trust(&["relay-hook-0".to_string()]) + .unwrap(); + client + .restore_hook_trust(&[ + ( + "relay-hook-0".to_string(), + Some(json!({"trusted_hash": "sha256:old", "enabled": false})), + ), + ("relay-hook-1".to_string(), None), + ]) + .unwrap(); + drop(client); + + let requests = fs::read_to_string(dir.path().join("fake-codex-requests.jsonl")).unwrap(); + assert!(requests.contains(r#""method":"initialize""#)); + assert!(requests.contains(r#""method":"hooks/list""#)); + assert!(requests.contains(r#""method":"config/batchWrite""#)); + assert!(requests.contains(r#""trusted_hash":"sha256:relay-hook-0""#)); + assert!(requests.contains(r#""keyPath":"hooks.state.\"relay-hook-0\"""#)); + assert!(requests.contains(r#""trusted_hash":"sha256:old""#)); + assert!(requests.contains(r#""keyPath":"hooks.state.\"relay-hook-1\"""#)); + assert!(requests.contains(r#""value":null"#)); +} + +#[cfg(not(windows))] +#[test] +fn codex_setup_snapshot_restores_exact_files_and_trust() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + let config_backup = backup_path(&config_path); + let hooks_backup = backup_path(&hooks_path); + fs::write( + &config_path, + "[hooks.state.\"relay-hook-0\"]\ntrusted_hash = \"sha256:relay-hook-0\"\nenabled = true\n", + ) + .unwrap(); + fs::write(&hooks_path, "{\"custom\":true}\n").unwrap(); + fs::write(&config_backup, "original config backup\n").unwrap(); + fs::write(&hooks_backup, "original hooks backup\n").unwrap(); + let original = [ + fs::read(&config_path).unwrap(), + fs::read(&config_backup).unwrap(), + fs::read(&hooks_path).unwrap(), + fs::read(&hooks_backup).unwrap(), + ]; + let metadata = required_codex_hook_metadata(&hooks_path, "trusted", true); + let (_path, _hooks, _log) = fake_codex_app_server(dir.path(), &metadata); + let snapshot = snapshot_codex_setup().unwrap(); + + fs::write(&config_path, "model = \"changed\"\n").unwrap(); + fs::write(&hooks_path, "{}\n").unwrap(); + fs::remove_file(&config_backup).unwrap(); + fs::remove_file(&hooks_backup).unwrap(); + restore_codex_setup(&snapshot).unwrap(); + + assert_eq!(fs::read(&config_path).unwrap(), original[0]); + assert_eq!(fs::read(&config_backup).unwrap(), original[1]); + assert_eq!(fs::read(&hooks_path).unwrap(), original[2]); + assert_eq!(fs::read(&hooks_backup).unwrap(), original[3]); +} + +#[test] +fn codex_install_rolls_back_all_files_when_trust_activation_fails() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + let config_backup = backup_path(&config_path); + let hooks_backup = backup_path(&hooks_path); + fs::write(&config_path, "model_provider = \"openai\"\n").unwrap(); + fs::write(&hooks_path, "{}\n").unwrap(); + fs::write(&config_backup, "original config backup\n").unwrap(); + fs::write(&hooks_backup, "original hooks backup\n").unwrap(); + + let error = install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, _command| Err("Codex trust write rejected".into()), + ) + .unwrap_err(); + + assert!(error.contains("trust write rejected")); + assert_eq!( + fs::read_to_string(&config_path).unwrap(), + "model_provider = \"openai\"\n" + ); + assert_eq!(fs::read_to_string(&hooks_path).unwrap(), "{}\n"); + assert_eq!( + fs::read_to_string(&config_backup).unwrap(), + "original config backup\n" + ); + assert_eq!( + fs::read_to_string(&hooks_backup).unwrap(), + "original hooks backup\n" + ); +} + #[test] fn repeated_codex_install_does_not_overwrite_original_backup() { let dir = tempdir().unwrap(); @@ -373,10 +1536,9 @@ supports_websockets = false #[test] fn codex_hooks_installed_requires_generated_plugin_local_groups() { let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let codex_dir = dir.path().join(".codex"); - fs::create_dir_all(&codex_dir).unwrap(); - let path = codex_dir.join("hooks.json"); + let plugin_root = dir.path().join("plugin"); + let path = plugin_root.join("hooks").join("hooks.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write( &path, serde_json::to_vec_pretty(&json!({ @@ -398,20 +1560,24 @@ fn codex_hooks_installed_requires_generated_plugin_local_groups() { ) .unwrap(); - assert!(!codex_hooks_installed(DEFAULT_URL).unwrap()); - install_codex_hooks(&path, DEFAULT_URL).unwrap(); - assert!(codex_hooks_installed(DEFAULT_URL).unwrap()); - assert!(!codex_hooks_installed("http://127.0.0.1:47633").unwrap()); + assert!(!codex_hooks_installed(&path).unwrap()); + write_plugin_hooks(&plugin_root); + assert!(codex_hooks_installed(&path).unwrap()); } +#[cfg(not(windows))] #[test] -fn codex_doctor_allows_stopped_lazy_sidecar_when_static_setup_is_valid() { +fn codex_doctor_requires_app_server_reported_trust_but_allows_stopped_sidecar() { let dir = tempdir().unwrap(); let _home = HomeScope::enter(dir.path()); let codex_dir = dir.path().join(".codex"); fs::create_dir_all(&codex_dir).unwrap(); install_codex_config(&codex_dir.join("config.toml"), DEFAULT_URL).unwrap(); - install_codex_hooks(&codex_dir.join("hooks.json"), DEFAULT_URL).unwrap(); + let plugin_root = dir.path().join("plugin"); + let hooks_path = write_plugin_hooks(&plugin_root); + let trusted = required_codex_hook_metadata(&hooks_path, "trusted", true); + let (_path, _hooks, _log) = fake_codex_app_server(dir.path(), &trusted); + let _plugin_root = EnvVarGuard::set_path("PLUGIN_ROOT", &plugin_root); let status = doctor(PluginShimDoctorCommand { agent: CodingAgent::Codex, @@ -420,10 +1586,20 @@ fn codex_doctor_allows_stopped_lazy_sidecar_when_static_setup_is_valid() { .unwrap(); assert_eq!(status, std::process::ExitCode::SUCCESS); + let report = doctor_plugin_json(CodingAgent::Codex, DEFAULT_URL, &plugin_root).unwrap(); + assert_eq!(report["checks"]["codex_hooks_trusted"], json!(true)); + assert_eq!( + report["codex_hook_trust"]["trusted"], + json!( + (0..10) + .map(|index| format!("relay-hook-{index}")) + .collect::>() + ) + ); } #[test] -fn codex_doctor_requires_enabled_hooks_feature() { +fn codex_provider_install_check_requires_enabled_hooks_feature() { let dir = tempdir().unwrap(); let _home = HomeScope::enter(dir.path()); let codex_dir = dir.path().join(".codex"); @@ -445,15 +1621,11 @@ supports_websockets = false "#, ) .unwrap(); - install_codex_hooks(&codex_dir.join("hooks.json"), DEFAULT_URL).unwrap(); - - let status = doctor(PluginShimDoctorCommand { - agent: CodingAgent::Codex, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap(); + let plugin_root = dir.path().join("plugin"); + let hooks_path = write_plugin_hooks(&plugin_root); - assert_eq!(status, std::process::ExitCode::FAILURE); + assert!(!codex_provider_installed(DEFAULT_URL)); + assert!(codex_hooks_installed(&hooks_path).unwrap()); } #[test] @@ -487,66 +1659,72 @@ fn plugin_shim_helpers_reject_unsupported_agents_and_report_lazy_claude_status() .contains("supports claude") ); assert!( - doctor_plugin(CodingAgent::Hermes, DEFAULT_URL) + doctor_plugin(CodingAgent::Hermes, DEFAULT_URL, dir.path()) .unwrap_err() .contains("supports claude and codex") ); assert!( - doctor_plugin_json(CodingAgent::Hermes, DEFAULT_URL) + doctor_plugin_json(CodingAgent::Hermes, DEFAULT_URL, dir.path()) .unwrap_err() .contains("supports claude and codex") ); - let report = doctor_plugin_json(CodingAgent::ClaudeCode, DEFAULT_URL).unwrap(); + let report = doctor_plugin_json(CodingAgent::ClaudeCode, DEFAULT_URL, dir.path()).unwrap(); assert_eq!(report["ok"], json!(false)); assert_eq!(report["sidecar_health"], json!("not_running_lazy_start")); assert_eq!(report["checks"]["claude_provider_routing"], json!(false)); } #[test] -fn codex_setup_persists_path_based_launcher_when_sidecar_binary_override_is_set() { +fn codex_setup_uses_plugin_hooks_without_writing_user_hooks() { let dir = tempdir().unwrap(); let _home = HomeScope::enter(dir.path()); let codex_dir = dir.path().join(".codex"); fs::create_dir_all(&codex_dir).unwrap(); - let sidecar_override = dir.path().join("sidecar").join("nemo-relay"); - fs::create_dir_all(sidecar_override.parent().unwrap()).unwrap(); - fs::write(&sidecar_override, b"sidecar override").unwrap(); - let _binary_override = EnvVarGuard::set_path("NEMO_RELAY_PLUGIN_BINARY", &sidecar_override); - install_codex(DEFAULT_URL).unwrap(); + install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, command| { + assert_eq!(command, expected_plugin_command()); + Ok(()) + }, + ) + .unwrap(); let hooks_path = codex_dir.join("hooks.json"); - let hooks: Value = serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); - let launcher_command = codex_hook_command(DEFAULT_URL); - let sidecar_command = codex_hook_command_for_platform(&sidecar_override, DEFAULT_URL, false); - assert!(event_contains_command( - &hooks, - "SessionStart", - &launcher_command - )); - assert!(!event_contains_command( - &hooks, - "SessionStart", - &sidecar_command - )); - assert!(codex_hooks_installed(DEFAULT_URL).unwrap()); - assert_eq!( - doctor(PluginShimDoctorCommand { - agent: CodingAgent::Codex, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap(), - std::process::ExitCode::SUCCESS - ); + assert!(!hooks_path.exists()); + assert!(codex_provider_installed(DEFAULT_URL)); - uninstall_codex(DEFAULT_URL).unwrap(); - let hooks: Value = serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); - assert!(!event_contains_command( - &hooks, - "SessionStart", - &launcher_command - )); + let mut client = empty_codex_hooks_client(); + uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap(); + assert!(!hooks_path.exists()); +} + +#[test] +fn codex_setup_and_uninstall_honor_custom_codex_home() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(&dir.path().join("home")); + let codex_home = dir.path().join("custom-codex-home"); + let _codex_home = EnvVarGuard::set_path("CODEX_HOME", &codex_home); + + install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_cwd, config_path, _command| { + assert_eq!(config_path, codex_home.join("config.toml")); + Ok(()) + }, + ) + .unwrap(); + + assert!(codex_provider_installed(DEFAULT_URL)); + assert!(codex_home.join("config.toml").exists()); + assert!(!dir.path().join("home/.codex/config.toml").exists()); + + let mut client = empty_codex_hooks_client(); + uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap(); + assert!(!codex_provider_installed(DEFAULT_URL)); } #[test] @@ -591,6 +1769,257 @@ supports_websockets = false assert!(!updated.contains("hooks = true")); } +#[test] +fn codex_uninstall_clears_all_trust_for_the_exact_relay_plugin() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + fs::write(&config_path, "model_provider = \"openai\"\n").unwrap(); + fs::write(&hooks_path, "{}\n").unwrap(); + install_codex_hooks(&hooks_path, DEFAULT_URL).unwrap(); + install_codex_config(&config_path, DEFAULT_URL).unwrap(); + let mut hooks = generated_codex_hook_metadata(&hooks_path, "trusted", true); + let mut unrelated = codex_hook_metadata( + &hooks_path, + "session_start", + "unrelated-hook", + "trusted", + true, + ); + unrelated.command = Some("custom hook".into()); + hooks.push(unrelated); + let mut cleared = hooks.clone(); + for hook in &mut cleared { + hook.trust_status = "untrusted".into(); + } + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(hooks), Ok(cleared)]), + ..FakeCodexHooksClient::default() + }; + + uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap(); + + assert_eq!( + client.cleared, + vec![ + (0..10) + .map(|index| format!("relay-hook-{index}")) + .chain(["unrelated-hook".into()]) + .collect::>() + ] + ); + assert!( + !serde_json::from_str::(&fs::read_to_string(&hooks_path).unwrap()) + .unwrap() + .to_string() + .contains("plugin-shim hook codex") + ); +} + +#[test] +fn codex_uninstall_clears_persisted_optional_hooks_after_downgrade() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + let all_hooks = persisted_relay_hook_metadata(&hooks_path, "trusted"); + let all_keys = all_hooks + .iter() + .map(|hook| hook.key.clone()) + .collect::>(); + let unrelated_key = "other-plugin@example:hooks/hooks.json:session_start:0:0"; + write_persisted_hook_trust(&config_path, &all_keys, unrelated_key); + let visible = all_hooks + .into_iter() + .filter(|hook| { + !matches!( + hook.event_name.as_str(), + "post_tool_use_failure" | "notification" | "session_end" + ) + }) + .collect::>(); + let mut cleared = visible.clone(); + for hook in &mut cleared { + hook.trust_status = "untrusted".into(); + } + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(visible), Ok(cleared)]), + clear_config_path: Some(config_path.clone()), + ..FakeCodexHooksClient::default() + }; + + uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap(); + + assert_eq!( + client.cleared[0].iter().cloned().collect::>(), + all_keys.into_iter().collect::>() + ); + assert_eq!( + configured_hook_trust_keys(&config_path).unwrap(), + BTreeSet::from([unrelated_key.to_string()]) + ); +} + +#[test] +fn codex_uninstall_clears_persisted_relay_trust_when_discovery_is_empty() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + let relay_keys = persisted_relay_hook_metadata(&hooks_path, "trusted") + .into_iter() + .map(|hook| hook.key) + .collect::>(); + let unrelated_key = "other-plugin@example:hooks/hooks.json:session_start:0:0"; + write_persisted_hook_trust(&config_path, &relay_keys, unrelated_key); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(Vec::new())]), + clear_config_path: Some(config_path.clone()), + ..FakeCodexHooksClient::default() + }; + + uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap(); + + assert_eq!(client.cleared.len(), 1); + assert_eq!( + client.cleared[0].iter().cloned().collect::>(), + relay_keys.into_iter().collect::>() + ); + assert_eq!( + configured_hook_trust_keys(&config_path).unwrap(), + BTreeSet::from([unrelated_key.to_string()]) + ); +} + +#[test] +fn codex_uninstall_rolls_back_persisted_relay_trust_when_clear_is_not_applied() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + let relay_keys = persisted_relay_hook_metadata(&hooks_path, "trusted") + .into_iter() + .map(|hook| hook.key) + .collect::>(); + let unrelated_key = "other-plugin@example:hooks/hooks.json:session_start:0:0"; + write_persisted_hook_trust(&config_path, &relay_keys, unrelated_key); + let original_config = fs::read(&config_path).unwrap(); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(Vec::new())]), + ..FakeCodexHooksClient::default() + }; + + let error = uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap_err(); + + assert!(error.contains("did not clear trust"), "{error}"); + assert_eq!(fs::read(&config_path).unwrap(), original_config); + assert_eq!(client.restored.len(), 1); + assert_eq!( + client.restored[0] + .iter() + .map(|(key, _)| key.clone()) + .collect::>(), + relay_keys.into_iter().collect::>() + ); + assert!(client.restored[0].iter().all(|(_, value)| value.is_some())); +} + +#[test] +fn codex_uninstall_restores_files_even_when_trust_cleanup_fails() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + fs::write(&config_path, "model_provider = \"openai\"\n").unwrap(); + fs::write(&hooks_path, "{}\n").unwrap(); + install_codex_hooks(&hooks_path, DEFAULT_URL).unwrap(); + install_codex_config(&config_path, DEFAULT_URL).unwrap(); + let original_config = fs::read(&config_path).unwrap(); + let original_config_backup = fs::read(backup_path(&config_path)).unwrap(); + let original_hooks = fs::read(&hooks_path).unwrap(); + let original_hooks_backup = fs::read(backup_path(&hooks_path)).unwrap(); + let original_metadata = required_codex_hook_metadata(&hooks_path, "trusted", true); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(original_metadata.clone()), Ok(original_metadata)]), + clear_error: Some("config is locked".into()), + ..FakeCodexHooksClient::default() + }; + + let error = uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap_err(); + + assert!(error.contains("config is locked"), "{error}"); + assert_eq!(fs::read(&config_path).unwrap(), original_config); + assert_eq!( + fs::read(backup_path(&config_path)).unwrap(), + original_config_backup + ); + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); + assert_eq!( + fs::read(backup_path(&hooks_path)).unwrap(), + original_hooks_backup + ); + assert_eq!(client.restored.len(), 1); +} + +#[test] +fn codex_uninstall_requires_trust_client_before_mutating_files() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + fs::write(&config_path, "model_provider = \"openai\"\n").unwrap(); + fs::write(&hooks_path, "{\"custom\":true}\n").unwrap(); + let original_config = fs::read(&config_path).unwrap(); + let original_hooks = fs::read(&hooks_path).unwrap(); + + let error = uninstall_codex_with_client(DEFAULT_URL, None).unwrap_err(); + + assert!(error.contains("app-server is required"), "{error}"); + assert_eq!(fs::read(&config_path).unwrap(), original_config); + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); +} + +#[test] +fn codex_uninstall_rolls_back_when_trust_cleanup_cannot_be_verified() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + fs::write(&config_path, "model_provider = \"openai\"\n").unwrap(); + fs::write(&hooks_path, "{}\n").unwrap(); + install_codex_hooks(&hooks_path, DEFAULT_URL).unwrap(); + install_codex_config(&config_path, DEFAULT_URL).unwrap(); + let original_config = fs::read(&config_path).unwrap(); + let original_hooks = fs::read(&hooks_path).unwrap(); + let trusted = required_codex_hook_metadata(&hooks_path, "trusted", true); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(trusted.clone()), Ok(trusted.clone()), Ok(trusted)]), + ..FakeCodexHooksClient::default() + }; + + let error = uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap_err(); + + assert!(error.contains("did not clear trust"), "{error}"); + assert_eq!(fs::read(&config_path).unwrap(), original_config); + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); + assert_eq!(client.restored.len(), 1); +} + #[test] fn codex_uninstall_with_backup_preserves_user_changed_model_provider() { let dir = tempdir().unwrap(); @@ -755,13 +2184,11 @@ hooks = false ) .unwrap(); - install_codex(DEFAULT_URL).unwrap(); let hooks_path = codex_dir.join("hooks.json"); - let mut hooks: Value = serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); - hooks["hooks"]["SessionStart"] - .as_array_mut() - .unwrap() - .push(json!({ + fs::write( + &hooks_path, + serde_json::to_vec_pretty(&json!({ + "hooks": {"SessionStart": [{ "hooks": [ { "type": "command", @@ -769,10 +2196,19 @@ hooks = false "timeout": 30 } ] - })); - fs::write(&hooks_path, serde_json::to_vec_pretty(&hooks).unwrap()).unwrap(); + }]}})) + .unwrap(), + ) + .unwrap(); + install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, _command| Ok(()), + ) + .unwrap(); - uninstall_codex(DEFAULT_URL).unwrap(); + let mut client = empty_codex_hooks_client(); + uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap(); let updated_config = fs::read_to_string(codex_dir.join("config.toml")).unwrap(); assert!(updated_config.contains("hooks = true")); @@ -1063,18 +2499,322 @@ fn claude_reinstall_uses_fresh_backup_after_prior_restore() { } #[test] -fn stale_lock_is_repaired_after_grace_period_even_when_pid_file_exists() { +fn advisory_sidecar_lock_blocks_until_the_owner_releases_it() { let dir = tempdir().unwrap(); - let lock = dir.path().join("codex-sidecar.lock"); - fs::create_dir(&lock).unwrap(); - fs::write( - dir.path().join("codex-sidecar.pid"), - std::process::id().to_string(), + let url = "http://127.0.0.1:47632"; + let owner = lock_sidecar_endpoint(dir.path(), url).unwrap(); + let path = dir.path().to_path_buf(); + let (ready_sender, ready_receiver) = std::sync::mpsc::channel(); + let (acquired_sender, acquired_receiver) = std::sync::mpsc::channel(); + let contender = thread::spawn(move || { + ready_sender.send(()).unwrap(); + let lock = lock_sidecar_endpoint(&path, url).unwrap(); + acquired_sender.send(()).unwrap(); + drop(lock); + }); + + ready_receiver.recv_timeout(Duration::from_secs(5)).unwrap(); + let error = lock_sidecar_endpoint_for(dir.path(), url, Duration::ZERO).unwrap_err(); + assert!(error.contains("timed out waiting for sidecar lock")); + drop(owner); + acquired_receiver + .recv_timeout(Duration::from_secs(5)) + .unwrap(); + contender.join().unwrap(); +} + +#[test] +fn sidecar_lock_zero_wait_fails_when_the_endpoint_is_owned() { + let dir = tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + let owner = lock_sidecar_endpoint(dir.path(), url).unwrap(); + + let error = lock_sidecar_endpoint_for(dir.path(), url, Duration::ZERO).unwrap_err(); + + assert!(error.contains("timed out waiting for sidecar lock")); + drop(owner); +} + +#[test] +fn sidecar_ownership_records_are_endpoint_specific_and_enumerated() { + let dir = tempdir().unwrap(); + let first = sidecar_owner_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47632"); + let second = sidecar_owner_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47633"); + let legacy = dir.path().join("codex-sidecar.owner.json"); + let ignored = sidecar_owner_path( + dir.path(), + CodingAgent::ClaudeCode, + "http://127.0.0.1:47634", + ); + for path in [&first, &second, &legacy, &ignored] { + fs::write(path, "{}").unwrap(); + } + + let paths = sidecar_owner_paths(dir.path(), CodingAgent::Codex).unwrap(); + + assert_eq!(paths.len(), 3); + assert!(paths.contains(&first)); + assert!(paths.contains(&second)); + assert!(paths.contains(&legacy)); + assert_ne!(first, second); +} + +#[test] +fn managed_sidecar_publishes_valid_ownership_before_parent_validation() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let state = dir.path().join("bootstrap-state"); + let _state = EnvVarGuard::set_path(BOOTSTRAP_STATE_DIR_ENV, &state); + let _agent = EnvVarGuard::set_value(BOOTSTRAP_AGENT_ENV, "codex"); + let _token = + EnvVarGuard::set_value("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", "test-shutdown-token"); + let _fingerprint = + EnvVarGuard::set_value(crate::config::BOOTSTRAP_FINGERPRINT_ENV, "test-fingerprint"); + let address = "127.0.0.1:47632".parse().unwrap(); + + publish_sidecar_owner_from_env(address).unwrap(); + + let url = format!("http://{address}"); + let owner_path = sidecar_owner_path(&state, CodingAgent::Codex, &url); + let pid_path = sidecar_pid_path(&state, CodingAgent::Codex, &url); + validate_sidecar_owner( + &owner_path, + &pid_path, + std::process::id(), + &url, + "test-shutdown-token", + Some("test-fingerprint"), + ) + .unwrap(); +} + +#[test] +fn managed_shutdown_rejects_a_listener_without_authenticated_health_proof() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let state = dir.path().join("bootstrap-state"); + fs::create_dir_all(&state).unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let url = format!("http://{address}"); + let owner_path = sidecar_owner_path(&state, CodingAgent::Codex, &url); + write_sidecar_owner( + &owner_path, + std::process::id(), + &url, + "must-not-be-sent", + Some("hmac-sha256:fake-owner-fingerprint"), + ) + .unwrap(); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let listener_thread = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + request_sender.send(request).unwrap(); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + }); + + let error = stop_owned_sidecar_record(CodingAgent::Codex, &state, &owner_path).unwrap_err(); + + assert!(error.contains("foreign listener"), "{error}"); + let request = request_receiver + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + let request = String::from_utf8(request).unwrap(); + assert!(request.starts_with("GET /healthz HTTP/1.1"), "{request}"); + assert!(!request.contains("must-not-be-sent"), "{request}"); + assert!(owner_path.exists()); + listener_thread.join().unwrap(); +} + +#[cfg(unix)] +#[test] +fn failed_sidecar_startup_terminates_the_detached_process_group() { + let dir = tempdir().unwrap(); + let grandchild_pid_path = dir.path().join("grandchild.pid"); + let mut command = std::process::Command::new("sh"); + command + .args(["-c", "sleep 30 & echo $! > \"$1\"; exit 1", "sh"]) + .arg(&grandchild_pid_path); + configure_detached_sidecar(&mut command); + let mut child = command.spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + while !grandchild_pid_path.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + let grandchild_pid = fs::read_to_string(&grandchild_pid_path) + .unwrap() + .trim() + .parse::() + .unwrap(); + assert!(!child.wait().unwrap().success()); + + terminate_sidecar_process_tree(&mut child); + + let deadline = Instant::now() + Duration::from_secs(3); + loop { + // SAFETY: Signal 0 performs an existence check and does not alter the target process. + let result = unsafe { libc::kill(grandchild_pid, 0) }; + if result == -1 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { + break; + } + if Instant::now() >= deadline { + // SAFETY: Best-effort cleanup of the test process if the process-group assertion fails. + unsafe { libc::kill(grandchild_pid, libc::SIGKILL) }; + panic!("detached sidecar grandchild {grandchild_pid} survived startup termination"); + } + thread::sleep(Duration::from_millis(20)); + } +} + +#[cfg(windows)] +#[test] +fn windows_sidecar_job_terminates_an_assigned_process() { + let job = SidecarJob::create().unwrap(); + assert!(job.name().starts_with("Local\\NeMoRelaySidecar-")); + let mut command = std::process::Command::new("cmd"); + command.args(["/C", "ping -n 30 127.0.0.1 >NUL"]); + configure_detached_sidecar(&mut command); + let mut child = command.spawn().unwrap(); + match job.assign(&child) { + Ok(()) => { + job.terminate(); + assert!(!child.wait().unwrap().success()); + } + Err(error) => { + // Restrictive host Job Objects may forbid nested assignment; production reports the + // error and refuses to serve without its process-tree cleanup guarantee. + assert!(error.contains("Job Object"), "{error}"); + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +#[test] +fn sidecar_reaper_removes_only_the_exited_process_records() { + let dir = tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + let owner_path = sidecar_owner_path(dir.path(), CodingAgent::Codex, url); + let pid_path = sidecar_pid_path(dir.path(), CodingAgent::Codex, url); + let endpoint_lock = lock_sidecar_endpoint(dir.path(), url).unwrap(); + #[cfg(windows)] + let mut command = { + let mut command = std::process::Command::new("cmd"); + command.args(["/C", "ping -n 2 127.0.0.1 >NUL"]); + command + }; + #[cfg(not(windows))] + let mut command = { + let mut command = std::process::Command::new("sh"); + command.args(["-c", "sleep 0.1"]); + command + }; + let child = command.spawn().unwrap(); + let pid = child.id(); + write_sidecar_owner( + &owner_path, + pid, + url, + "test-shutdown-token", + Some("test-fingerprint"), + ) + .unwrap(); + fs::write(&pid_path, pid.to_string()).unwrap(); + + handoff_sidecar_to_reaper( + child, + owner_path.clone(), + pid_path.clone(), + sidecar_lock_path(dir.path(), url), ) .unwrap(); + drop(endpoint_lock); + + let deadline = Instant::now() + Duration::from_secs(3); + while (owner_path.exists() || pid_path.exists()) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + assert!(!owner_path.exists()); + assert!(!pid_path.exists()); +} + +#[test] +fn sidecar_reaper_does_not_block_other_cleanup_on_a_contended_lock() { + let dir = tempdir().unwrap(); + let blocked_url = "http://127.0.0.1:47632"; + let free_url = "http://127.0.0.1:47633"; + let blocked_lock = lock_sidecar_endpoint(dir.path(), blocked_url).unwrap(); + let mut records = Vec::new(); + for url in [blocked_url, free_url] { + let child = short_lived_command().spawn().unwrap(); + let pid = child.id(); + let owner_path = sidecar_owner_path(dir.path(), CodingAgent::Codex, url); + let pid_path = sidecar_pid_path(dir.path(), CodingAgent::Codex, url); + write_sidecar_owner( + &owner_path, + pid, + url, + "test-shutdown-token", + Some("test-fingerprint"), + ) + .unwrap(); + fs::write(&pid_path, pid.to_string()).unwrap(); + handoff_sidecar_to_reaper( + child, + owner_path.clone(), + pid_path.clone(), + sidecar_lock_path(dir.path(), url), + ) + .unwrap(); + records.push((owner_path, pid_path)); + } + + let deadline = Instant::now() + Duration::from_secs(3); + while (records[1].0.exists() || records[1].1.exists()) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + assert!(!records[1].0.exists()); + assert!(!records[1].1.exists()); + assert!(records[0].0.exists()); + + drop(blocked_lock); + let deadline = Instant::now() + Duration::from_secs(3); + while (records[0].0.exists() || records[0].1.exists()) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + assert!(!records[0].0.exists()); + assert!(!records[0].1.exists()); +} + +#[test] +fn sidecar_state_directory_does_not_follow_runtime_environment() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let _config_home = EnvVarGuard::remove("XDG_CONFIG_HOME"); + let first = { + let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &dir.path().join("runtime-a")); + sidecar_state_dir().unwrap() + }; + let second = { + let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &dir.path().join("runtime-b")); + sidecar_state_dir().unwrap() + }; - assert!(repair_stale_lock_after(&lock, Duration::ZERO)); - assert!(!lock.exists()); + assert_eq!(first, second); } #[test] @@ -1135,12 +2875,33 @@ fn codex_hook_command_uses_cmd_quoting_for_windows_paths() { command, r#""C:\Program Files\NeMo 100%%\bin\nemo-relay.exe" plugin-shim hook codex --gateway-url http://127.0.0.1:47632"# ); + assert_eq!( + codex_plugin_hook_command_for_platform(&relay, true), + r#""C:\Program Files\NeMo 100%%\bin\nemo-relay.exe" plugin-shim hook codex --gateway-url http://127.0.0.1:47632"# + ); assert_eq!( shell_quote_arg_for_platform("foo&bar", true), r#""foo^&bar""# ); } +#[test] +fn windows_sidecar_flags_only_request_permitted_job_breakaway() { + let base = WINDOWS_CREATE_NEW_PROCESS_GROUP | WINDOWS_CREATE_NO_WINDOW; + assert_eq!(windows_sidecar_creation_flags(false, None), (base, false)); + assert_eq!( + windows_sidecar_creation_flags(true, Some(WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK)), + (base | WINDOWS_CREATE_BREAKAWAY_FROM_JOB, false) + ); + assert_eq!( + windows_sidecar_creation_flags(true, Some(WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK)), + (base, false) + ); + assert_eq!(windows_sidecar_creation_flags(true, Some(0)), (base, true)); + assert_eq!(windows_sidecar_creation_flags(true, None), (base, true)); + assert_eq!(WINDOWS_JOB_OBJECT_LIMIT_KILL_ON_CLOSE, 0x0000_2000); +} + #[test] fn codex_hook_command_uses_posix_single_quote_escaping() { let relay = std::path::PathBuf::from("/tmp/NeMo $Relay`test'/bin/nemo-relay"); @@ -1150,6 +2911,10 @@ fn codex_hook_command_uses_posix_single_quote_escaping() { command, "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' plugin-shim hook codex --gateway-url http://127.0.0.1:47632" ); + assert_eq!( + codex_plugin_hook_command_for_platform(&relay, false), + "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' plugin-shim hook codex --gateway-url http://127.0.0.1:47632" + ); assert_eq!(shell_quote_arg_for_platform("", false), "''"); assert_eq!( shell_quote_arg_for_platform(r"/tmp/path\with-backslash", false), @@ -1161,11 +2926,11 @@ fn codex_hook_command_uses_posix_single_quote_escaping() { fn hook_forward_connect_attempt_is_bounded() { let error = post_hook(CodingAgent::Codex, "http://127.0.0.1:9", b"{}").unwrap_err(); - assert!(error.contains("hook forward failed")); + assert!(error.to_string().contains("hook forward failed")); } #[test] -fn hook_forward_posts_to_local_sidecar_and_healthz_accepts_200() { +fn hook_forward_posts_to_local_sidecar_and_healthz_verifies_relay_identity() { let hook_listener = TcpListener::bind("127.0.0.1:0").unwrap(); let hook_port = hook_listener.local_addr().unwrap().port(); let hook_thread = thread::spawn(move || { @@ -1194,14 +2959,47 @@ fn hook_forward_posts_to_local_sidecar_and_healthz_accepts_200() { let (mut stream, _) = health_listener.accept().unwrap(); let request = read_http_request(&mut stream); assert!(String::from_utf8_lossy(&request).starts_with("GET /healthz")); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":1}}"#, + env!("CARGO_PKG_VERSION") + ); stream - .write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n") + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .as_bytes(), + ) .unwrap(); + stream.write_all(body.as_bytes()).unwrap(); }); assert!(healthz(&format!("http://127.0.0.1:{health_port}"))); health_thread.join().unwrap(); } +#[test] +fn hook_forward_rejects_an_oversized_http_response() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut stream); + let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"); + let _ = stream.write_all(&vec![b'x'; MAX_HOOK_RESPONSE_BYTES + 1]); + }); + + let error = post_hook( + CodingAgent::Codex, + &format!("http://127.0.0.1:{port}"), + b"{}", + ) + .unwrap_err(); + + assert!(error.to_string().contains("response exceeds")); + server.join().unwrap(); +} + #[test] fn hook_http_response_requires_numeric_2xx_status() { assert_eq!( @@ -1234,6 +3032,28 @@ fn unready_sidecar_child_is_terminated_and_pid_removed() { assert!(!pid_path.exists()); } +#[cfg(unix)] +#[test] +fn spawned_sidecar_uses_an_independent_process_group() { + let mut command = long_lived_command(); + configure_detached_sidecar(&mut command); + let mut child = command.spawn().unwrap(); + + let output = std::process::Command::new("ps") + .args(["-o", "pgid=", "-p", &child.id().to_string()]) + .output() + .unwrap(); + let process_group = String::from_utf8(output.stdout) + .unwrap() + .trim() + .parse::() + .unwrap(); + + assert_eq!(process_group, child.id()); + child.kill().unwrap(); + child.wait().unwrap(); +} + #[test] fn ensure_sidecar_releases_lock_when_startup_fails_fast() { let dir = tempdir().unwrap(); @@ -1241,8 +3061,11 @@ fn ensure_sidecar_releases_lock_when_startup_fails_fast() { let runtime = dir.path().join("runtime"); let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &runtime); - ensure_sidecar(CodingAgent::Codex, "not a loopback url"); + let error = loopback_bind("not a loopback url") + .and_then(|bind| GatewaySpec::new(CodingAgent::Codex, bind).ensure()) + .unwrap_err(); + assert!(error.contains("loopback URL")); assert!( !runtime .join("nemo-relay-plugin") @@ -1251,7 +3074,119 @@ fn ensure_sidecar_releases_lock_when_startup_fails_fast() { ); } -#[cfg(not(windows))] +#[test] +fn healthz_rejects_foreign_success_response() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + for _ in 0..4 { + let (mut stream, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut stream); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\nConnection: close\r\n\r\n{\"status\":\"ok\"}", + ) + .unwrap(); + } + }); + + let error = ensure_sidecar_bind(CodingAgent::Codex, address).unwrap_err(); + assert!( + error.contains("not a compatible NeMo Relay gateway"), + "{error}" + ); + handle.join().unwrap(); +} + +#[test] +fn startup_reprobes_a_transient_foreign_health_result_after_locking() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + let (mut starting, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut starting); + starting + .write_all(b"HTTP/1.1 503 Starting\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .unwrap(); + drop(starting); + + let (mut ready, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut ready); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); + ready + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + }); + + let endpoint = ensure_sidecar_bind(CodingAgent::Codex, address).unwrap(); + + assert_eq!(endpoint.address, address); + handle.join().unwrap(); +} + +#[test] +fn active_startup_lock_waits_for_relay_identity_instead_of_rejecting_listener() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let runtime_base = dir.path().join("runtime"); + let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &runtime_base); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let state = sidecar_state_dir().unwrap(); + fs::create_dir_all(&state).unwrap(); + let url = format!("http://{address}"); + let lock_path = sidecar_lock_path(&state, &url); + let owner_lock = lock_sidecar_endpoint(&state, &url).unwrap(); + let handle = thread::spawn(move || { + let (mut starting, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut starting); + starting + .write_all(b"HTTP/1.1 503 Starting\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .unwrap(); + drop(starting); + + let (mut ready, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut ready); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); + ready + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + drop(owner_lock); + }); + + let endpoint = ensure_sidecar_bind(CodingAgent::Codex, address).unwrap(); + + assert_eq!(endpoint.address, address); + assert_eq!(endpoint.url, format!("http://{address}")); + handle.join().unwrap(); + assert!(lock_path.exists()); +} + +#[cfg(unix)] #[test] fn start_sidecar_reports_child_exit_before_healthz_ready() { use std::os::unix::fs::PermissionsExt; @@ -1265,18 +3200,9 @@ fn start_sidecar_reports_child_exit_before_healthz_ready() { fs::set_permissions(&relay, permissions).unwrap(); let _binary = EnvVarGuard::set_path("NEMO_RELAY_PLUGIN_BINARY", &relay); - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = listener.local_addr().unwrap().port(); - drop(listener); - - let error = start_sidecar( - CodingAgent::Codex, - &format!("http://127.0.0.1:{port}"), - dir.path(), - ) - .unwrap_err(); + let error = start_sidecar(CodingAgent::Codex, "http://127.0.0.1:0", dir.path()).unwrap_err(); - assert!(error.contains("exited before becoming ready")); + assert!(error.contains("exited before becoming ready"), "{error}"); assert!(!dir.path().join("codex-sidecar.pid").exists()); } @@ -1379,6 +3305,13 @@ fn long_lived_command() -> std::process::Command { command } +#[cfg(windows)] +fn short_lived_command() -> std::process::Command { + let mut command = std::process::Command::new("cmd"); + command.args(["/C", "ping -n 2 127.0.0.1 >NUL"]); + command +} + #[cfg(not(windows))] fn long_lived_command() -> std::process::Command { let mut command = std::process::Command::new("sh"); @@ -1386,6 +3319,13 @@ fn long_lived_command() -> std::process::Command { command } +#[cfg(not(windows))] +fn short_lived_command() -> std::process::Command { + let mut command = std::process::Command::new("sh"); + command.args(["-c", "sleep 0.1"]); + command +} + #[test] fn codex_install_hooks_persist_custom_gateway_url() { let dir = tempdir().unwrap(); @@ -1402,15 +3342,26 @@ fn codex_install_hooks_persist_custom_gateway_url() { } #[test] -fn codex_install_hooks_replaces_legacy_generated_command() { +fn codex_install_migration_removes_legacy_relay_groups_and_preserves_unrelated_hooks() { let dir = tempdir().unwrap(); let path = dir.path().join("hooks.json"); let relay = current_exe().unwrap(); let legacy_command = legacy_codex_hook_command(&relay); - let legacy = generated_hooks(CodingAgent::Codex, &legacy_command); - fs::write(&path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap(); + let mut legacy = generated_hooks(CodingAgent::Codex, &legacy_command); + legacy["hooks"]["SessionStart"] + .as_array_mut() + .unwrap() + .push(json!({ + "hooks": [{ + "type": "command", + "command": "custom-user-hook", + "timeout": 30 + }] + })); + let original = serde_json::to_vec_pretty(&legacy).unwrap(); + fs::write(&path, &original).unwrap(); - install_codex_hooks(&path, DEFAULT_URL).unwrap(); + remove_legacy_codex_hooks(&path).unwrap(); let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); assert!(!event_contains_command( @@ -1421,7 +3372,44 @@ fn codex_install_hooks_replaces_legacy_generated_command() { assert!(event_contains_command( &updated, "SessionStart", - &codex_hook_command(DEFAULT_URL) + "custom-user-hook" + )); + assert_eq!(fs::read(backup_path(&path)).unwrap(), original); +} + +#[test] +fn codex_migration_removes_modified_relay_handler_from_mixed_user_group() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + let legacy_command = + "'/old install/nemo-relay' plugin-shim hook codex --gateway-url http://127.0.0.1:47632"; + write_json( + &path, + &json!({ + "hooks": { + "SessionStart": [{ + "hooks": [ + {"type": "command", "command": legacy_command, "timeout": 60}, + {"type": "command", "command": "custom-user-hook", "timeout": 45} + ] + }] + } + }), + ) + .unwrap(); + + remove_legacy_codex_hooks(&path).unwrap(); + let updated = read_json_object(&path).unwrap(); + + assert!(!event_contains_command( + &updated, + "SessionStart", + legacy_command + )); + assert!(event_contains_command( + &updated, + "SessionStart", + "custom-user-hook" )); } @@ -1436,9 +3424,11 @@ fn codex_install_does_not_write_provider_config_when_hooks_are_invalid() { "model_provider = \"openai\"\n", ) .unwrap(); - fs::write(codex_dir.join("hooks.json"), "{ invalid json").unwrap(); + let plugin_hooks = dir.path().join("plugin").join("hooks").join("hooks.json"); + fs::create_dir_all(plugin_hooks.parent().unwrap()).unwrap(); + fs::write(&plugin_hooks, "{ invalid json").unwrap(); - let error = install_codex(DEFAULT_URL).unwrap_err(); + let error = install_codex(DEFAULT_URL, &plugin_hooks).unwrap_err(); assert!(error.contains("invalid JSON")); assert_eq!( @@ -1474,7 +3464,12 @@ fn codex_install_does_not_write_hooks_when_config_is_invalid() { .unwrap(); fs::write(&hooks_path, &original_hooks).unwrap(); - let error = install_codex(DEFAULT_URL).unwrap_err(); + let error = install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, _command| Err("expected exactly one Relay handler".into()), + ) + .unwrap_err(); assert!(error.contains("invalid TOML")); assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); @@ -1507,7 +3502,12 @@ fn codex_install_does_not_write_hooks_when_config_is_not_readable() { .unwrap(); fs::write(&hooks_path, &original_hooks).unwrap(); - let error = install_codex(DEFAULT_URL).unwrap_err(); + let error = install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, _command| Err("expected exactly one Relay handler".into()), + ) + .unwrap_err(); assert!(error.contains("failed to read")); assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); @@ -1528,7 +3528,7 @@ fn codex_install_config_rolls_back_backup_when_write_fails() { } #[test] -fn codex_install_rolls_back_hooks_backup_when_hook_merge_fails() { +fn codex_install_preserves_invalid_user_hooks_when_trust_fails() { let dir = tempdir().unwrap(); let _home = HomeScope::enter(dir.path()); let codex_dir = dir.path().join(".codex"); @@ -1547,9 +3547,14 @@ fn codex_install_rolls_back_hooks_backup_when_hook_merge_fails() { .unwrap(); fs::write(&hooks_path, &original_hooks).unwrap(); - let error = install_codex(DEFAULT_URL).unwrap_err(); + let error = install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, _command| Err("expected exactly one Relay handler".into()), + ) + .unwrap_err(); - assert!(error.contains("SessionStart hooks must be an array")); + assert!(error.contains("exactly one Relay handler"), "{error}"); assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); assert!(!backup_path(&hooks_path).exists()); assert_eq!( @@ -1569,7 +3574,8 @@ fn codex_uninstall_rolls_back_hooks_when_provider_config_is_invalid() { install_codex_hooks(&hooks_path, DEFAULT_URL).unwrap(); let original_hooks = fs::read(&hooks_path).unwrap(); - let error = uninstall_codex(DEFAULT_URL).unwrap_err(); + let mut client = empty_codex_hooks_client(); + let error = uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap_err(); assert!(error.contains("invalid TOML")); assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); @@ -1606,7 +3612,8 @@ fn codex_install_rolls_back_hooks_when_provider_config_write_fails() { .unwrap(); fs::write(&hooks_path, &original_hooks).unwrap(); - let error = install_codex(DEFAULT_URL).unwrap_err(); + let plugin_hooks = write_plugin_hooks(&dir.path().join("plugin")); + let error = install_codex(DEFAULT_URL, &plugin_hooks).unwrap_err(); assert!(error.contains("failed to write")); assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); @@ -1659,18 +3666,32 @@ base_url = "http://127.0.0.1:47633" fn healthz_times_out_for_bad_port_occupant() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); - let handle = thread::spawn(move || { + let (accepted_sender, accepted_receiver) = std::sync::mpsc::channel(); + let (release_sender, release_receiver) = std::sync::mpsc::channel(); + let server = thread::spawn(move || { let Ok((mut stream, _)) = listener.accept() else { return; }; - thread::sleep(Duration::from_secs(2)); + accepted_sender.send(()).unwrap(); + release_receiver + .recv_timeout(Duration::from_secs(5)) + .unwrap(); let _ = stream.write_all(b"HTTP/1.1 200 OK\r\n\r\n"); }); + let (result_sender, result_receiver) = std::sync::mpsc::channel(); + let health = thread::spawn(move || { + let result = healthz(&format!("http://127.0.0.1:{port}")); + result_sender.send(result).unwrap(); + }); - let started = Instant::now(); - assert!(!healthz(&format!("http://127.0.0.1:{port}"))); - assert!(started.elapsed() < Duration::from_secs(2)); - handle.join().unwrap(); + accepted_receiver + .recv_timeout(Duration::from_secs(5)) + .unwrap(); + let result = result_receiver.recv_timeout(Duration::from_secs(5)); + release_sender.send(()).unwrap(); + server.join().unwrap(); + health.join().unwrap(); + assert!(!result.expect("health probe did not honor its read timeout")); } #[test] @@ -1725,16 +3746,6 @@ fn shared_filesystem_helpers_cover_tables_snapshots_and_lock_branches() { fs::write(&existing, "after").unwrap(); restore_file_snapshot(&snapshot).unwrap(); assert_eq!(fs::read_to_string(&existing).unwrap(), "before"); - - let lock = dir.path().join("lock"); - assert!(!repair_stale_lock_after(&lock, Duration::ZERO)); - fs::write(&lock, "not a directory").unwrap(); - assert!(!repair_stale_lock_after(&lock, Duration::ZERO)); - fs::remove_file(&lock).unwrap(); - fs::create_dir(&lock).unwrap(); - assert!(lock_is_old(&lock, Duration::ZERO)); - assert!(repair_stale_lock_after(&lock, Duration::ZERO)); - assert!(!lock.exists()); } #[test] @@ -1759,7 +3770,11 @@ fn shared_url_env_and_response_helpers_cover_error_branches() { gateway_url(CodingAgent::Codex, Some("http://127.0.0.1:9")), "http://127.0.0.1:9" ); - assert_eq!(plugin_idle_timeout(), "7"); + assert_eq!(plugin_idle_timeout().unwrap(), Duration::from_secs(7)); + assert_eq!( + plugin_heartbeat_interval().unwrap(), + Duration::from_secs(7) / 3 + ); assert!(fail_closed()); assert_eq!( @@ -1797,6 +3812,10 @@ fn shared_url_env_and_response_helpers_cover_error_branches() { parse_loopback_url("http://localhost:47632/path").unwrap(), ("localhost".to_string(), 47632) ); + assert_eq!( + parse_loopback_url("http://[::1]:47632/path").unwrap(), + ("::1".to_string(), 47632) + ); assert!( parse_loopback_url("https://127.0.0.1:47632") .unwrap_err() @@ -1815,7 +3834,7 @@ fn shared_url_env_and_response_helpers_cover_error_branches() { assert!( parse_loopback_url("http://127.0.0.1:nope") .unwrap_err() - .contains("invalid gateway port") + .contains("invalid plugin shim loopback URL") ); assert_eq!( @@ -1844,7 +3863,11 @@ fn shared_defaults_cover_runtime_username_and_empty_segments() { let _fail_closed = EnvVarGuard::remove("NEMO_RELAY_FAIL_CLOSED"); assert_eq!(gateway_url(CodingAgent::Codex, None), DEFAULT_URL); - assert_eq!(plugin_idle_timeout(), "300"); + assert_eq!(plugin_idle_timeout().unwrap(), Duration::from_secs(300)); + assert_eq!( + plugin_heartbeat_interval().unwrap(), + Duration::from_secs(30) + ); assert!(!fail_closed()); assert_eq!( runtime_dir_for( @@ -1855,7 +3878,9 @@ fn shared_defaults_cover_runtime_username_and_empty_segments() { None, Some("bob/name".into()), ), - std::path::PathBuf::from("/tmp/temp-base").join("nemo-relay-plugin") + std::path::PathBuf::from("/tmp/temp-base") + .join("bob_name") + .join("nemo-relay-plugin") ); assert_eq!(sidecar_lock_name(""), "unknown"); assert_eq!( @@ -2018,29 +4043,48 @@ fn plugin_shim_entrypoints_reject_unsupported_agents_and_report_json() { .unwrap(), ) .unwrap(); + let plugin_root = dir.path().join("plugin"); + let plugin_hooks = plugin_root.join("hooks").join("hooks.json"); + fs::create_dir_all(plugin_hooks.parent().unwrap()).unwrap(); + fs::write( + &plugin_hooks, + serde_json::to_vec_pretty(&json!({ + "hooks": { + "SessionStart": [{ + "hooks": [{ + "type": "command", + "command": expected_plugin_command(), + "timeout": 30 + }] + }] + } + })) + .unwrap(), + ) + .unwrap(); - let report = doctor_plugin_json(CodingAgent::ClaudeCode, DEFAULT_URL).unwrap(); + let report = doctor_plugin_json(CodingAgent::ClaudeCode, DEFAULT_URL, &plugin_root).unwrap(); assert_eq!(report["sidecar_health"], json!("not_running_lazy_start")); assert_eq!(report["checks"]["claude_provider_routing"], json!(true)); - let codex_report = doctor_plugin_json(CodingAgent::Codex, DEFAULT_URL).unwrap(); + let codex_report = doctor_plugin_json(CodingAgent::Codex, DEFAULT_URL, &plugin_root).unwrap(); assert_eq!( codex_report["sidecar_health"], - json!("not_running_lazy_start") + json!("not_running_mcp_start") ); assert_eq!(codex_report["checks"]["codex_provider_alias"], json!(false)); assert_eq!(codex_report["checks"]["codex_hooks"], json!(false)); assert!( - doctor_plugin_json(CodingAgent::Hermes, DEFAULT_URL) + doctor_plugin_json(CodingAgent::Hermes, DEFAULT_URL, &plugin_root) .unwrap_err() .contains("supports claude and codex") ); assert!( - doctor_plugin(CodingAgent::Hermes, DEFAULT_URL) + doctor_plugin(CodingAgent::Hermes, DEFAULT_URL, &plugin_root) .unwrap_err() .contains("supports claude and codex") ); assert!( - doctor_plugin(CodingAgent::Codex, DEFAULT_URL) + doctor_plugin(CodingAgent::Codex, DEFAULT_URL, &plugin_root) .unwrap_err() .contains("codex plugin doctor checks failed") ); @@ -2072,6 +4116,7 @@ fn plugin_shim_entrypoints_reject_unsupported_agents_and_report_json() { assert!( post_hook(CodingAgent::Hermes, DEFAULT_URL, b"{}") .unwrap_err() + .to_string() .contains("supports claude and codex") ); } diff --git a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs index 2781cbc00..eac8e24ed 100644 --- a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs @@ -14,11 +14,92 @@ use crate::config::{ }; use crate::error::PluginLifecycleFailureKind; use base64::Engine; -use nemo_relay::plugin::dynamic::DynamicPluginFailurePhase; +use nemo_relay::plugin::dynamic::{ + DynamicPluginFailurePhase, WorkerPluginLoadSpec, load_worker_plugins, +}; use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; use sha2::{Digest, Sha256}; +#[cfg(unix)] +#[test] +fn python_venv_launcher_detection_only_preserves_bin_python_links() { + assert!(is_python_venv_launcher(Path::new("env/bin/python"))); + assert!(is_python_venv_launcher(Path::new("env/bin/python3.11"))); + assert!(!is_python_venv_launcher(Path::new("env/bin/pip"))); + assert!(!is_python_venv_launcher(Path::new("env/lib/python3.11"))); +} + +#[cfg(unix)] +#[test] +fn snapshot_protection_does_not_follow_python_launcher_symlink() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("external-python"); + std::fs::write(&target, b"python").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap(); + let root = temp.path().join("snapshot"); + let bin = root.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + symlink(&target, bin.join("python")).unwrap(); + + protect_snapshot_tree(&root).unwrap(); + + assert!( + std::fs::symlink_metadata(bin.join("python")) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!( + std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o755 + ); + make_snapshot_removable(&root); +} + +#[cfg(unix)] +#[test] +fn snapshot_digest_hashes_python_launcher_symlink_without_following_it() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("snapshot"); + let bin = root + .join(MANAGED_ENVIRONMENTS_DIR) + .join("environment") + .join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let launcher = bin.join("python"); + symlink("/missing/python-a", &launcher).unwrap(); + + let first_verification = snapshot_tree_digest(&root, false).unwrap(); + let first_identity = snapshot_tree_digest(&root, true).unwrap(); + + std::fs::remove_file(&launcher).unwrap(); + std::fs::write(&launcher, b"/missing/python-a").unwrap(); + assert_ne!( + first_verification, + snapshot_tree_digest(&root, false).unwrap(), + "a regular file must not collide with an equivalent symlink target" + ); + + std::fs::remove_file(&launcher).unwrap(); + symlink("/missing/python-b", &launcher).unwrap(); + + assert_ne!( + first_verification, + snapshot_tree_digest(&root, false).unwrap(), + "verification must include the exact launcher target" + ); + assert_eq!( + first_identity, + snapshot_tree_digest(&root, true).unwrap(), + "managed environment contents are excluded from stable gateway identity" + ); +} + struct CurrentDirGuard { original: PathBuf, } @@ -592,6 +673,450 @@ fn tracked_native_plugin_example_rejects_tampered_artifact() { ); } +#[cfg(unix)] +#[test] +fn activation_snapshot_never_rereads_replaced_or_oversized_worker_code() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugin"); + let worker_dir = temp.path().join("worker-runtime"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::create_dir_all(&worker_dir).unwrap(); + let artifact_path = worker_dir.join("worker.sh"); + let safe_worker = "#!/bin/sh\nexit 1\n".to_string(); + std::fs::write(&artifact_path, &safe_worker).unwrap(); + std::fs::write(worker_dir.join("resource.txt"), b"expected\n").unwrap(); + std::fs::set_permissions(&artifact_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + let digest = Sha256::digest(safe_worker.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let manifest_path = plugin_dir.join("relay-plugin.toml"); + std::fs::write( + &manifest_path, + format!( + r#"manifest_version = 1 + +[plugin] +id = "acme.snapshot-race" +kind = "worker" + +[compat] +relay = "0.5" +worker_protocol = "grpc-v1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker"] + +[source] +artifact = "../worker-runtime/worker.sh" + +[integrity] +sha256 = "sha256:{digest}" + +[load] +runtime = "command" +entrypoint = "../worker-runtime/worker.sh" +"# + ), + ) + .unwrap(); + let snapshot = DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.snapshot-race", + DynamicPluginKind::Worker, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap(); + assert_eq!( + std::fs::read(snapshot.root.join("external-entrypoint/resource.txt")).unwrap(), + b"expected\n" + ); + let (activation_manifest, _) = + DynamicPluginManifest::load_from_path(PathBuf::from(snapshot.activation_manifest_ref())) + .unwrap(); + let activation_artifact = activation_manifest + .source + .as_ref() + .and_then(|source| source.artifact.as_deref()) + .unwrap(); + let DynamicPluginManifestLoad::Worker(activation_load) = &activation_manifest.load else { + panic!("command activation must retain a worker load contract"); + }; + assert_eq!( + Some(activation_artifact), + activation_load.entrypoint.as_deref(), + "the integrity-checked artifact and executed entrypoint must be one snapshot file" + ); + let source_closure_digest = + dynamic_plugin_runtime_closure_digest(manifest_path.to_string_lossy().as_ref(), None) + .unwrap(); + assert_eq!(snapshot.closure_digest(), source_closure_digest); + + let marker = temp.path().join("replaced-worker-executed"); + std::fs::write( + &artifact_path, + format!("#!/bin/sh\ntouch {}\nexit 1\n", marker.display()), + ) + .unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&artifact_path) + .unwrap() + .set_len(crate::config::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1) + .unwrap(); + std::fs::set_permissions(&artifact_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(&manifest_path, b"not valid TOML").unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&manifest_path) + .unwrap() + .set_len(crate::config::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1) + .unwrap(); + + let error = match load_worker_plugins(vec![WorkerPluginLoadSpec { + plugin_id: "acme.snapshot-race".into(), + manifest_ref: snapshot.activation_manifest_ref(), + environment_ref: None, + config: Map::new(), + }]) { + Ok(_) => panic!("safe snapshot worker unexpectedly activated"), + Err(error) => error.to_string(), + }; + + assert!( + !marker.exists(), + "the replaced original worker was executed" + ); + assert!(!error.contains("invalid relay-plugin.toml"), "{error}"); + assert!(!error.contains("exceeds the"), "{error}"); +} + +#[cfg(unix)] +#[test] +fn activation_snapshot_detects_mutation_of_the_runtime_copy() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let manifest_path = write_native_dynamic_manifest(temp.path(), "acme.snapshot-mutation"); + let snapshot = DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.snapshot-mutation", + DynamicPluginKind::RustDynamic, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap(); + std::fs::set_permissions(&snapshot.root, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::set_permissions( + snapshot.activation_manifest.parent().unwrap(), + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + std::fs::set_permissions( + &snapshot.activation_manifest, + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + std::fs::write(&snapshot.activation_manifest, b"replaced").unwrap(); + + let error = snapshot.verify_current().unwrap_err().to_string(); + assert!(error.contains("changed before code load"), "{error}"); +} + +#[test] +fn activation_snapshot_keeps_adjacent_native_dependencies_for_external_load_target() { + let temp = tempfile::tempdir().unwrap(); + let manifest_dir = temp.path().join("plugin"); + let native_dir = temp.path().join("native-runtime"); + std::fs::create_dir_all(&manifest_dir).unwrap(); + std::fs::create_dir_all(&native_dir).unwrap(); + let library = native_dir.join("libfixture_native.so"); + let library_bytes = b"native plugin fixture"; + std::fs::write(&library, library_bytes).unwrap(); + std::fs::write( + native_dir.join("libadjacent_dependency.so"), + b"adjacent dependency", + ) + .unwrap(); + let digest = Sha256::digest(library_bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let manifest_path = manifest_dir.join("relay-plugin.toml"); + std::fs::write( + &manifest_path, + format!( + r#"manifest_version = 1 + +[plugin] +id = "acme.external-native-closure" +kind = "rust_dynamic" + +[compat] +relay = "0.5" +native_api = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_native"] + +[source] +artifact = "../native-runtime/libfixture_native.so" + +[integrity] +sha256 = "sha256:{digest}" + +[load] +library = "../native-runtime/libfixture_native.so" +symbol = "nemo_relay_fixture_native_plugin" +"#, + ), + ) + .unwrap(); + + let snapshot = DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.external-native-closure", + DynamicPluginKind::RustDynamic, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap(); + + assert!( + snapshot + .root + .join("external-library/libadjacent_dependency.so") + .is_file() + ); + assert_eq!( + snapshot.closure_digest(), + dynamic_plugin_runtime_closure_digest(manifest_path.to_string_lossy().as_ref(), None) + .unwrap() + ); +} + +#[test] +fn activation_snapshot_and_python_attestation_enforce_exact_directory_depth_boundary() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let plugin_dir = temp.path().join("deep-plugin"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest_path = write_native_dynamic_manifest(&plugin_dir, "acme.deep-closure"); + let mut deep_plugin_path = plugin_dir.clone(); + for _ in 1..MAX_SNAPSHOT_DEPTH { + deep_plugin_path.push("d"); + } + std::fs::create_dir_all(&deep_plugin_path).unwrap(); + + dynamic_plugin_runtime_closure_digest(manifest_path.to_string_lossy().as_ref(), None).unwrap(); + DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.deep-closure", + DynamicPluginKind::RustDynamic, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap(); + + deep_plugin_path.push("too-deep"); + std::fs::create_dir(&deep_plugin_path).unwrap(); + + let error = + dynamic_plugin_runtime_closure_digest(manifest_path.to_string_lossy().as_ref(), None) + .unwrap_err() + .to_string(); + assert!(error.contains("traversal depth"), "{error}"); + + let error = DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.deep-closure", + DynamicPluginKind::RustDynamic, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("traversal depth"), "{error}"); + + let environment_path = temp.path().join("deep-environment"); + let mut deep_environment_path = environment_path.clone(); + for _ in 1..environment::MAX_ENVIRONMENT_DEPTH { + deep_environment_path.push("d"); + } + std::fs::create_dir_all(&deep_environment_path).unwrap(); + environment::write_environment_attestation(&environment_path, "sha256:fixture-source-artifact") + .unwrap(); + + deep_environment_path.push("too-deep"); + std::fs::create_dir(&deep_environment_path).unwrap(); + let error = environment::write_environment_attestation( + &environment_path, + "sha256:fixture-source-artifact", + ) + .unwrap_err(); + assert!(error.contains("traversal depth"), "{error}"); +} + +#[test] +fn runtime_directory_collection_rejects_before_exceeding_bounded_sort_capacity() { + let temp = tempfile::tempdir().unwrap(); + for name in ["one", "two", "three"] { + std::fs::write(temp.path().join(name), name).unwrap(); + } + + let error = bounded_runtime_directory_entries(temp.path(), 2) + .unwrap_err() + .to_string(); + + assert!( + error.contains("entry activation snapshot budget"), + "{error}" + ); +} + +#[test] +fn python_environment_entry_budget_counts_skipped_cache_entries() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("ignored.pyc"), b"cache").unwrap(); + std::fs::write(temp.path().join("module.py"), b"module").unwrap(); + std::fs::write(temp.path().join("metadata.txt"), b"metadata").unwrap(); + + let error = + environment::test_environment_tree_digest_with_entry_limit(temp.path(), 2).unwrap_err(); + + assert!(error.contains("2-entry attestation budget"), "{error}"); +} + +#[test] +fn python_activation_snapshot_is_attested_copied_and_tamper_evident() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let plugin_dir = temp.path().join("python-plugin"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest_path = write_python_dynamic_manifest(&plugin_dir, "acme.python-snapshot"); + let environment_name = Sha256::digest(b"acme.python-snapshot") + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let environment_path = temp + .path() + .join(environment::MANAGED_ENVIRONMENTS_DIR) + .join(environment_name); + let interpreter = environment::environment_python_path(&environment_path); + std::fs::create_dir_all(interpreter.parent().unwrap()).unwrap(); + std::fs::write(&interpreter, b"attested interpreter").unwrap(); + let installed_module = environment_path + .join("site-packages") + .join("plugin-data.txt"); + std::fs::create_dir_all(installed_module.parent().unwrap()).unwrap(); + std::fs::write(&installed_module, b"safe installed module").unwrap(); + let (manifest, _) = DynamicPluginManifest::load_from_path(&manifest_path).unwrap(); + let source_artifact_sha256 = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.sha256.as_deref()) + .unwrap(); + environment::write_environment_attestation(&environment_path, source_artifact_sha256).unwrap(); + + let snapshot = DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.python-snapshot", + DynamicPluginKind::Worker, + Some(environment_path.to_string_lossy().as_ref()), + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap(); + let source_closure_digest = dynamic_plugin_runtime_closure_digest( + manifest_path.to_string_lossy().as_ref(), + Some(environment_path.to_string_lossy().as_ref()), + ) + .unwrap(); + assert_eq!(snapshot.closure_digest(), source_closure_digest); + let copied_environment = PathBuf::from(snapshot.activation_environment_ref().unwrap()); + assert_ne!(copied_environment, environment_path); + assert_eq!( + copied_environment.parent().unwrap().file_name(), + Some(OsStr::new(environment::MANAGED_ENVIRONMENTS_DIR)) + ); + assert_eq!(copied_environment.file_name(), environment_path.file_name()); + assert_eq!( + std::fs::read(copied_environment.join("site-packages/plugin-data.txt")).unwrap(), + b"safe installed module" + ); + let load_error = match load_worker_plugins(vec![WorkerPluginLoadSpec { + plugin_id: "acme.python-snapshot".into(), + manifest_ref: snapshot.activation_manifest_ref(), + environment_ref: snapshot.activation_environment_ref().map(ToOwned::to_owned), + config: Map::new(), + }]) { + Ok(_) => panic!("fixture Python worker unexpectedly activated"), + Err(error) => error.to_string(), + }; + assert!( + !load_error.contains("not the lifecycle-managed path"), + "{load_error}" + ); + + std::fs::write(&installed_module, b"tampered installed module").unwrap(); + + assert!( + environment::verify_environment_attestation(&environment_path, source_artifact_sha256) + .is_err() + ); + assert_eq!( + std::fs::read(copied_environment.join("site-packages/plugin-data.txt")).unwrap(), + b"safe installed module" + ); + snapshot.verify_current().unwrap(); + dynamic_plugin_runtime_closure_digest( + manifest_path.to_string_lossy().as_ref(), + Some(environment_path.to_string_lossy().as_ref()), + ) + .expect("hook preflight should authenticate the attestation without rehashing the environment"); + let changed_error = DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.python-snapshot", + DynamicPluginKind::Worker, + Some(environment_path.to_string_lossy().as_ref()), + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + assert!( + changed_error.contains("changed after provisioning"), + "{changed_error}" + ); + let attestation_path = environment_path.join(environment::ENVIRONMENT_ATTESTATION_FILE); + let mut forged: serde_json::Value = + serde_json::from_slice(&std::fs::read(&attestation_path).unwrap()).unwrap(); + forged["environment_sha256"] = + serde_json::json!(environment::environment_tree_digest(&environment_path).unwrap()); + std::fs::write( + &attestation_path, + serde_json::to_vec_pretty(&forged).unwrap(), + ) + .unwrap(); + let error = DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.python-snapshot", + DynamicPluginKind::Worker, + Some(environment_path.to_string_lossy().as_ref()), + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("failed authentication"), "{error}"); +} + #[test] fn add_registers_dynamic_plugin_in_project_plugins_toml() { let temp = tempfile::tempdir().unwrap(); @@ -1099,6 +1624,72 @@ fn add_requires_manifest_root_for_python_workers() { assert!(runner.calls().is_empty()); } +#[test] +fn add_rejects_python_entrypoint_module_that_is_not_integrity_checked_artifact() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("python"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest = write_python_dynamic_manifest(&plugin_dir, "acme.unsigned-entrypoint"); + std::fs::write( + plugin_dir.join("unsigned_sibling.py"), + b"def main(): pass\n", + ) + .unwrap(); + let contents = std::fs::read_to_string(&manifest).unwrap().replace( + "entrypoint = \"plugin:main\"", + "entrypoint = \"unsigned_sibling:main\"", + ); + std::fs::write(&manifest, contents).unwrap(); + let runner = FakePythonEnvironmentRunner::default(); + + let error = add_with_environment_runner( + PluginsAddCommand { + scope: PluginsScopeArgs { + project: true, + ..PluginsScopeArgs::default() + }, + path: plugin_dir, + }, + &ServerArgs::default(), + &runner, + ) + .expect_err("an unsigned sibling module must not become the executed entrypoint"); + + let (_, _, kind, code, message) = error + .as_plugin_lifecycle_error_context() + .expect("environment refusal should be structured"); + assert_eq!(kind, PluginLifecycleFailureKind::Failed); + assert_eq!(code, Some("environment_failed")); + assert!(message.contains("executed entrypoint module"), "{message}"); + assert!(message.contains("integrity-checked artifact"), "{message}"); + assert!(runner.calls().is_empty()); +} + +#[test] +fn activation_snapshot_rejects_ambiguous_python_entrypoint_module() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("python-plugin"); + std::fs::create_dir_all(plugin_dir.join("plugin")).unwrap(); + let manifest = write_python_dynamic_manifest(&plugin_dir, "acme.ambiguous-entrypoint"); + std::fs::write(plugin_dir.join("plugin/__init__.py"), b"def main(): pass\n").unwrap(); + + let error = DynamicPluginActivationSnapshot::create( + manifest.to_string_lossy().as_ref(), + "acme.ambiguous-entrypoint", + DynamicPluginKind::Worker, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("exactly one source module"), "{error}"); + assert!(error.contains("plugin.py"), "{error}"); + assert!(error.contains("__init__.py"), "{error}"); +} + #[test] fn managed_environment_cleanup_refuses_paths_outside_lifecycle_directory() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index 25d7ac598..00b8bfeb7 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -30,6 +30,7 @@ use tokio::task::JoinHandle; use tower::ServiceExt; use super::*; +use crate::config::BootstrapChallengeKey; use crate::error::CliError; use crate::plugins::lifecycle::ActiveDynamicPluginComponent; use crate::test_support::PLUGIN_CONFIG_TEST_LOCK; @@ -371,7 +372,163 @@ async fn healthz_returns_ok() { assert_eq!(response.status(), StatusCode::OK); let bytes = response.into_body().collect().await.unwrap().to_bytes(); let body: Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(body, json!({ "status": "ok" })); + assert_eq!(body["status"], json!("ok")); + assert_eq!(body["service"], json!("nemo-relay")); + assert_eq!(body["version"], json!(env!("CARGO_PKG_VERSION"))); + assert_eq!(body["bootstrap_protocol"], json!(1)); +} + +#[tokio::test] +async fn healthz_rejects_a_different_persistent_gateway_fingerprint() { + let app = router_with_state(AppState::new_with_bootstrap( + test_config(), + Some("expected-fingerprint".into()), + Some(BootstrapChallengeKey::from_bytes(b"test challenge key")), + None, + )); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/healthz") + .header( + "x-nemo-relay-bootstrap-fingerprint", + "different-fingerprint", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CONFLICT); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["status"], json!("incompatible")); + assert!(body.get("bootstrap_fingerprint").is_none()); +} + +#[tokio::test] +async fn healthz_only_refreshes_idle_activity_for_an_authenticated_heartbeat() { + let challenge_key = BootstrapChallengeKey::from_bytes(b"test challenge key"); + let state = AppState::new_with_bootstrap( + test_config(), + Some("expected-fingerprint".into()), + Some(challenge_key.clone()), + None, + ); + let activity = state.last_activity.clone(); + let baseline = std::time::Instant::now() - Duration::from_secs(30); + *activity.lock().unwrap() = baseline; + let app = router_with_state(state); + + for fingerprint in [ + None, + Some("wrong-fingerprint"), + Some("expected-fingerprint"), + ] { + let mut request = Request::builder().method("GET").uri("/healthz"); + if let Some(fingerprint) = fingerprint { + request = request.header("x-nemo-relay-bootstrap-fingerprint", fingerprint); + } + let _ = app + .clone() + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(*activity.lock().unwrap(), baseline); + } + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/healthz") + .header("x-nemo-relay-bootstrap-fingerprint", "expected-fingerprint") + .header( + "x-nemo-relay-bootstrap-nonce", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-nemo-relay-bootstrap-proof") + .unwrap(), + challenge_key + .proof( + "expected-fingerprint", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ) + .as_str() + ); + assert!(*activity.lock().unwrap() > baseline); +} + +#[tokio::test] +async fn bootstrap_shutdown_requires_the_private_owner_token() { + let (sender, receiver) = oneshot::channel(); + let app = router_with_state(AppState::new_with_bootstrap( + test_config(), + None, + None, + Some(BootstrapShutdown { + token: "private-token".into(), + sender: Arc::new(std::sync::Mutex::new(Some(sender))), + }), + )); + let rejected = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/bootstrap/shutdown") + .header("x-nemo-relay-bootstrap-token", "wrong-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::FORBIDDEN); + + let accepted = app + .oneshot( + Request::builder() + .method("POST") + .uri("/bootstrap/shutdown") + .header("x-nemo-relay-bootstrap-token", "private-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(accepted.status(), StatusCode::NO_CONTENT); + tokio::time::timeout(std::time::Duration::from_secs(1), receiver) + .await + .expect("shutdown signal was not delivered") + .unwrap(); +} + +#[test] +fn readiness_file_is_published_atomically_with_gateway_identity() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("gateway.ready.json"); + let address = "127.0.0.1:43123".parse().unwrap(); + + write_ready_file(&path, address).unwrap(); + + let ready: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(ready["address"], json!(address)); + assert_eq!(ready["service"], json!("nemo-relay")); + assert_eq!(ready["version"], json!(env!("CARGO_PKG_VERSION"))); + assert_eq!(ready["bootstrap_protocol"], json!(1)); + assert!(!path.with_extension("json.tmp").exists()); } #[tokio::test] @@ -462,20 +619,20 @@ async fn plugin_idle_timeout_parses_absent_invalid_zero_and_positive_values() { let key = "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS"; let removed = EnvVarGuard::remove(key); - assert_eq!(plugin_idle_timeout(), None); + assert_eq!(plugin_idle_timeout().unwrap(), None); drop(removed); let invalid = EnvVarGuard::set(key, "not-a-number"); - assert_eq!(plugin_idle_timeout(), None); + assert!(plugin_idle_timeout().is_err()); drop(invalid); let zero = EnvVarGuard::set(key, "0"); - assert_eq!(plugin_idle_timeout(), None); + assert!(plugin_idle_timeout().is_err()); drop(zero); let positive = EnvVarGuard::set(key, "2"); assert_eq!( - plugin_idle_timeout(), + plugin_idle_timeout().unwrap(), Some(std::time::Duration::from_secs(2)) ); drop(positive); @@ -537,6 +694,36 @@ async fn serve_listener_waits_for_active_turn_before_plugin_idle_shutdown() { result.unwrap(); } +#[tokio::test] +async fn idle_shutdown_rechecks_activity_after_session_lookup() { + let timeout = std::time::Duration::from_secs(1); + let last_activity = Arc::new(std::sync::Mutex::new( + std::time::Instant::now() - timeout - std::time::Duration::from_millis(1), + )); + let activity_during_lookup = Arc::clone(&last_activity); + + let ready = idle_shutdown_ready(&last_activity, timeout, async move { + *activity_during_lookup.lock().unwrap() = std::time::Instant::now(); + false + }) + .await; + + assert!(!ready, "new activity must cancel a stale shutdown decision"); +} + +#[tokio::test] +async fn idle_shutdown_requires_expiry_and_no_open_session_without_new_activity() { + let timeout = std::time::Duration::from_secs(1); + let recent = Arc::new(std::sync::Mutex::new(std::time::Instant::now())); + assert!(!idle_shutdown_ready(&recent, timeout, async { false }).await); + + let expired = Arc::new(std::sync::Mutex::new( + std::time::Instant::now() - timeout - std::time::Duration::from_millis(1), + )); + assert!(!idle_shutdown_ready(&expired, timeout, async { true }).await); + assert!(idle_shutdown_ready(&expired, timeout, async { false }).await); +} + #[tokio::test] async fn serve_listener_exits_after_codex_stop_without_session_end() { let _guard = PLUGIN_CONFIG_TEST_LOCK.lock().await; @@ -1632,6 +1819,8 @@ async fn serve_listener_records_codex_stop_atof_contract() { assert_eq!(turn_start["metadata"]["turn_source"], "user_prompt"); assert_eq!(turn_end["data"]["hook_event_name"], "Stop"); assert_eq!(turn_end["data"]["response"], "Done."); + assert_eq!(turn_end["metadata"]["hook_event_name"], "Stop"); + assert_eq!(turn_end["metadata"]["session_id"], "codex-atof-session"); let tool_start = find_scope_event(&events, "Read", "tool", "start"); let tool_end = find_scope_event(&events, "Read", "tool", "end"); @@ -1831,9 +2020,11 @@ async fn serve_listener_with_dynamic_reports_native_load_errors() { vec![ActiveDynamicPluginComponent { plugin_id: "cli.missing-native".into(), kind: DynamicPluginKind::RustDynamic, + lifecycle_generation: 0, manifest_ref: Some(manifest_ref.to_string_lossy().into_owned()), environment_ref: None, config: Map::new(), + activation_snapshot: None, }], Some(shutdown_rx), ) diff --git a/crates/cli/tests/coverage/session_tests.rs b/crates/cli/tests/coverage/session_tests.rs index 117831a3c..0f043253d 100644 --- a/crates/cli/tests/coverage/session_tests.rs +++ b/crates/cli/tests/coverage/session_tests.rs @@ -7,7 +7,7 @@ use nemo_relay::api::runtime::EventSubscriberFn; use nemo_relay::api::subscriber::{deregister_subscriber, flush_subscribers, register_subscriber}; use nemo_relay::observability::atof::{AtofExporter, AtofExporterConfig, AtofExporterMode}; use nemo_relay::observability::openinference::OpenInferenceSubscriber; -use nemo_relay::plugin::{PluginConfig, clear_plugin_configuration, initialize_plugins}; +use nemo_relay::plugin::{PluginConfig, clear_plugin_configuration, initialize_plugins_exact}; use opentelemetry::KeyValue; use opentelemetry_sdk::trace::InMemorySpanExporterBuilder; use serde_json::json; @@ -42,7 +42,63 @@ async fn install_test_atif_plugin(output_directory: &Path) { ] })) .unwrap(); - initialize_plugins(config).await.unwrap(); + initialize_plugins_exact(config).await.unwrap(); +} + +#[tokio::test] +async fn atif_test_plugin_ignores_discovered_atof_configuration() { + let _guard = PLUGIN_CONFIG_TEST_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let project_config = temp.path().join(".nemo-relay/plugins.toml"); + std::fs::create_dir_all(project_config.parent().unwrap()).unwrap(); + std::fs::write( + &project_config, + r#"version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 1 + +[components.config.atof] +enabled = true +"#, + ) + .unwrap(); + let _cwd = crate::test_support::CwdTestScope::enter(temp.path()); + let atif_dir = temp.path().join("atif"); + install_test_atif_plugin(&atif_dir).await; + let manager = SessionManager::new(session_test_config()); + manager + .apply_events( + &HeaderMap::new(), + vec![ + NormalizedEvent::AgentStarted(session_event("hermetic-atif", "SessionStart")), + NormalizedEvent::PromptSubmitted(session_event( + "hermetic-atif", + "UserPromptSubmit", + )), + NormalizedEvent::AgentEnded(session_event("hermetic-atif", "SessionEnd")), + ], + ) + .await + .unwrap(); + let _trajectory = read_atif_for_session(&atif_dir, "hermetic-atif"); + clear_plugin_configuration().unwrap(); + + let leaked = std::fs::read_dir(temp.path()) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.file_name()) + .filter_map(|name| name.into_string().ok()) + .filter(|name| name.starts_with("nemo-relay-events-") && name.ends_with(".jsonl")) + .collect::>(); + assert!( + leaked.is_empty(), + "test plugin setup must not activate ambient ATOF exporters: {leaked:?}" + ); } fn make_atof_test_exporter(output_directory: &Path, filename: &str) -> AtofExporter { @@ -888,6 +944,121 @@ async fn turn_output_uses_last_root_owned_llm_response() { deregister_subscriber(subscriber_name).unwrap(); } +#[tokio::test] +async fn turn_end_metadata_comes_only_from_the_real_turn_boundary() { + let subscriber_name = "cli-turn-boundary-metadata-test"; + let _ = deregister_subscriber(subscriber_name); + let captured = Arc::new(StdMutex::new(HashMap::::new())); + let events = captured.clone(); + register_subscriber( + subscriber_name, + Arc::new(move |event| { + if event.scope_category() != Some(ScopeCategory::End) || event.name() != "codex-turn" { + return; + } + let Some(session_id) = event + .metadata() + .and_then(|metadata| metadata.get("session_id")) + .and_then(Value::as_str) + else { + return; + }; + events.lock().unwrap().insert( + session_id.to_string(), + ( + event.output().cloned().unwrap_or(Value::Null), + event.metadata().cloned().unwrap_or(Value::Null), + ), + ); + }), + ) + .unwrap(); + + let manager = SessionManager::new(session_test_config()); + for session_id in [ + "explicit-turn-end", + "fallback-turn-end", + "shutdown-turn-end", + ] { + manager + .apply_events( + &HeaderMap::new(), + vec![ + NormalizedEvent::AgentStarted(codex_session_event( + session_id, + "SessionStart", + json!({ "session_id": session_id }), + )), + NormalizedEvent::PromptSubmitted(codex_session_event( + session_id, + "UserPromptSubmit", + json!({ "session_id": session_id }), + )), + ], + ) + .await + .unwrap(); + } + let llm = manager + .start_llm( + &HeaderMap::new(), + LlmGatewayStart { + session_id: Some("explicit-turn-end".into()), + ..llm_start() + }, + ) + .await + .unwrap(); + manager + .end_llm(llm, json!({ "message": "pong" }), json!({})) + .await + .unwrap(); + manager + .apply_events( + &HeaderMap::new(), + vec![NormalizedEvent::TurnEnded(codex_session_event( + "explicit-turn-end", + "Stop", + json!({ + "session_id": "explicit-turn-end", + "hook_event_name": "Stop", + "boundary_processed": true + }), + ))], + ) + .await + .unwrap(); + manager + .apply_events( + &HeaderMap::new(), + vec![NormalizedEvent::AgentEnded(codex_session_event( + "fallback-turn-end", + "SessionEnd", + json!({ + "session_id": "fallback-turn-end", + "boundary_processed": "must-not-leak" + }), + ))], + ) + .await + .unwrap(); + manager.close_all("gateway_shutdown").await.unwrap(); + + flush_subscribers().unwrap(); + let captured = captured.lock().unwrap(); + let (output, metadata) = captured.get("explicit-turn-end").unwrap(); + assert_eq!(output, &json!({ "message": "pong" })); + assert_eq!(metadata["hook_event_name"], "Stop"); + assert_eq!(metadata["boundary_processed"], true); + let (_, fallback_metadata) = captured.get("fallback-turn-end").unwrap(); + assert!(fallback_metadata.get("boundary_processed").is_none()); + let (shutdown_output, shutdown_metadata) = captured.get("shutdown-turn-end").unwrap(); + assert_eq!(shutdown_output["status"], "gateway_shutdown"); + assert!(shutdown_metadata.get("boundary_processed").is_none()); + drop(captured); + deregister_subscriber(subscriber_name).unwrap(); +} + #[tokio::test] async fn new_subagent_claims_first_unhinted_llm_when_siblings_active() { let manager = SessionManager::new(session_test_config()); diff --git a/crates/cli/tests/coverage/sidecar_health_tests.rs b/crates/cli/tests/coverage/sidecar_health_tests.rs new file mode 100644 index 000000000..97583fec1 --- /dev/null +++ b/crates/cli/tests/coverage/sidecar_health_tests.rs @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use super::*; + +fn serve_once(response: &[u8]) -> (String, mpsc::Receiver>, thread::JoinHandle<()>) { + let response = response.to_vec(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let (sender, receiver) = mpsc::channel(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + match stream.read(&mut buffer) { + Ok(0) => break, + Ok(read) => request.extend_from_slice(&buffer[..read]), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + break; + } + Err(error) => panic!("failed to read request: {error}"), + } + } + let _ = sender.send(request); + stream.write_all(&response).unwrap(); + }); + (url, receiver, server) +} + +#[test] +fn shutdown_request_sends_the_private_token_and_accepts_no_content() { + let (url, request, server) = + serve_once(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + + request_shutdown(&url, "private-token").unwrap(); + + let request = String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap(); + assert!( + request.starts_with("POST /bootstrap/shutdown HTTP/1.1"), + "{request}" + ); + assert!( + request.contains("X-NeMo-Relay-Bootstrap-Token: private-token"), + "{request}" + ); + server.join().unwrap(); +} + +#[test] +fn shutdown_request_reports_rejection_without_hiding_the_status() { + let (url, _, server) = + serve_once(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + + let error = request_shutdown(&url, "wrong-token").unwrap_err(); + + assert!(error.contains("rejected shutdown"), "{error}"); + assert!(error.contains("HTTP/1.1 403 Forbidden"), "{error}"); + server.join().unwrap(); +} + +#[test] +fn shutdown_request_rejects_a_malformed_http_response() { + let (url, _, server) = serve_once(b"not-http"); + + let error = request_shutdown(&url, "private-token").unwrap_err(); + + assert!(error.contains("malformed shutdown response"), "{error}"); + server.join().unwrap(); +} + +#[test] +fn shutdown_request_reports_connection_failure() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + + let error = request_shutdown(&url, "private-token").unwrap_err(); + + assert!(error.contains("failed to connect"), "{error}"); +} + +#[test] +fn health_probe_classifies_invalid_and_malformed_endpoints_as_unavailable_or_foreign() { + assert_eq!(probe("not a URL", None), RelayHealth::Unavailable); + + let (url, _, server) = serve_once(b"not-http"); + assert_eq!(probe(&url, None), RelayHealth::Foreign); + server.join().unwrap(); + + let body = format!( + r#"{{"status":"starting","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let (url, _, server) = serve_once(response.as_bytes()); + assert_eq!(probe(&url, None), RelayHealth::Foreign); + server.join().unwrap(); +} + +#[test] +fn loopback_helpers_normalize_localhost_and_ipv6_authorities() { + assert_eq!( + loopback_bind("http://localhost:47632").unwrap(), + "127.0.0.1:47632".parse().unwrap() + ); + assert_eq!(loopback_authority("::1", 47632), "[::1]:47632"); +} diff --git a/crates/cli/tests/coverage/sidecar_state_tests.rs b/crates/cli/tests/coverage/sidecar_state_tests.rs new file mode 100644 index 000000000..e6cbb48c1 --- /dev/null +++ b/crates/cli/tests/coverage/sidecar_state_tests.rs @@ -0,0 +1,403 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::{OsStr, OsString}; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::Path; +use std::thread; +use std::time::Duration; + +use tempfile::tempdir; + +use super::*; + +const SHUTDOWN_TOKEN_ENV: &str = "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN"; + +struct Environment { + _lock: std::sync::MutexGuard<'static, ()>, + saved: Vec<(&'static str, Option)>, +} + +impl Environment { + fn isolated() -> Self { + let lock = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let keys = [ + "HOME", + "USERPROFILE", + "XDG_CONFIG_HOME", + BOOTSTRAP_STATE_DIR_ENV, + BOOTSTRAP_AGENT_ENV, + SHUTDOWN_TOKEN_ENV, + crate::config::BOOTSTRAP_FINGERPRINT_ENV, + ]; + let saved = keys + .into_iter() + .map(|key| (key, std::env::var_os(key))) + .collect(); + Self { _lock: lock, saved } + } + + fn set(&self, key: &'static str, value: impl AsRef) { + // SAFETY: This scope holds the process-wide environment test lock. + unsafe { std::env::set_var(key, value) }; + } + + fn remove(&self, key: &'static str) { + // SAFETY: This scope holds the process-wide environment test lock. + unsafe { std::env::remove_var(key) }; + } + + fn clear_managed_bootstrap(&self) { + self.remove(BOOTSTRAP_STATE_DIR_ENV); + self.remove(BOOTSTRAP_AGENT_ENV); + self.remove(SHUTDOWN_TOKEN_ENV); + self.remove(crate::config::BOOTSTRAP_FINGERPRINT_ENV); + } +} + +impl Drop for Environment { + fn drop(&mut self) { + // SAFETY: Restoration happens while the process-wide environment test lock is held. + unsafe { + for (key, value) in self.saved.drain(..).rev() { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } +} + +fn configure_managed_bootstrap(environment: &Environment, state: &Path) { + environment.set(BOOTSTRAP_STATE_DIR_ENV, state); + environment.set(BOOTSTRAP_AGENT_ENV, "codex"); + environment.set(SHUTDOWN_TOKEN_ENV, "shutdown-token"); + environment.set(crate::config::BOOTSTRAP_FINGERPRINT_ENV, "fingerprint"); +} + +fn read_http_headers(stream: &mut TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + } + String::from_utf8(request).unwrap() +} + +fn request_header(request: &str, name: &str) -> String { + request + .lines() + .find_map(|line| { + let (candidate, value) = line.split_once(':')?; + candidate + .eq_ignore_ascii_case(name) + .then(|| value.trim().to_string()) + }) + .unwrap_or_else(|| panic!("missing {name} in request: {request}")) +} + +#[test] +fn state_files_distinguish_absence_from_read_failure() { + let dir = tempdir().unwrap(); + let missing = dir.path().join("missing.json"); + + assert!(read_owner_record(&missing).unwrap().is_none()); + assert!(read_ready_file(&missing).unwrap().is_none()); + + let owner_error = read_owner_record(dir.path()).unwrap_err(); + assert!(owner_error.contains("failed to read sidecar ownership")); + let ready_error = read_ready_file(dir.path()).unwrap_err(); + assert!(ready_error.contains("failed to read sidecar readiness file")); +} + +#[test] +fn state_directory_requires_a_user_configuration_home() { + let environment = Environment::isolated(); + environment.remove("HOME"); + environment.remove("USERPROFILE"); + environment.remove("XDG_CONFIG_HOME"); + + let error = state_dir().unwrap_err(); + + assert!(error.contains("cannot determine"), "{error}"); +} + +#[test] +fn managed_owner_environment_is_validated_before_writing_state() { + let dir = tempdir().unwrap(); + let environment = Environment::isolated(); + environment.clear_managed_bootstrap(); + + environment.set(BOOTSTRAP_STATE_DIR_ENV, "relative-state"); + environment.set(BOOTSTRAP_AGENT_ENV, "codex"); + environment.set(SHUTDOWN_TOKEN_ENV, "shutdown-token"); + let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); + assert!(error.contains("must be an absolute path"), "{error}"); + + environment.set(BOOTSTRAP_STATE_DIR_ENV, dir.path()); + environment.set(BOOTSTRAP_AGENT_ENV, "unsupported"); + let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); + assert!(error.contains("unsupported bootstrap agent"), "{error}"); + + environment.remove(BOOTSTRAP_AGENT_ENV); + let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); + assert!(error.contains(BOOTSTRAP_AGENT_ENV), "{error}"); + + environment.set(BOOTSTRAP_AGENT_ENV, "codex"); + environment.set(SHUTDOWN_TOKEN_ENV, ""); + let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); + assert!(error.contains(SHUTDOWN_TOKEN_ENV), "{error}"); + + environment.set(SHUTDOWN_TOKEN_ENV, "shutdown-token"); + let error = publish_owner_from_env("0.0.0.0:47632".parse().unwrap()).unwrap_err(); + assert!(error.contains("requires a loopback address"), "{error}"); + + let state_file = dir.path().join("state-file"); + std::fs::write(&state_file, "not a directory").unwrap(); + environment.set(BOOTSTRAP_STATE_DIR_ENV, &state_file); + let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); + assert!( + error.contains("failed to create bootstrap state directory"), + "{error}" + ); +} + +#[test] +fn managed_owner_supports_claude_code_identity() { + let dir = tempdir().unwrap(); + let state = dir.path().join("state"); + let environment = Environment::isolated(); + configure_managed_bootstrap(&environment, &state); + environment.set(BOOTSTRAP_AGENT_ENV, "claude-code"); + let address = "127.0.0.1:47633".parse().unwrap(); + + publish_owner_from_env(address).unwrap(); + + let url = format!("http://{address}"); + let owner = owner_path(&state, CodingAgent::ClaudeCode, &url); + let pid = pid_path(&state, CodingAgent::ClaudeCode, &url); + validate_owner( + &owner, + &pid, + std::process::id(), + &url, + "shutdown-token", + Some("fingerprint"), + ) + .unwrap(); +} + +#[test] +fn failed_owner_publish_removes_the_partial_pid_record() { + let dir = tempdir().unwrap(); + let state = dir.path().join("state"); + std::fs::create_dir_all(&state).unwrap(); + let environment = Environment::isolated(); + configure_managed_bootstrap(&environment, &state); + let address = "127.0.0.1:47632".parse().unwrap(); + let url = format!("http://{address}"); + let owner = owner_path(&state, CodingAgent::Codex, &url); + let pid = pid_path(&state, CodingAgent::Codex, &url); + std::fs::create_dir_all(&owner).unwrap(); + #[cfg(windows)] + { + let file_name = owner.file_name().unwrap().to_string_lossy(); + let stale_backup = owner.with_file_name(format!(".{file_name}.nemo-relay-replace.tmp")); + std::fs::create_dir_all(stale_backup).unwrap(); + } + + let error = publish_owner_from_env(address).unwrap_err(); + + assert!(error.contains("failed to"), "{error}"); + assert!(!pid.exists()); +} + +#[test] +fn owner_validation_reports_a_missing_record() { + let dir = tempdir().unwrap(); + let owner = dir.path().join("missing-owner.json"); + let pid = dir.path().join("missing-owner.pid"); + + let error = validate_owner( + &owner, + &pid, + 42, + "http://127.0.0.1:47632", + "shutdown-token", + Some("fingerprint"), + ) + .unwrap_err(); + + assert!(error.contains("file does not exist"), "{error}"); +} + +#[test] +fn owner_enumeration_distinguishes_missing_and_unreadable_directories() { + let dir = tempdir().unwrap(); + assert!( + owner_paths(&dir.path().join("missing"), CodingAgent::Codex) + .unwrap() + .is_empty() + ); + + let file = dir.path().join("not-a-directory"); + std::fs::write(&file, "file").unwrap(); + let error = owner_paths(&file, CodingAgent::Codex).unwrap_err(); + assert!(error.contains("failed to enumerate"), "{error}"); +} + +#[test] +fn stale_owner_cleanup_requires_shutdown_credentials() { + let dir = tempdir().unwrap(); + let url = "http://127.0.0.1:9"; + let owner = owner_path(dir.path(), CodingAgent::Codex, url); + + assert!( + stop_owned_record(CodingAgent::Codex, dir.path(), &owner).is_ok(), + "an already absent owner is clean" + ); + + write_owner(&owner, 42, url, "", Some("fingerprint")).unwrap(); + let error = stop_owned_record(CodingAgent::Codex, dir.path(), &owner).unwrap_err(); + assert!(error.contains("has no shutdown token"), "{error}"); + + write_owner(&owner, 42, url, "shutdown-token", None).unwrap(); + let error = stop_owned_record(CodingAgent::Codex, dir.path(), &owner).unwrap_err(); + assert!( + error.contains("no authenticated bootstrap fingerprint"), + "{error}" + ); +} + +#[test] +fn unavailable_owned_sidecar_is_removed_without_sending_shutdown() { + let dir = tempdir().unwrap(); + let url = "http://127.0.0.1:9"; + let owner = owner_path(dir.path(), CodingAgent::Codex, url); + let pid = pid_path(dir.path(), CodingAgent::Codex, url); + write_owner(&owner, 42, url, "shutdown-token", Some("fingerprint")).unwrap(); + std::fs::write(&pid, "42").unwrap(); + + stop_owned_record(CodingAgent::Codex, dir.path(), &owner).unwrap(); + + assert!(!owner.exists()); + assert!(!pid.exists()); +} + +#[test] +fn unavailable_legacy_owner_removes_the_legacy_pid_record() { + let dir = tempdir().unwrap(); + let url = "http://127.0.0.1:9"; + let owner = dir.path().join("codex-sidecar.owner.json"); + let pid = dir.path().join("codex-sidecar.pid"); + write_owner(&owner, 42, url, "shutdown-token", Some("fingerprint")).unwrap(); + std::fs::write(&pid, "42").unwrap(); + + stop_owned_record(CodingAgent::Codex, dir.path(), &owner).unwrap(); + + assert!(!owner.exists()); + assert!(!pid.exists()); +} + +#[test] +fn authenticated_owned_sidecar_is_shut_down_and_cleaned_up() { + let dir = tempdir().unwrap(); + let environment = Environment::isolated(); + environment.set("XDG_CONFIG_HOME", dir.path().join("config")); + environment.set("HOME", dir.path()); + environment.remove("USERPROFILE"); + let key = crate::config::BootstrapChallengeKey::load().unwrap(); + let reloaded_key = crate::config::BootstrapChallengeKey::load().unwrap(); + assert!(reloaded_key.verify( + "fingerprint", + "test-nonce", + &key.proof("fingerprint", "test-nonce") + )); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let owner = owner_path(dir.path(), CodingAgent::Codex, &url); + let pid = pid_path(dir.path(), CodingAgent::Codex, &url); + write_owner(&owner, 42, &url, "shutdown-token", Some("fingerprint")).unwrap(); + std::fs::write(&pid, "42").unwrap(); + let server = thread::spawn(move || { + for _ in 0..2 { + let (mut health, _) = listener.accept().unwrap(); + let request = read_http_headers(&mut health); + let fingerprint = request_header(&request, "x-nemo-relay-bootstrap-fingerprint"); + let nonce = request_header(&request, "x-nemo-relay-bootstrap-nonce"); + let proof = key.proof(&fingerprint, &nonce); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); + health + .write_all( + format!( + "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + } + + let (mut shutdown, _) = listener.accept().unwrap(); + let request = read_http_headers(&mut shutdown); + assert!(request.starts_with("POST /bootstrap/shutdown HTTP/1.1")); + assert_eq!( + request_header(&request, "x-nemo-relay-bootstrap-token"), + "shutdown-token" + ); + shutdown + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .unwrap(); + }); + + assert_eq!(probe(&url, Some("fingerprint")), RelayHealth::Compatible); + stop_owned_record(CodingAgent::Codex, dir.path(), &owner).unwrap(); + + server.join().unwrap(); + assert!(!owner.exists()); + assert!(!pid.exists()); +} + +#[test] +fn stop_owned_aggregates_record_errors() { + let dir = tempdir().unwrap(); + let environment = Environment::isolated(); + environment.set("XDG_CONFIG_HOME", dir.path()); + environment.set("HOME", dir.path()); + environment.remove("USERPROFILE"); + let runtime = state_dir().unwrap(); + std::fs::create_dir_all(&runtime).unwrap(); + let url = "http://127.0.0.1:9"; + let owner = owner_path(&runtime, CodingAgent::Codex, url); + write_owner(&owner, 42, url, "", Some("fingerprint")).unwrap(); + + let error = stop_owned(CodingAgent::Codex).unwrap_err(); + + assert!(error.contains("has no shutdown token"), "{error}"); +} + +#[test] +fn stop_owned_succeeds_when_no_records_exist() { + let dir = tempdir().unwrap(); + let environment = Environment::isolated(); + environment.set("XDG_CONFIG_HOME", dir.path()); + environment.set("HOME", dir.path()); + environment.remove("USERPROFILE"); + + stop_owned(CodingAgent::Codex).unwrap(); +} diff --git a/crates/cli/tests/coverage/sidecar_tests.rs b/crates/cli/tests/coverage/sidecar_tests.rs new file mode 100644 index 000000000..8bf385ffc --- /dev/null +++ b/crates/cli/tests/coverage/sidecar_tests.rs @@ -0,0 +1,423 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::OsString; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::process::Command; +use std::thread; + +use super::*; +use serde_json::json; + +struct EnvVarRestore { + key: &'static str, + previous: Option, +} + +impl EnvVarRestore { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var_os(key); + // SAFETY: Callers hold the process-wide environment test lock. + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } +} + +impl Drop for EnvVarRestore { + fn drop(&mut self) { + // SAFETY: Callers retain the process-wide environment test lock until after this guard. + unsafe { + match self.previous.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } +} + +#[test] +fn gateway_spec_is_the_complete_compatibility_contract() { + let bind = "127.0.0.1:47632".parse().unwrap(); + let first = GatewaySpec::new(CodingAgent::Codex, bind) + .with_launch_args(vec![ + OsString::from("--openai-base-url"), + OsString::from("mock"), + ]) + .with_fingerprint("fingerprint-a"); + let same = GatewaySpec::new(CodingAgent::Codex, bind) + .with_launch_args(vec![ + OsString::from("--openai-base-url"), + OsString::from("mock"), + ]) + .with_fingerprint("fingerprint-a"); + let different = GatewaySpec::new(CodingAgent::Codex, bind) + .with_launch_args(vec![ + OsString::from("--openai-base-url"), + OsString::from("other"), + ]) + .with_fingerprint("fingerprint-a"); + + assert_eq!(first, same); + assert_ne!(first, different); + assert_eq!(first.bind(), bind); +} + +#[test] +fn gateway_spec_rejects_non_loopback_bind_before_launch() { + let error = GatewaySpec::new(CodingAgent::Codex, "0.0.0.0:47632".parse().unwrap()) + .ensure() + .unwrap_err(); + + assert!(error.contains("require a loopback bind address"), "{error}"); +} + +#[test] +fn typed_owner_record_round_trips_and_rejects_identity_drift() { + let dir = tempfile::tempdir().unwrap(); + let owner = dir.path().join("owner.json"); + let pid = dir.path().join("owner.pid"); + std::fs::write(&pid, "42").unwrap(); + write_sidecar_owner( + &owner, + 42, + "http://127.0.0.1:47632", + "shutdown-token", + Some("fingerprint"), + ) + .unwrap(); + + validate_sidecar_owner( + &owner, + &pid, + 42, + "http://127.0.0.1:47632", + "shutdown-token", + Some("fingerprint"), + ) + .unwrap(); + let error = validate_sidecar_owner( + &owner, + &pid, + 42, + "http://127.0.0.1:47632", + "different-token", + Some("fingerprint"), + ) + .unwrap_err(); + assert!(error.contains("does not match the ready process")); +} + +#[test] +fn typed_owner_record_rejects_missing_required_fields() { + let dir = tempfile::tempdir().unwrap(); + let owner = dir.path().join("owner.json"); + let pid = dir.path().join("owner.pid"); + std::fs::write(&owner, serde_json::to_vec(&json!({"pid": 42})).unwrap()).unwrap(); + std::fs::write(&pid, "42").unwrap(); + + let error = validate_sidecar_owner( + &owner, + &pid, + 42, + "http://127.0.0.1:47632", + "shutdown-token", + Some("fingerprint"), + ) + .unwrap_err(); + + assert!(error.contains("invalid sidecar ownership file"), "{error}"); +} + +#[test] +fn readiness_file_requires_exact_protocol_identity() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ready.json"); + std::fs::write( + &path, + serde_json::to_vec(&json!({ + "service": "nemo-relay", + "version": env!("CARGO_PKG_VERSION"), + "bootstrap_protocol": BOOTSTRAP_PROTOCOL_VERSION, + "address": "127.0.0.1:47777" + })) + .unwrap(), + ) + .unwrap(); + + let endpoint = read_sidecar_ready_file(&path).unwrap().unwrap(); + assert_eq!(endpoint.address, "127.0.0.1:47777".parse().unwrap()); + + std::fs::write( + &path, + serde_json::to_vec(&json!({ + "service": "nemo-relay", + "version": env!("CARGO_PKG_VERSION"), + "bootstrap_protocol": BOOTSTRAP_PROTOCOL_VERSION + 1, + "address": "127.0.0.1:47777" + })) + .unwrap(), + ) + .unwrap(); + let error = read_sidecar_ready_file(&path).unwrap_err(); + assert!(error.contains("incompatible sidecar readiness file")); +} + +fn one_health_response(body: String) -> (SocketAddr, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request).unwrap(); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + }); + (address, server) +} + +fn unused_loopback_address() -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + address +} + +#[test] +fn direct_sidecar_start_classifies_existing_listeners_before_spawning() { + let dir = tempfile::tempdir().unwrap(); + let compatible_body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); + let (address, server) = one_health_response(compatible_body); + let bootstrap = start_sidecar_bind( + CodingAgent::Codex, + address, + dir.path(), + dir.path(), + &[], + None, + None, + ) + .unwrap(); + assert!(!bootstrap.started); + assert_eq!(bootstrap.endpoint.address, address); + server.join().unwrap(); + + let incompatible_body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"other","bootstrap_protocol":{}}}"#, + BOOTSTRAP_PROTOCOL_VERSION + ); + let (address, server) = one_health_response(incompatible_body); + let error = start_sidecar_bind( + CodingAgent::Codex, + address, + dir.path(), + dir.path(), + &[], + None, + None, + ) + .unwrap_err(); + assert!(error.contains("different version"), "{error}"); + server.join().unwrap(); + + let (address, server) = one_health_response("{}".into()); + let error = start_sidecar_bind( + CodingAgent::Codex, + address, + dir.path(), + dir.path(), + &[], + None, + None, + ) + .unwrap_err(); + assert!(error.contains("not a compatible NeMo Relay"), "{error}"); + server.join().unwrap(); +} + +#[test] +fn sidecar_record_cleanup_is_scoped_to_the_exited_pid() { + let dir = tempfile::tempdir().unwrap(); + let invalid = sidecar_owner_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47630"); + std::fs::write(&invalid, "not-json").unwrap(); + let matching = sidecar_owner_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47631"); + write_sidecar_owner( + &matching, + 42, + "http://127.0.0.1:47631", + "token", + Some("fingerprint"), + ) + .unwrap(); + let matching_pid = sidecar_pid_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47631"); + std::fs::write(&matching_pid, "42").unwrap(); + let other = sidecar_owner_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47632"); + write_sidecar_owner( + &other, + 43, + "http://127.0.0.1:47632", + "token", + Some("fingerprint"), + ) + .unwrap(); + + cleanup_sidecar_records_for_pid(dir.path(), CodingAgent::Codex, 42); + + assert!(!matching.exists()); + assert!(!matching_pid.exists()); + assert!(invalid.exists()); + assert!(other.exists()); + + let not_a_directory = dir.path().join("runtime-file"); + std::fs::write(¬_a_directory, "file").unwrap(); + cleanup_sidecar_records_for_pid(¬_a_directory, CodingAgent::Codex, 42); +} + +fn exited_command() -> Command { + #[cfg(windows)] + { + let mut command = Command::new("cmd"); + command.args(["/C", "exit 7"]); + command + } + #[cfg(not(windows))] + { + let mut command = Command::new("sh"); + command.args(["-c", "exit 7"]); + command + } +} + +#[test] +fn an_already_exited_unready_sidecar_reports_its_status() { + let dir = tempfile::tempdir().unwrap(); + let pid_path = dir.path().join("startup.pid"); + let mut child = exited_command().spawn().unwrap(); + let pid = child.id(); + assert!(!child.wait().unwrap().success()); + std::fs::write(&pid_path, pid.to_string()).unwrap(); + + let error = terminate_unready_sidecar(child, &pid_path, DEFAULT_URL).unwrap_err(); + + assert!(error.contains("exited before becoming ready"), "{error}"); + assert!(!pid_path.exists()); +} + +#[test] +fn reaper_cleanup_treats_an_unopenable_lock_as_terminal() { + let dir = tempfile::tempdir().unwrap(); + let mut child = exited_command().spawn().unwrap(); + assert!(!child.wait().unwrap().success()); + let request = SidecarReapRequest { + process: DetachedSidecarProcess::new( + child, + #[cfg(windows)] + None, + ), + exited: true, + owner_path: dir.path().join("owner.json"), + pid_path: dir.path().join("owner.pid"), + lock_path: dir.path().join("missing-parent").join("owner.lock"), + }; + + assert!(cleanup_reaped_sidecar(&request)); +} + +#[test] +fn zero_idle_timeout_is_rejected_by_timeout_and_heartbeat_resolution() { + let _lock = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let key = crate::config::PLUGIN_IDLE_TIMEOUT_ENV; + let _environment = EnvVarRestore::set(key, "0"); + + let timeout_error = plugin_idle_timeout().unwrap_err(); + let heartbeat_error = plugin_heartbeat_interval().unwrap_err(); + + assert!(timeout_error.contains("must be greater than 0")); + assert!(heartbeat_error.contains("must be greater than 0")); +} + +#[test] +fn gateway_start_reports_an_uncreatable_runtime_directory() { + let dir = tempfile::tempdir().unwrap(); + let runtime_base = dir.path().join("runtime"); + let runtime = runtime_base.join("nemo-relay-plugin"); + std::fs::create_dir_all(&runtime_base).unwrap(); + std::fs::write(&runtime, "file").unwrap(); + let _lock = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let _runtime = EnvVarRestore::set("XDG_RUNTIME_DIR", runtime_base.to_str().unwrap()); + let _config = EnvVarRestore::set("XDG_CONFIG_HOME", dir.path().to_str().unwrap()); + let bind = unused_loopback_address(); + + let error = GatewaySpec::new(CodingAgent::Codex, bind) + .ensure() + .unwrap_err(); + + assert!(error.contains("failed to create"), "{error}"); + assert!(error.contains(&runtime.display().to_string()), "{error}"); + assert!(error.contains("inspect"), "{error}"); +} + +#[test] +fn gateway_start_reports_an_uncreatable_state_directory() { + let dir = tempfile::tempdir().unwrap(); + let runtime_base = dir.path().join("runtime"); + let config_base = dir.path().join("config"); + let _lock = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let _runtime = EnvVarRestore::set("XDG_RUNTIME_DIR", runtime_base.to_str().unwrap()); + let _config = EnvVarRestore::set("XDG_CONFIG_HOME", config_base.to_str().unwrap()); + let state = sidecar_state_dir().unwrap(); + std::fs::create_dir_all(state.parent().unwrap()).unwrap(); + std::fs::write(&state, "file").unwrap(); + let bind = unused_loopback_address(); + + let error = GatewaySpec::new(CodingAgent::Codex, bind) + .ensure() + .unwrap_err(); + + assert!(error.contains("failed to create"), "{error}"); + assert!(error.contains(&state.display().to_string()), "{error}"); +} + +#[test] +fn gateway_start_reports_an_unopenable_endpoint_lock() { + let dir = tempfile::tempdir().unwrap(); + let runtime_base = dir.path().join("runtime"); + let config_base = dir.path().join("config"); + let _lock = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let _runtime = EnvVarRestore::set("XDG_RUNTIME_DIR", runtime_base.to_str().unwrap()); + let _config = EnvVarRestore::set("XDG_CONFIG_HOME", config_base.to_str().unwrap()); + let state = sidecar_state_dir().unwrap(); + let bind = unused_loopback_address(); + let url = format!("http://{bind}"); + let endpoint_lock = sidecar_lock_path(&state, &url); + std::fs::create_dir_all(&endpoint_lock).unwrap(); + + let error = GatewaySpec::new(CodingAgent::Codex, bind) + .ensure() + .unwrap_err(); + + assert!(error.contains("failed to open sidecar lock"), "{error}"); + assert!( + error.contains(&endpoint_lock.display().to_string()), + "{error}" + ); +} diff --git a/integrations/coding-agents/codex/.codex-plugin/plugin.json b/integrations/coding-agents/codex/.codex-plugin/plugin.json index ffa5c12ab..865e88009 100644 --- a/integrations/coding-agents/codex/.codex-plugin/plugin.json +++ b/integrations/coding-agents/codex/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "nemo-relay-plugin", "version": "0.6.0", - "description": "Codex hooks that forward canonical lifecycle payloads to nemo-relay.", + "description": "Native Relay gateway lifecycle and Codex hooks for complete local observability.", "author": { "name": "NVIDIA Corporation and Affiliates", "url": "https://github.com/NVIDIA/NeMo-Relay" @@ -9,6 +9,7 @@ "homepage": "https://github.com/NVIDIA/NeMo-Relay", "repository": "https://github.com/NVIDIA/NeMo-Relay", "license": "Apache-2.0", + "mcpServers": "./.mcp.json", "keywords": [ "nemo-relay", "codex", @@ -17,8 +18,8 @@ ], "interface": { "displayName": "NeMo Relay Plugin", - "shortDescription": "Forward Codex lifecycle hooks to a local NeMo Relay sidecar.", - "longDescription": "Installs command hooks that preserve Codex hook payloads and forward them to nemo-relay for agent, subagent, tool, and lifecycle observability. Full LLM capture also requires sidecar provider routing.", + "shortDescription": "Run the native Relay gateway and capture Codex lifecycle events.", + "longDescription": "Starts the native nemo-relay gateway through a required lifecycle-bound MCP server, routes model traffic through it, and installs command hooks that preserve canonical Codex lifecycle payloads.", "developerName": "NVIDIA", "category": "Coding", "capabilities": [ diff --git a/integrations/coding-agents/codex/.mcp.json b/integrations/coding-agents/codex/.mcp.json new file mode 100644 index 000000000..f3ff39455 --- /dev/null +++ b/integrations/coding-agents/codex/.mcp.json @@ -0,0 +1,62 @@ +{ + "nemo-relay": { + "command": "nemo-relay", + "args": [ + "mcp" + ], + "env": { + "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632" + }, + "env_vars": [ + "ANTHROPIC_API_KEY", + "APPDATA", + "AWS_ACCESS_KEY_ID", + "AWS_ALLOW_HTTP", + "AWS_CONFIG_FILE", + "AWS_DEFAULT_REGION", + "AWS_ENDPOINT_URL", + "AWS_PROFILE", + "AWS_REGION", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SHARED_CREDENTIALS_FILE", + "HOME", + "HTTPS_PROXY", + "HTTP_PROXY", + "LOCALAPPDATA", + "NEMO_RELAY_ANTHROPIC_BASE_URL", + "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", + "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES", + "NEMO_RELAY_OPENAI_BASE_URL", + "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", + "NEMO_RELAY_PYTHON", + "NO_PROXY", + "OPENAI_API_KEY", + "OTEL_EXPORTER_OTLP_COMPRESSION", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_TIMEOUT", + "OTEL_EXPORTER_OTLP_TRACES_COMPRESSION", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", + "OTEL_RESOURCE_ATTRIBUTES", + "OTEL_SDK_DISABLED", + "OTEL_SERVICE_NAME", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "TEMP", + "TMPDIR", + "USERPROFILE", + "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", + "http_proxy", + "https_proxy", + "no_proxy" + ], + "required": true, + "startup_timeout_sec": 20 + } +} diff --git a/integrations/coding-agents/codex/hooks/hooks.json b/integrations/coding-agents/codex/hooks/hooks.json index 43a79c2be..d4048c37a 100644 --- a/integrations/coding-agents/codex/hooks/hooks.json +++ b/integrations/coding-agents/codex/hooks/hooks.json @@ -1,5 +1,5 @@ { - "SPDX-License-Identifier": "Apache-2.0", + "description": "SPDX-License-Identifier: Apache-2.0", "hooks": { "SessionStart": [ { @@ -47,18 +47,6 @@ ] } ], - "PostToolUseFailure": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "nemo-relay plugin-shim hook codex", - "timeout": 30 - } - ] - } - ], "PermissionRequest": [ { "matcher": "*", @@ -93,17 +81,6 @@ ] } ], - "Notification": [ - { - "hooks": [ - { - "type": "command", - "command": "nemo-relay plugin-shim hook codex", - "timeout": 30 - } - ] - } - ], "Stop": [ { "hooks": [ @@ -136,17 +113,6 @@ } ] } - ], - "SessionEnd": [ - { - "hooks": [ - { - "type": "command", - "command": "nemo-relay plugin-shim hook codex", - "timeout": 30 - } - ] - } ] } } From 1371da7e30e8430c08b70643577fa366c8a7ef3d Mon Sep 17 00:00:00 2001 From: Will Killian Date: Fri, 10 Jul 2026 14:23:13 -0400 Subject: [PATCH 002/102] test(codex): add cold bootstrap end-to-end coverage Exercise ten isolated cold Codex runs plus concurrent clients against a local mock provider, verifying shared gateway startup, lifecycle delivery, environment propagation, balanced ATOF output, and clean teardown without adding Codex to mandatory Rust CI. Signed-off-by: Will Killian --- justfile | 13 +- scripts/test-codex-plugin-e2e.sh | 693 ++++++++++++++++++++ scripts/test-support/codex_mock_provider.py | 235 +++++++ 3 files changed, 940 insertions(+), 1 deletion(-) create mode 100755 scripts/test-codex-plugin-e2e.sh create mode 100644 scripts/test-support/codex_mock_provider.py diff --git a/justfile b/justfile index b7666532d..d26d35f43 100644 --- a/justfile +++ b/justfile @@ -1056,6 +1056,10 @@ clean: examples/rust-native-plugin/target \ target +# Opt-in: requires codex-cli 0.143+ and is intentionally outside test-rust/CI. +test-codex-plugin-e2e: + ./scripts/test-codex-plugin-e2e.sh + # --set [output_dir=] [ci=true|false] test-rust: #!/usr/bin/env bash @@ -1238,8 +1242,10 @@ test-python-plugin-e2e: 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" "$cli" --config "$config" --bind "127.0.0.1:$port" >"$tmp/gateway.log" 2>&1 & gateway_pid=$! + gateway_ready_timeout_seconds=30 + gateway_ready_deadline=$((SECONDS + gateway_ready_timeout_seconds)) ready=false - for _ in $(seq 1 100); do + while ((SECONDS < gateway_ready_deadline)); do if "$python_executable" -c \ 'import sys, urllib.request; urllib.request.urlopen(sys.argv[1], timeout=0.2).read()' \ "http://127.0.0.1:$port/healthz" 2>/dev/null; then @@ -1252,6 +1258,11 @@ test-python-plugin-e2e: sleep 0.1 done if [[ "$ready" != true ]]; then + if kill -0 "$gateway_pid" 2>/dev/null; then + echo "gateway remained alive but did not become ready within ${gateway_ready_timeout_seconds}s" >&2 + else + echo "gateway exited before becoming ready" >&2 + fi cat "$tmp/gateway.log" exit 1 fi diff --git a/scripts/test-codex-plugin-e2e.sh b/scripts/test-codex-plugin-e2e.sh new file mode 100755 index 000000000..bcd64b228 --- /dev/null +++ b/scripts/test-codex-plugin-e2e.sh @@ -0,0 +1,693 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! command -v codex >/dev/null 2>&1; then + echo "SKIP: codex is not installed; Codex plugin E2E requires codex-cli 0.143+" + exit 0 +fi + +codex_version="$(codex --version 2>/dev/null || true)" +if ! python3 - "$codex_version" <<'PY' +import re +import sys + +match = re.search(r"(\d+)\.(\d+)\.(\d+)", sys.argv[1]) +raise SystemExit(0 if match and tuple(map(int, match.groups())) >= (0, 143, 0) else 1) +PY +then + echo "SKIP: Codex plugin E2E requires codex-cli 0.143+; found: ${codex_version:-unknown}" + exit 0 +fi + +cargo build -p nemo-relay-cli --bin nemo-relay + +work="$(mktemp -d)" +provider_pid="" +background_pids=("") +find_sidecar_file() { + python3 - "${TMPDIR:-$work}" "${XDG_CONFIG_HOME:-$work}" "$1" <<'PY' +import sys +from pathlib import Path + +matches = [ + path + for root in sys.argv[1:3] + for path in Path(root).rglob(sys.argv[3]) + if path.is_file() +] +if matches: + print(max(matches, key=lambda path: path.stat().st_mtime_ns)) +PY +} + +cleanup() { + codex_pgids=("") + for pgid_file in "$work"/codex-*.pgid; do + [[ -f "$pgid_file" ]] || continue + pgid="$(cat "$pgid_file" 2>/dev/null || true)" + [[ "$pgid" =~ ^[0-9]+$ ]] || continue + codex_pgids+=("$pgid") + kill -TERM -- "-$pgid" 2>/dev/null || true + done + for pid in "${background_pids[@]}"; do + [[ -n "$pid" ]] || continue + pkill -TERM -P "$pid" 2>/dev/null || true + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + for pgid in "${codex_pgids[@]}"; do + [[ -n "$pgid" ]] || continue + for _ in $(seq 1 50); do + kill -0 -- "-$pgid" 2>/dev/null || break + sleep 0.02 + done + kill -KILL -- "-$pgid" 2>/dev/null || true + done + if [[ -n "$provider_pid" ]]; then + kill "$provider_pid" 2>/dev/null || true + wait "$provider_pid" 2>/dev/null || true + fi + pid_file="$(find_sidecar_file 'codex-sidecar*.pid')" + if [[ -n "$pid_file" && -f "$pid_file" ]]; then + sidecar_pid="$(cat "$pid_file" 2>/dev/null || true)" + if [[ "$sidecar_pid" =~ ^[0-9]+$ ]]; then + kill "$sidecar_pid" 2>/dev/null || true + for _ in $(seq 1 50); do + kill -0 "$sidecar_pid" 2>/dev/null || break + sleep 0.02 + done + kill -KILL "$sidecar_pid" 2>/dev/null || true + fi + fi + rm -rf "$work" +} +trap cleanup EXIT + +# Remove inherited Relay settings before defining the test-owned environment. +while IFS='=' read -r name _; do + if [[ "$name" == NEMO_RELAY_* ]]; then + unset "$name" + fi +done < <(env) +if env | grep -q '^NEMO_RELAY_'; then + echo "failed to clear ambient NEMO_RELAY_* variables" >&2 + exit 1 +fi + +# Keep a conflicting hook target set so the persistent plugin must preserve its +# explicitly installed hook endpoint instead of inheriting an ambient target. +export NEMO_RELAY_PLUGIN_GATEWAY_URL="http://127.0.0.1:1" + +export HOME="$work/home" +export CODEX_HOME="$work/codex-home" +export XDG_CONFIG_HOME="$work/xdg" +export XDG_DATA_HOME="$work/data" +export TMPDIR="$work/tmp" +export PATH="$repo_root/target/debug:$PATH" +export OPENAI_API_KEY="relay-e2e-key" +export NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS=1 +mkdir -p "$HOME" "$CODEX_HOME" "$XDG_CONFIG_HOME/nemo-relay" "$XDG_DATA_HOME" "$TMPDIR" + +provider_ready="$work/provider-ready.json" +provider_log="$work/provider-requests.jsonl" +provider_barrier="$work/provider-barrier" +python3 "$repo_root/scripts/test-support/codex_mock_provider.py" \ + --ready-file "$provider_ready" \ + --log-file "$provider_log" \ + --barrier-dir "$provider_barrier" & +provider_pid=$! + +for _ in $(seq 1 100); do + [[ -s "$provider_ready" ]] && break + sleep 0.05 +done +[[ -s "$provider_ready" ]] +provider_address="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["address"])' "$provider_ready")" + +cat >"$XDG_CONFIG_HOME/nemo-relay/config.toml" <"$XDG_CONFIG_HOME/nemo-relay/plugins.toml" <<'EOF' +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 1 + +[components.config.atof] +enabled = true +output_directory = "atof" +filename = "events.jsonl" +mode = "append" +EOF + +wait_for_relay_port_release() { + python3 - <<'PY' +import socket +import time + +deadline = time.monotonic() + 6 +while time.monotonic() < deadline: + with socket.socket() as sock: + sock.settimeout(0.2) + if sock.connect_ex(("127.0.0.1", 47632)) != 0: + raise SystemExit(0) + time.sleep(0.1) +raise SystemExit("Relay port 47632 did not become free") +PY +} + +wait_for_mcp_initialize() { + output_path="$1" + process_pid="$2" + python3 - "$output_path" "$process_pid" <<'PY' +import os +import sys +import time +from pathlib import Path + +output_path = Path(sys.argv[1]) +process_pid = int(sys.argv[2]) +deadline = time.monotonic() + 25 +while time.monotonic() < deadline: + try: + if '"serverInfo"' in output_path.read_text(encoding="utf-8", errors="replace"): + raise SystemExit(0) + except FileNotFoundError: + pass + try: + os.kill(process_pid, 0) + except ProcessLookupError: + raise SystemExit(1) + time.sleep(0.05) +raise SystemExit(1) +PY +} + +wait_for_process_exit() { + process_pid="$1" + for _ in $(seq 1 200); do + kill -0 "$process_pid" 2>/dev/null || return 0 + sleep 0.05 + done + return 1 +} + +run_mcp_once() { + stdout_path="$1" + stderr_path="$2" + request_id="$3" + python3 - "$stdout_path" "$stderr_path" "$request_id" <<'PY' +import os +import signal +import subprocess +import sys + +stdout_path, stderr_path, request_id = sys.argv[1:] +message = ( + '{"jsonrpc":"2.0","id":' + + request_id + + ',"method":"initialize","params":{"protocolVersion":"2025-06-18"}}\n' +).encode() +with open(stdout_path, "wb") as stdout, open(stderr_path, "wb") as stderr: + process = subprocess.Popen( + ["nemo-relay", "mcp"], + stdin=subprocess.PIPE, + stdout=stdout, + stderr=stderr, + start_new_session=True, + ) + try: + process.communicate(message, timeout=15) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + raise SystemExit(124) +raise SystemExit(process.returncode) +PY +} + +stop_owned_sidecar() { + owner_path="$1" + python3 - "$owner_path" <<'PY' +import http.client +import json +import sys +from urllib.parse import urlsplit + +with open(sys.argv[1], encoding="utf-8") as source: + owner = json.load(source) +url = urlsplit(owner["url"]) +connection = http.client.HTTPConnection(url.hostname, url.port, timeout=2) +connection.request( + "POST", + "/bootstrap/shutdown", + headers={"X-NeMo-Relay-Bootstrap-Token": owner["shutdown_token"]}, +) +response = connection.getresponse() +response.read() +assert response.status == 204, response.status +PY +} + +wait_for_relay_port_release +install_dir="$work/plugins" +nemo-relay install codex --install-dir "$install_dir" +nemo-relay doctor --plugin codex --install-dir "$install_dir" + +run_codex_ping() { + stdout="$work/codex-$1.stdout" + stderr="$work/codex-$1.stderr" + pgid_path="$work/codex-$1.pgid" + if ! python3 - "$stdout" "$stderr" "$pgid_path" <<'PY' +import os +from pathlib import Path +import signal +import subprocess +import sys + +stdout_path, stderr_path, pgid_path = sys.argv[1:] + + +def stop_process_group(process: subprocess.Popen[bytes]) -> None: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + return + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + + +try: + with open(stdout_path, "wb") as stdout, open(stderr_path, "wb") as stderr: + process = subprocess.Popen( + ["codex", "exec", "--skip-git-repo-check", "ping"], + stdout=stdout, + stderr=stderr, + start_new_session=True, + ) + temporary = Path(f"{pgid_path}.tmp") + temporary.write_text(str(process.pid), encoding="utf-8") + temporary.replace(pgid_path) + try: + returncode = process.wait(timeout=60) + except subprocess.TimeoutExpired: + stop_process_group(process) + raise SystemExit(124) +finally: + Path(pgid_path).unlink(missing_ok=True) +raise SystemExit(returncode) +PY + then + echo "Codex run $1 failed" >&2 + cat "$stdout" >&2 + cat "$stderr" >&2 + return 1 + fi + grep -qi "pong" "$stdout" + if ! python3 - "$stderr" <<'PY' +import re +import sys +from collections import Counter + +with open(sys.argv[1], encoding="utf-8", errors="replace") as source: + lines = source.read().splitlines() +failure = re.compile(r"\b(error|failed|failure|panic(?:ked)?|refused|unable|timed?\s*out)\b", re.I) +models_retry = re.compile( + r"connection refused|connect error|failed to (?:connect|send)|error sending request|retry", + re.I, +) +unexpected = [] +for line in lines: + if not failure.search(line): + continue + if "/models" in line.lower() and models_retry.search(line): + continue + lowered = line.lower() + if ( + "failed to warm featured plugin ids cache" in lowered + and "chatgpt.com/backend-api/plugins/featured" in lowered + ): + continue + if ( + "codex_core::shell_snapshot: failed to delete shell snapshot" in lowered + and "kind: notfound" in lowered + and "no such file or directory" in lowered + ): + continue + unexpected.append(line) +if unexpected: + print("\n".join(unexpected), file=sys.stderr) + raise SystemExit(1) + +ansi = re.compile(r"\x1b\[[0-9;]*m") +started = Counter() +completed = Counter() +for raw_line in lines: + line = ansi.sub("", raw_line).strip() + match = re.search(r"(?:^|\s)hook: (SessionStart|UserPromptSubmit|Stop)( Completed)?$", line) + if not match: + continue + target = completed if match.group(2) else started + target[match.group(1)] += 1 +expected = Counter({"SessionStart": 1, "UserPromptSubmit": 1, "Stop": 1}) +if started != expected or completed != expected: + print( + f"unexpected Codex hook counts: started={started}, completed={completed}", + file=sys.stderr, + ) + raise SystemExit(1) +PY + then + echo "Codex run $1 reported an unexpected error" >&2 + cat "$stderr" >&2 + return 1 + fi +} + +# Exercise incompatible configuration handling before collecting acceptance events. +export NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS=300 +holder_fifo="$work/mcp-holder.stdin" +holder_stdout="$work/mcp-holder.stdout" +holder_stderr="$work/mcp-holder.stderr" +mkfifo "$holder_fifo" +exec 9<>"$holder_fifo" +nemo-relay mcp 9>&- <"$holder_fifo" >"$holder_stdout" 2>"$holder_stderr" & +holder_pid=$! +background_pids+=("$holder_pid") +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' >&9 +if ! wait_for_mcp_initialize "$holder_stdout" "$holder_pid"; then + cat "$holder_stderr" >&2 + exit 1 +fi +old_sidecar_pid_file="$(find_sidecar_file 'codex-sidecar*.pid')" +[[ -s "$old_sidecar_pid_file" ]] +old_sidecar_pid="$(cat "$old_sidecar_pid_file")" +kill -0 "$old_sidecar_pid" +exec 9>&- +if ! wait_for_process_exit "$holder_pid"; then + echo "original MCP client did not exit after its stdin closed" >&2 + exit 1 +fi +wait "$holder_pid" +background_pids=("") +kill -0 "$old_sidecar_pid" + +export OPENAI_API_KEY="relay-e2e-key-rotated" +mismatch_stdout="$work/mcp-mismatch.stdout" +mismatch_stderr="$work/mcp-mismatch.stderr" +if run_mcp_once "$mismatch_stdout" "$mismatch_stderr" 2; then + echo "MCP unexpectedly reused a sidecar with an incompatible credential fingerprint" >&2 + exit 1 +fi +grep -qi "different version or persistent configuration" "$mismatch_stderr" + +nemo-relay install codex --force --install-dir "$install_dir" +for _ in $(seq 1 100); do + kill -0 "$old_sidecar_pid" 2>/dev/null || break + sleep 0.05 +done +if kill -0 "$old_sidecar_pid" 2>/dev/null; then + echo "forced Codex reinstall did not retire the owned sidecar" >&2 + exit 1 +fi +wait_for_relay_port_release + +replacement_stdout="$work/mcp-replacement.stdout" +replacement_stderr="$work/mcp-replacement.stderr" +run_mcp_once "$replacement_stdout" "$replacement_stderr" 3 +grep -q '"serverInfo"' "$replacement_stdout" +replacement_pid_file="$(find_sidecar_file 'codex-sidecar*.pid')" +replacement_owner_file="$(find_sidecar_file 'codex-sidecar*.owner.json')" +[[ -s "$replacement_pid_file" && -s "$replacement_owner_file" ]] +replacement_pid="$(cat "$replacement_pid_file")" +[[ "$replacement_pid" != "$old_sidecar_pid" ]] +kill -0 "$replacement_pid" +stop_owned_sidecar "$replacement_owner_file" +wait_for_relay_port_release +rm -f "$replacement_owner_file" "$replacement_pid_file" + +# The acceptance counts below cover only real Codex runs, not bootstrap probes. +: >"$provider_log" +events="$XDG_CONFIG_HOME/nemo-relay/atof/events.jsonl" +rm -f "$events" +export NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS=1 + +for iteration in $(seq 1 10); do + run_codex_ping "cold-$iteration" + wait_for_relay_port_release +done + +export NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS=300 +touch "$provider_barrier/enabled" +run_codex_ping concurrent-1 & +first_pid=$! +background_pids+=("$first_pid") +run_codex_ping concurrent-2 & +second_pid=$! +background_pids+=("$second_pid") + +python3 - "$provider_barrier/arrivals" <<'PY' +import sys +import time +from pathlib import Path + +arrivals_path = Path(sys.argv[1]) +deadline = time.monotonic() + 25 +while time.monotonic() < deadline: + try: + arrivals = int(arrivals_path.read_text(encoding="utf-8")) + except (FileNotFoundError, ValueError): + arrivals = 0 + if arrivals >= 2: + raise SystemExit(0) + time.sleep(0.05) +raise SystemExit("concurrent Codex requests did not reach the provider within 25 seconds") +PY +[[ "$(cat "$provider_barrier/arrivals")" -eq 2 ]] +sidecar_pid_file="$(find_sidecar_file 'codex-sidecar*.pid')" +[[ -s "$sidecar_pid_file" ]] +shared_sidecar_pid="$(cat "$sidecar_pid_file")" +[[ "$shared_sidecar_pid" =~ ^[0-9]+$ ]] +kill -0 "$shared_sidecar_pid" +touch "$provider_barrier/release" +wait "$first_pid" +wait "$second_pid" +background_pids=("") +[[ "$(cat "$sidecar_pid_file")" == "$shared_sidecar_pid" ]] +kill -0 "$shared_sidecar_pid" +python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.settimeout(0.2) + assert sock.connect_ex(("127.0.0.1", 47632)) == 0, "shared Relay gateway stopped early" +PY + +owner_file="$(find_sidecar_file 'codex-sidecar*.owner.json')" +stop_owned_sidecar "$owner_file" +if ! wait_for_process_exit "$shared_sidecar_pid"; then + echo "shared Relay gateway did not exit after the shutdown handshake" >&2 + exit 1 +fi +wait_for_relay_port_release +rm -f "$owner_file" "$sidecar_pid_file" + +python3 - "$provider_log" "$events" <<'PY' +import collections +import json +import sys + +request_path, event_path = sys.argv[1:] +with open(request_path, encoding="utf-8") as source: + requests = [json.loads(line) for line in source if line.strip()] +response_requests = [ + item for item in requests + if item.get("method") == "POST" and item.get("path", "").endswith("/responses") +] +model_requests = [ + item for item in requests + if item.get("method") == "GET" and item.get("path", "").endswith("/models") +] +assert len(response_requests) == 12, ( + f"expected one provider response per Codex run, got {len(response_requests)}; " + f"all provider requests: {requests}" +) +assert all(item["authorization"] == "Bearer relay-e2e-key-rotated" for item in response_requests), response_requests +provider_response_ids = {item.get("response_id") for item in response_requests} +assert None not in provider_response_ids, response_requests +assert len(provider_response_ids) == 12, ( + f"expected 12 unique provider response IDs, got {provider_response_ids}" +) +assert len(response_requests) + len(model_requests) == len(requests), requests + +with open(event_path, encoding="utf-8") as source: + events = [json.loads(line) for line in source if line.strip()] +assert events and all(event.get("atof_version") == "0.1" for event in events) + +scope_counts = collections.defaultdict(collections.Counter) +for event in events: + if event.get("kind") == "scope": + scope_counts[event["uuid"]][event["scope_category"]] += 1 +for scope_id, counts in scope_counts.items(): + assert counts == {"start": 1, "end": 1}, f"unbalanced or duplicate scope {scope_id}: {counts}" + +turn_starts = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("scope_category") == "start" + and event.get("category") == "custom" + and event.get("name") == "codex-turn" +] +turn_ends = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("scope_category") == "end" + and event.get("category") == "custom" + and event.get("name") == "codex-turn" +] +session_ids = [event.get("metadata", {}).get("session_id") for event in turn_starts] +session_counts = collections.Counter(session_ids) +summary = sorted( + { + ( + event.get("kind"), + event.get("scope_category"), + event.get("category"), + event.get("name"), + ) + for event in events + } +) +assert len(turn_starts) == 12, ( + f"expected exactly one Codex turn start per run, got {len(turn_starts)}; " + f"sessions: {session_counts}; event shapes: {summary}" +) +assert None not in session_ids +assert len(set(session_ids)) == 12, f"Codex sessions were not isolated: {session_ids}" +assert len(turn_ends) == 12, f"expected exactly one Codex turn end per run, got {len(turn_ends)}" +assert {event["uuid"] for event in turn_starts} == {event["uuid"] for event in turn_ends} +assert all( + event.get("data", {}).get("hook_event_name", "").lower() == "userpromptsubmit" + for event in turn_starts +), turn_starts +assert all( + event.get("metadata", {}).get("hook_event_name", "").lower() == "userpromptsubmit" + for event in turn_starts +), turn_starts +# Stop closes the turn, but the semantic output remains the final provider response. +assert all( + event.get("metadata", {}).get("hook_event_name", "").lower() == "stop" + for event in turn_ends +), turn_ends +assert all("pong" in json.dumps(event.get("data")) for event in turn_ends), turn_ends +turn_start_sessions = { + event["uuid"]: event.get("metadata", {}).get("session_id") for event in turn_starts +} +assert all( + event.get("metadata", {}).get("session_id") == turn_start_sessions[event["uuid"]] + for event in turn_ends +), turn_ends + +llm_starts = [ + event for event in events + if event.get("category") == "llm" and event.get("scope_category") == "start" +] +llm_ends = [ + event for event in events + if event.get("category") == "llm" and event.get("scope_category") == "end" +] +assert len(llm_starts) == 12, f"expected 12 LLM starts, got {len(llm_starts)}" +assert len(llm_ends) == 12, f"expected 12 LLM ends, got {len(llm_ends)}" +assert all("pong" in json.dumps(event) for event in llm_ends), llm_ends +llm_start_by_uuid = {event["uuid"]: event for event in llm_starts} +llm_end_by_uuid = {event["uuid"]: event for event in llm_ends} +assert len(llm_start_by_uuid) == 12, f"duplicate LLM starts: {llm_starts}" +assert llm_start_by_uuid.keys() == llm_end_by_uuid.keys(), ( + f"unmatched LLM scopes: starts={llm_start_by_uuid.keys()}, ends={llm_end_by_uuid.keys()}" +) +llm_starts_by_turn = collections.defaultdict(list) +llm_ends_by_turn = collections.defaultdict(list) +for event in llm_starts: + llm_starts_by_turn[event.get("parent_uuid")].append(event) +for event in llm_ends: + llm_ends_by_turn[event.get("parent_uuid")].append(event) +turn_ids = {event["uuid"] for event in turn_starts} +assert set(llm_starts_by_turn) == turn_ids, ( + f"LLM starts were not attached to every turn: {llm_starts_by_turn}" +) +assert set(llm_ends_by_turn) == turn_ids, ( + f"LLM ends were not attached to every turn: {llm_ends_by_turn}" +) +assert all(len(children) == 1 for children in llm_starts_by_turn.values()), llm_starts_by_turn +assert all(len(children) == 1 for children in llm_ends_by_turn.values()), llm_ends_by_turn + + +def strings(value): + if isinstance(value, dict): + for child in value.values(): + yield from strings(child) + elif isinstance(value, list): + for child in value: + yield from strings(child) + elif isinstance(value, str): + yield value + + +def response_id(event): + matches = provider_response_ids.intersection(strings(event)) + assert len(matches) == 1, ( + f"expected exactly one provider response ID in event, got {matches}: {event}" + ) + return next(iter(matches)) + + +turn_end_by_uuid = {event["uuid"]: event for event in turn_ends} +captured_response_ids = set() +for turn_id in turn_ids: + llm_start = llm_starts_by_turn[turn_id][0] + llm_end = llm_ends_by_turn[turn_id][0] + assert llm_start["uuid"] == llm_end["uuid"] + captured_id = response_id(llm_end) + assert response_id(turn_end_by_uuid[turn_id]) == captured_id + captured_response_ids.add(captured_id) +assert captured_response_ids == provider_response_ids, ( + f"captured/provider response IDs differ: captured={captured_response_ids}, " + f"provider={provider_response_ids}" +) +print( + f"validated 12 captured turns and 12 provider responses; " + f"{len(model_requests)} /models requests reached Relay" +) +PY + +nemo-relay uninstall codex --install-dir "$install_dir" +echo "Codex plugin E2E passed: 10 cold and 2 concurrent runs each invoked and completed SessionStart, UserPromptSubmit, and Stop exactly once; every Stop closed one ATOF turn with the matching provider response; pre-MCP /models retries, featured-plugin cache warnings, and concurrent shell-snapshot cleanup races were ignored" diff --git a/scripts/test-support/codex_mock_provider.py b/scripts/test-support/codex_mock_provider.py new file mode 100644 index 000000000..3d530bd95 --- /dev/null +++ b/scripts/test-support/codex_mock_provider.py @@ -0,0 +1,235 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Local OpenAI Responses API fixture for the opt-in Codex plugin E2E test.""" + +from __future__ import annotations + +import argparse +import json +import threading +import time +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + + +def response_events(request: dict[str, Any]) -> list[dict[str, Any]]: + response_id = f"resp_{uuid.uuid4().hex}" + item_id = f"msg_{uuid.uuid4().hex}" + model = request.get("model", "gpt-5-codex") + created_at = int(time.time()) + item = { + "id": item_id, + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "pong", + "annotations": [], + "logprobs": [], + } + ], + } + response = { + "id": response_id, + "object": "response", + "created_at": created_at, + "completed_at": created_at, + "status": "completed", + "background": False, + "error": None, + "incomplete_details": None, + "instructions": None, + "max_output_tokens": None, + "max_tool_calls": None, + "model": model, + "output": [item], + "parallel_tool_calls": True, + "previous_response_id": None, + "prompt_cache_key": None, + "reasoning": {"effort": "medium", "summary": None}, + "safety_identifier": None, + "service_tier": "default", + "store": False, + "temperature": None, + "text": {"format": {"type": "text"}, "verbosity": "medium"}, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": None, + "truncation": "disabled", + "usage": { + "input_tokens": 1, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 1, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 2, + }, + "user": None, + "metadata": {}, + } + in_progress = {**response, "completed_at": None, "status": "in_progress", "output": []} + return [ + {"type": "response.created", "response": in_progress}, + { + "type": "response.output_item.added", + "response_id": response_id, + "output_index": 0, + "item": {**item, "status": "in_progress", "content": []}, + }, + { + "type": "response.content_part.added", + "response_id": response_id, + "item_id": item_id, + "output_index": 0, + "content_index": 0, + "part": {"type": "output_text", "text": "", "annotations": [], "logprobs": []}, + }, + { + "type": "response.output_text.delta", + "response_id": response_id, + "item_id": item_id, + "output_index": 0, + "content_index": 0, + "delta": "pong", + "logprobs": [], + }, + { + "type": "response.output_text.done", + "response_id": response_id, + "item_id": item_id, + "output_index": 0, + "content_index": 0, + "text": "pong", + "logprobs": [], + }, + { + "type": "response.content_part.done", + "response_id": response_id, + "item_id": item_id, + "output_index": 0, + "content_index": 0, + "part": item["content"][0], + }, + { + "type": "response.output_item.done", + "response_id": response_id, + "output_index": 0, + "item": item, + }, + {"type": "response.completed", "response": response}, + ] + + +class Provider(ThreadingHTTPServer): + def __init__(self, address: tuple[str, int], log_path: Path, barrier_dir: Path) -> None: + super().__init__(address, Handler) + self.log_path = log_path + self.log_lock = threading.Lock() + self.barrier_dir = barrier_dir + self.barrier_lock = threading.Lock() + + def log_request_record(self, record: dict[str, Any]) -> None: + with self.log_lock, self.log_path.open("a", encoding="utf-8") as output: + output.write(json.dumps(record, sort_keys=True) + "\n") + + def wait_at_barrier_if_enabled(self) -> None: + if not (self.barrier_dir / "enabled").exists(): + return + with self.barrier_lock: + arrivals = self.barrier_dir / "arrivals" + count = int(arrivals.read_text(encoding="utf-8") or "0") if arrivals.exists() else 0 + temporary = arrivals.with_suffix(".tmp") + temporary.write_text(str(count + 1), encoding="utf-8") + temporary.replace(arrivals) + deadline = time.monotonic() + 30 + release = self.barrier_dir / "release" + while not release.exists(): + if time.monotonic() >= deadline: + raise TimeoutError("concurrent Codex provider barrier timed out") + time.sleep(0.02) + + +class Handler(BaseHTTPRequestHandler): + server: Provider + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + del format, args + + def do_GET(self) -> None: # noqa: N802 + self.server.log_request_record( + { + "method": "GET", + "path": self.path, + "authorization": self.headers.get("authorization"), + } + ) + if not self.path.endswith("/models"): + self.send_error(404) + return + body = json.dumps( + { + "object": "list", + "data": [{"id": "gpt-5-codex", "object": "model", "owned_by": "openai"}], + } + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self) -> None: # noqa: N802 + length = int(self.headers.get("content-length", "0")) + raw = self.rfile.read(length) + request = json.loads(raw or b"{}") + events = response_events(request) if self.path.endswith("/responses") else None + self.server.log_request_record( + { + "method": "POST", + "path": self.path, + "authorization": self.headers.get("authorization"), + "model": request.get("model"), + "response_id": events[-1]["response"]["id"] if events else None, + } + ) + if events is None: + self.send_error(404) + return + self.server.wait_at_barrier_if_enabled() + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "close") + self.end_headers() + for event in events: + self.wfile.write(f"data: {json.dumps(event)}\n\n".encode()) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--ready-file", type=Path, required=True) + parser.add_argument("--log-file", type=Path, required=True) + parser.add_argument("--barrier-dir", type=Path, required=True) + args = parser.parse_args() + args.log_file.parent.mkdir(parents=True, exist_ok=True) + args.log_file.write_text("", encoding="utf-8") + args.barrier_dir.mkdir(parents=True, exist_ok=True) + server = Provider(("127.0.0.1", 0), args.log_file, args.barrier_dir) + temporary = args.ready_file.with_suffix(".tmp") + temporary.write_text( + json.dumps({"address": f"127.0.0.1:{server.server_port}"}), + encoding="utf-8", + ) + temporary.replace(args.ready_file) + server.serve_forever() + + +if __name__ == "__main__": + main() From 97cd427d20a78c908319ab5ae687f11e54f65d0e Mon Sep 17 00:00:00 2001 From: Will Killian Date: Fri, 10 Jul 2026 14:23:32 -0400 Subject: [PATCH 003/102] docs(codex): explain shared gateway bootstrap Describe deferred MCP startup, plugin-owned hooks, fixed-port sidecar sharing, persistent user configuration, environment forwarding, idle shutdown, concurrency, diagnostics, and the pre-MCP models limitation across CLI and integration guidance. Signed-off-by: Will Killian --- README.md | 13 +- .../release-notes/known-issues.mdx | 18 +- .../grpc-worker/python/about.mdx | 15 ++ docs/nemo-relay-cli/about.mdx | 2 +- docs/nemo-relay-cli/basic-usage.mdx | 6 +- docs/nemo-relay-cli/codex.mdx | 185 ++++++++++++---- docs/nemo-relay-cli/plugin-installation.mdx | 98 ++++++--- integrations/coding-agents/README.md | 43 ++-- integrations/coding-agents/codex/README.md | 203 +++++++++++++----- 9 files changed, 451 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index ecbca300a..c7d9234c6 100644 --- a/README.md +++ b/README.md @@ -120,10 +120,15 @@ and provider settings for that launched process, then shuts the gateway down when the agent exits. > [!WARNING] -> If generated hooks are inactive, Codex users must review and activate them -> before events appear. The Codex Desktop App has additional limitations. +> `nemo-relay install codex` automatically trusts only the exact hooks owned by +> `nemo-relay-plugin@nemo-relay-local`. It does not trust unrelated user, +> project, or plugin hooks. Manual or source-marketplace installs can still +> require review. Restart an already running Codex app after persistent +> installation. On Windows, a restrictive host Job Object can keep the shared +> Relay gateway scoped to the host process lifetime. +> The Codex Desktop App has additional limitations. > Refer to the [Codex CLI guide](https://docs.nvidia.com/nemo/relay/nemo-relay-cli/codex) for the -> current hook activation caveat and troubleshooting steps. +> current lifecycle, startup, and troubleshooting details. #### 4. Verify the Run @@ -287,7 +292,7 @@ coverage. | Agent | Observability | Security | Optimization | Notes | |:--|:--:|:--:|:--:|:--| | Claude Code | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed LLM observability are supported. | -| Codex | Yes | Yes | Partial | Hook activation is required; missing session-end behavior limits trajectory finalization and full optimization coverage. | +| Codex | Yes | Yes | Partial | Persistent install verifies the exact plugin hooks. Each `Stop` finalizes a turn snapshot; `SessionEnd` availability remains Codex-version-dependent. | | Hermes Agent | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed or hook-backed LLM observability are supported. | ### Public API Integrations diff --git a/docs/about-nemo-relay/release-notes/known-issues.mdx b/docs/about-nemo-relay/release-notes/known-issues.mdx index 7d1b64a4d..d04d6dc5f 100644 --- a/docs/about-nemo-relay/release-notes/known-issues.mdx +++ b/docs/about-nemo-relay/release-notes/known-issues.mdx @@ -9,6 +9,22 @@ SPDX-License-Identifier: Apache-2.0 */} This page lists current limitations and support notes for the release documentation set. +## NVIDIA NeMo Relay 0.6 + +- Codex can perform provider discovery and request `/models` before launching + required plugin MCP servers. That cold-start request can fail and be retried; + the captured turn and `/responses` traffic wait for verified Relay gateway + readiness. +- On Windows, a restrictive host Job Object can prevent the Codex plugin's + Relay sidecar from breaking away. When nested job assignment is supported, + the sidecar remains scoped to the host job and the shared gateway might not + remain available for the full 300-second idle reuse window. If Relay cannot + create or configure its cleanup job, bootstrap fails instead of starting + without descendant-cleanup guarantees. +- The Codex 0.143 plugin hook schema does not expose `SessionEnd`. Relay writes a + cumulative trajectory snapshot for every delivered `Stop`, so the final turn + snapshot is also the final session snapshot. + ## NVIDIA NeMo Relay 0.5 These notes apply to the NVIDIA NeMo Relay 0.5 release. @@ -19,8 +35,6 @@ These notes apply to the NVIDIA NeMo Relay 0.5 release. - The NeMo Relay CLI is experimental. Coding-agent observability support varies with host plugin, hook, and provider-routing capabilities. Hooks alone cannot produce complete LLM request and response spans. -- Complete first-request capture in Codex plugin mode depends on Codex firing - an installed hook before the first provider request. - Node.js 24 or newer is required for Node.js binding and package workflows. - OpenClaw support uses public hook-backed telemetry with partial security and optimization support. Security is limited to pre-tool conditional guardrails, diff --git a/docs/build-plugins/dynamic-plugins/grpc-worker/python/about.mdx b/docs/build-plugins/dynamic-plugins/grpc-worker/python/about.mdx index 6317330a5..1c6db7772 100644 --- a/docs/build-plugins/dynamic-plugins/grpc-worker/python/about.mdx +++ b/docs/build-plugins/dynamic-plugins/grpc-worker/python/about.mdx @@ -211,6 +211,14 @@ print(f"sha256:{sha256(artifact.read_bytes()).hexdigest()}") PY ``` +Relay requires the module portion of `load.entrypoint` to resolve directly +under `source.manifest_root` to exactly one Python source file: either +`module/path.py` or `module/path/__init__.py`. That canonical file must be the +integrity-checked `source.artifact`. Relay rejects ambiguous modules and custom +build-backend mappings, including `src/` layouts that cannot be derived from the +manifest. This prevents an unsigned sibling module from becoming the executed +entrypoint. + ## Register the Plugin Run the following commands from the project directory: @@ -227,6 +235,13 @@ the `source.manifest_root` project into it. Relay uses that recorded environment when it starts the worker. Do not start the worker directly: Relay supplies its worker socket, host socket, activation ID, and activation token. +Relay records a digest of the installed environment in a locally authenticated +marker bound to the integrity-checked entrypoint artifact. The marker detects +changes between provisioning and activation, but it is not a security boundary +against a process running as the same operating-system user, which can access +the same user-owned keys and files. Worker process isolation is not a security +sandbox. + ## Configure the Plugin After `plugins add` registers the worker, edit diff --git a/docs/nemo-relay-cli/about.mdx b/docs/nemo-relay-cli/about.mdx index 32211aba7..5412d3e15 100644 --- a/docs/nemo-relay-cli/about.mdx +++ b/docs/nemo-relay-cli/about.mdx @@ -49,7 +49,7 @@ controls. | Agent | Observability | Security | Optimization | Notes | | --- | --- | --- | --- | --- | | Claude Code | Yes | Yes | Partial | Pre-tool hook responses are supported. LLM optimization uses gateway-routed traffic; full coverage depends on loaded Claude Code hooks. | -| Codex | Yes | Yes | Partial | Hook forwarding and gateway-routed LLM optimization are supported after hooks are reviewed and activated. The missing session-end hook limits full coverage. | +| Codex | Yes | Yes | Partial | Persistent install verifies all 10 hooks in the supported schema. Each `Stop` finalizes a turn snapshot because the plugin schema does not expose `SessionEnd`. | | Hermes Agent | Yes | Yes | Partial | Hook forwarding, pre-tool guardrails, and Hermes API-request telemetry are supported. Optimization depends on Hermes shell and API-request hook coverage. | ## Guides diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index 674ebed8b..4686a2c84 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -113,7 +113,7 @@ nemo-relay uninstall codex ``` Refer to [Plugin Installation](/nemo-relay-cli/plugin-installation) for install -directories, host-specific behavior, and Codex lazy sidecar constraints. +directories, host-specific behavior, and Codex shared-sidecar lifecycle. ## Shared Configuration @@ -306,8 +306,8 @@ Generated hook bundles subscribe to the events needed for that mapping: | Agent | LLM lifecycle and correlation hooks | Scope, tool, and mark hooks | | --- | --- | --- | -| Claude Code | `UserPromptSubmit`, `AfterAgentResponse`, `AfterAgentThought`, `Stop` | `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `Notification`, `PreCompact` | -| Codex | `UserPromptSubmit`, `AfterAgentResponse`, `AfterAgentThought`, `Stop` | `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `Notification`, `PreCompact` | +| Claude Code | `UserPromptSubmit`, `Stop` | `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `Notification`, `PreCompact`, `PostCompact` | +| Codex | `UserPromptSubmit`, `Stop` | `SessionStart`, `SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `PreCompact`, `PostCompact` | | Hermes | `pre_api_request`, `post_api_request`, `api_request_error`, `pre_llm_call`, `post_llm_call` | `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`, `subagent_start`, `subagent_stop`, `pre_tool_call`, `post_tool_call` | Hermes `pre_api_request`, `post_api_request`, and `api_request_error` hooks diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index 2446e1ff4..b7240f7f6 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -14,17 +14,21 @@ local gateway cannot observe provider traffic that never reaches the machine. ## Requirements -`codex-cli >= 0.129.0`. The gateway uses the `features.hooks` flag and the -`nemo-relay-openai` provider alias, both of which require this version. Earlier -versions either reject the provider override or do not recognize the hooks -feature flag. +`codex-cli >= 0.143.0`. Persistent installation requires exactly one discovered, +enabled, and trusted handler for all 10 events in the supported Codex hook schema: +`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, +`PermissionRequest`, `SubagentStart`, `SubagentStop`, `Stop`, `PreCompact`, and +`PostCompact`. Persistent setup also requires the +`features.hooks` flag, plugin app-server metadata, and the `nemo-relay-openai` +provider alias. The installer checks the Codex version before it changes any +files. -As of Codex 0.129, Codex requires hooks to be manually reviewed and activated -before they run. Generated NeMo Relay hook configuration is not enough on its own -if Codex leaves those hooks inactive. Review and activate the installed or -injected hooks in Codex before expecting NeMo Relay events. This is being tracked -upstream as [openai/codex#21639](https://github.com/openai/codex/issues/21639). +Codex requires hooks to be trusted before they run. `nemo-relay install codex` +uses the Codex app-server API to trust and verify only the exact hooks owned by +`nemo-relay-plugin@nemo-relay-local`. Source-marketplace or manually installed +hooks can still require review. This behavior is tracked upstream as +[openai/codex#21639](https://github.com/openai/codex/issues/21639). @@ -84,9 +88,74 @@ The installer creates a local marketplace, installs existing `nemo-relay` binary on `PATH` but does not install a plugin-local Relay binary. -Codex plugin mode uses hook-supervised on-demand startup only. It does not install a -wrapper, user-level daemon, launch agent, system user service, scheduled -task, login item, or persistent supervisor. +The required plugin MCP process is a lightweight lifecycle client. It starts or +reuses a detached Rust `nemo-relay --bind 127.0.0.1:47632` sidecar and completes +MCP initialization only after `/healthz` confirms the Relay identity, version, +bootstrap protocol, and effective persistent configuration. The compatibility +check uses a one-way fingerprint that includes relevant environment values; it +does not return credentials or configuration secrets. Dynamic plugin manifests, +artifacts, and signatures included in this identity must be regular files. The +complete activation snapshot, including adjacent runtime files and a copied +managed Python environment, is limited to 100,000 filesystem entries and 512 +MiB in total, with a maximum directory traversal depth of 128. When startup +reports an activation snapshot budget error, remove unrelated files from the +manifest or load-target directory, flatten deeply nested directories, or reduce +the managed Python environment before retrying. Concurrent Codex processes +share the sidecar. Each MCP client sends a heartbeat while its stdio connection +is open, and the sidecar exits after 300 seconds without activity by default. No +wrapper, launch agent, system user service, scheduled task, login item, or +persistent supervisor is installed. + +On Windows, Relay requests Job Object breakaway only when the host job permits +it. If the host uses a restrictive Job Object but permits nested jobs, Relay +keeps the sidecar scoped to that host job and retains a nested cleanup job for +the sidecar process tree. The sidecar cannot outlive the host job, so the usual +300-second idle reuse window can end early. If Relay cannot create or configure +the cleanup job, or the host rejects nested job assignment, persistent bootstrap +fails with an actionable process-tree cleanup error instead of starting without +that guarantee. + +Persistent plugin mode loads system and user Relay configuration only. It does +not load a project's `.nemo-relay` files. The sidecar starts in the user Relay +configuration directory, so relative exporter paths are deterministic. Use a +transparent `nemo-relay run` when project-specific configuration is required. + +The generated MCP entry forwards variable names, never values, for provider +credentials, Relay runtime settings, common OpenTelemetry, AWS, proxy, and +certificate settings. It also forwards approved `NEMO_RELAY_`, `OTEL_`, and +`AWS_` names that exist during installation and credential variable names +referenced by user observability configuration. Rerun +`nemo-relay install codex --force` when doctor reports a missing forwarded +variable. + +Plugin-owned hook commands pin `http://127.0.0.1:47632` explicitly, so an +ambient `NEMO_RELAY_PLUGIN_GATEWAY_URL` cannot split hook traffic from the +required MCP-managed gateway. + +If user configuration, forwarded credentials, or the Relay version changes +while an old sidecar is still running, the new MCP client reports an actionable +compatibility conflict instead of silently reusing the wrong process. Running +`nemo-relay install codex --force` retires a sidecar owned by the prior install +through Relay's private shutdown handshake before it refreshes the plugin. +Running ordinary install again against an existing fenced Codex installation +does not replace files; use `--force` for the fenced replacement transaction. + +An existing install without MCP generation fencing cannot be retired safely, +even when Codex reports that the plugin is no longer registered, because an +already-running MCP process can outlive registration. If upgrade or uninstall +reports a missing MCP generation marker, close all Codex clients and standalone +`nemo-relay mcp` processes, then enter: + +```bash +codex plugin remove nemo-relay-plugin@nemo-relay-local +codex plugin marketplace remove nemo-relay-local +``` + +Remove `codex-marketplace` and `codex.json` from the install directory named in +the error, then retry `nemo-relay install codex --force` to install a fenced +generation. If removal was the original goal, run `nemo-relay uninstall codex` +immediately afterward; the fenced reinstall lets Relay remove provider and hook +trust state transactionally. To check for the installed plugin, enter: @@ -101,13 +170,12 @@ nemo-relay uninstall codex ``` Refer to [Plugin Installation](/nemo-relay-cli/plugin-installation) for install -directories, first-request capture limits, rollback behavior, and source +directories, shared-sidecar behavior, rollback behavior, and source marketplace notes. -## Shared Config +## Configure Transparent Runs -Create `.nemo-relay/config.toml` for project defaults or -`~/.config/nemo-relay/config.toml` for user defaults: +Create `.nemo-relay/config.toml` for project defaults: ```toml [upstream] @@ -117,7 +185,7 @@ openai_base_url = "https://api.openai.com/v1" command = "codex" ``` -Then configure observability with `nemo-relay plugins edit --project` or +Configure project observability with `nemo-relay plugins edit --project` or `.nemo-relay/plugins.toml`: ```toml @@ -132,8 +200,35 @@ enabled = true output_directory = ".nemo-relay/atif" ``` -Run `nemo-relay run --agent codex` to use the configured command and plugin -config. User config takes priority over project and system config. +Run `nemo-relay run --agent codex` to use project-specific configuration. The +ATIF files from this example are written under the project at +`.nemo-relay/atif`. + +## Configure the Persistent Plugin + +Create `~/.config/nemo-relay/config.toml`, or +`$XDG_CONFIG_HOME/nemo-relay/config.toml` when `XDG_CONFIG_HOME` is set, for +persistent provider defaults. Use `nemo-relay plugins edit` without +`--project` to write user-scoped observability configuration. For example, set +the ATIF output directory to `atif`: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = true +output_directory = "atif" +``` + +The persistent sidecar deliberately ignores project layers, merges only system +and user configuration, and starts in the user Relay configuration directory. +The relative output directory in this example therefore resolves to +`$XDG_CONFIG_HOME/nemo-relay/atif`, or `~/.config/nemo-relay/atif` when +`XDG_CONFIG_HOME` is not set. ## Standalone Gateway @@ -179,17 +274,21 @@ traffic to pass through the gateway. ## Captured Events -Generated Codex hooks include `SessionStart`, `SessionEnd`, `SubagentStart`, -`SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, -`Notification`, and `PreCompact` for scope, tool, and mark events. -`UserPromptSubmit`, `AfterAgentResponse`, `AfterAgentThought`, and `Stop` are -retained as private LLM correlation hints and are not emitted as standalone -NeMo Relay events. +The required generated set includes `SessionStart`, `UserPromptSubmit`, +`PreToolUse`, `PostToolUse`, `PermissionRequest`, `SubagentStart`, +`SubagentStop`, `Stop`, `PreCompact`, and `PostCompact`. Relay requires the Codex +app-server to discover exactly one enabled, trusted handler for every generated +event. `PostToolUseFailure`, `Notification`, and `SessionEnd` are not in the +Codex 0.143 plugin hook schema, so Relay does not generate undiscoverable handlers +for them. Relay maps delivered events to agent, turn, subagent, tool, and mark +lifecycle events; prompt and stop payloads also provide LLM correlation context. The transparent wrapper passes hook entries as Codex CLI config overrides and sets `features.hooks=true` for that launched process. Persistent install writes -`.codex/config.toml` with `features.hooks = true` and merges generated hook -entries into `.codex/hooks.json`. +`.codex/config.toml` with `features.hooks = true`, but the installed plugin's +`hooks/hooks.json` is the sole persistent Relay hook source. During upgrades, +the installer removes legacy Relay groups from `.codex/hooks.json` while +preserving unrelated hooks and backups. ## Smoke Test @@ -208,20 +307,27 @@ an empty JSON object. ## Verify Export End the Codex session and confirm Agent Trajectory Interchange Format (ATIF) -exists: +exists at the path for the selected workflow. + +For a transparent project run, enter: ```bash ls .nemo-relay/atif ``` -The gateway writes `.atif.json` after every conversation turn for -Codex sessions (Codex's hook surface has no `SessionEnd`-equivalent event, so -the gateway uses each per-turn `Stop` hook to snapshot the trajectory; the file -grows cumulatively across turns and the final write reflects the full session). -For agents that do emit a session-end hook, the same file is written once on -session close. If the file is missing, confirm `features.hooks = true`, hook -config loading, and that `plugins.toml` enables the ATIF exporter with a -writable `output_directory`. +For a persistent plugin configured with the relative user-scoped path above, +enter: + +```bash +ls "${XDG_CONFIG_HOME:-$HOME/.config}/nemo-relay/atif" +``` + +The Codex plugin schema does not expose `SessionEnd`, so the gateway uses each +per-turn `Stop` hook to write `.atif.json`. The file grows cumulatively +across turns, so the final `Stop` write reflects the full session. If the file is +missing, confirm `features.hooks = true`, hook config +loading, and that `plugins.toml` enables the ATIF exporter with a writable +`output_directory`. ## Troubleshoot LLM Lifecycle @@ -234,3 +340,10 @@ If LLM spans exist but attach to the session instead of a subagent, pass `x-nemo-relay-subagent-id` on gateway requests or include shared `conversation_id`, `generation_id`, or `request_id` values in both hook payloads and provider requests. + +## Cold-Start Limitation + +Codex can request `/models` while it is discovering the provider, before it +launches plugin MCP servers. That pre-MCP request can fail and be retried. The +required MCP server still blocks the captured turn and `/responses` request +until the shared Relay gateway is ready. diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index 920887fee..6a568faf9 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -27,6 +27,9 @@ The selected host CLI must also be available: - `claude` for Claude Code plugin installation. - `codex` for Codex plugin installation. +Codex plugin installation requires `codex-cli >= 0.143.0`. The installer checks +the version before modifying plugin or Codex configuration. + ## Install Host Plugin Run the command for the host plugin that you want to install: @@ -64,6 +67,11 @@ default directory is platform-specific: | Linux | `${XDG_DATA_HOME:-~/.local/share}/nemo-relay/plugins` | | Windows | `%LOCALAPPDATA%\nemo-relay\plugins` | +Install, force-install, rollback, and uninstall operations are serialized per +user and host, including operations that name different install directories. +If another operation is active, Relay waits briefly and then returns an +actionable timeout instead of changing host-global plugin state concurrently. + ## What Install Changes `nemo-relay install` writes a local marketplace named `nemo-relay-local`, then @@ -76,22 +84,56 @@ existing Claude authentication and model settings and backs them up only when it adds the Relay provider route. For Codex, install registers the local Codex marketplace, installs -`nemo-relay-plugin@nemo-relay-local`, enables Codex hooks, merges generated hook -entries, and configures the `nemo-relay-openai` provider alias at -`http://127.0.0.1:47632`. - -Codex plugin mode is hook-supervised on-demand startup only. It does not install a -Codex wrapper, user-level daemon, launch agent, system user service, -scheduled task, login item, or persistent supervisor. The sidecar starts when an -installed Codex hook runs, reuses an already healthy sidecar when one exists, -and exits after its idle timeout. - - -The plugin captures the first provider request only when Codex fires an -installed hook before that request. If a Codex version calls the provider -before any hook, the plugin cannot guarantee first-request capture under -hook-only lazy startup. - +`nemo-relay-plugin@nemo-relay-local`, enables Codex hooks, and configures the +`nemo-relay-openai` provider alias at `http://127.0.0.1:47632`. The plugin's +`hooks/hooks.json` is the sole persistent Relay hook source. The installer uses +the Codex app-server API to select hooks by plugin ID, exact canonical command, +and event definition. It never trusts unrelated user, project, or plugin hooks. +If Codex does not discover exactly one enabled and trusted handler for every +generated event—`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, +`PermissionRequest`, `SubagentStart`, `SubagentStop`, `Stop`, `PreCompact`, and +`PostCompact`—installation restores the previous Codex config, legacy hooks, +Relay backup files, and every original targeted trust entry. These 10 events are +the complete supported Codex 0.143 plugin hook schema; Relay does not generate +undiscoverable `PostToolUseFailure`, `Notification`, or `SessionEnd` handlers. +Upgrade removes legacy Relay groups from +`~/.codex/hooks.json` while preserving unrelated hooks. + +Start a new Codex CLI process after installation. Restart the Codex desktop app +if it was already running so it reloads the provider and hook configuration. + +Codex plugin mode uses a required `nemo-relay mcp` lifecycle client. The client +starts or reuses a detached native sidecar on `127.0.0.1:47632`, verifies Relay +identity, version, protocol readiness, and effective user-level configuration, +and heartbeats it every 30 seconds. Concurrent +Codex processes share the gateway. After the final MCP client closes, the +sidecar exits after 300 idle seconds by default. If the gateway exits while MCP +stdio is open, the client performs one coordinated restart and fails if +recovery does not succeed. A foreign process on the configured port is rejected +with an actionable conflict instead of being accepted because it returned HTTP +200. + +On Windows, Relay requests Job Object breakaway only when the host job permits +it. If the host applies a restrictive job but permits nested jobs, Relay keeps +the sidecar scoped to that host job and retains a nested cleanup job for its +process tree; the sidecar cannot outlive the host job. If Relay cannot create or +configure the cleanup job, or the host rejects nested assignment, persistent +bootstrap fails actionably instead of starting without process-tree cleanup +guarantees. + +The compatibility fingerprint covers resolved provider, exporter, dynamic +plugin, and relevant environment settings without exposing their values in the +health response. `nemo-relay install codex --force` uses a private ownership +token to stop an existing installer-owned sidecar before refreshing the plugin; +it does not terminate an unrelated listener. + +Persistent mode resolves only system and user Relay configuration and starts in +the user configuration directory. Project `.nemo-relay` layers remain available +through transparent `nemo-relay run` invocations. The generated MCP manifest +forwards environment variable names for provider credentials, Relay runtime, +OpenTelemetry, AWS, proxies, certificates, approved prefixes, and credential +variables referenced by user observability configuration. It never stores +their values. ## Diagnose @@ -112,8 +154,14 @@ nemo-relay doctor --plugin all `nemo-relay doctor` includes every persistent host-plugin installation found in the default platform install directory. It checks the generated marketplace and plugin files, Relay binary and hook support, host registration, provider -routing, hooks, and lazy-sidecar assumptions. A stopped Codex lazy sidecar is -informational; hooks start it on first use. +routing, hooks, and sidecar readiness assumptions. For Codex, doctor also asks the +Codex app-server whether each exact Relay-generated hook is trusted and +enabled. JSON output includes `checks.codex_hooks_trusted` and a +`codex_hook_trust` object that groups trusted, untrusted, modified, disabled, +missing, or duplicated required hooks. A stopped Codex sidecar is informational; +the required plugin MCP starts it before the captured turn. Doctor also reports +newly required forwarded environment names and recommends +`nemo-relay install codex --force`. Use the focused plugin doctor when diagnosing one host or an installation that uses a custom directory: @@ -143,10 +191,11 @@ nemo-relay uninstall codex nemo-relay uninstall all ``` -Uninstall removes the generated host plugin registration and marketplace entry, -restores Claude Code provider routing from the Relay backup, and removes -generated Codex hook/provider configuration, while preserving unrelated user -configuration. +Uninstall removes Codex provider configuration and trust for the exact plugin +hooks before unregistering the plugin, while Codex can still report its hook +metadata. It then removes the host registration and marketplace. Claude Code +provider routing is restored from the Relay backup. Unrelated user hooks and +configuration remain unchanged. ## Source Marketplace Discovery @@ -159,6 +208,5 @@ validation: Those manifests are useful when validating host plugin metadata from a source checkout. For end-user setup, use `nemo-relay install `. It generates the local marketplace, registers the host plugin, and performs the required -provider and hook setup. Avoid keeping both a source-installed -plugin and a generated install active for the same host because both can forward -the same hook payload. +provider and hook setup. Source manifests do not provide Codex's complete +provider, environment-forwarding, and verified-trust workflow. diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 9a9c09a0c..1096ae96b 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -28,9 +28,10 @@ environment variables, or shared TOML config. - `codex/` is a Codex plugin package. `nemo-relay install codex` creates the marketplace, installs the plugin, enables `features.hooks = true`, and configures a local `nemo-relay-openai` provider alias. Codex plugin delivery - uses hook-supervised lazy sidecar startup only, with no wrapper, user-level daemon, - login item, launchd agent, systemd user service, scheduled task, or persistent - supervisor. + uses required native `nemo-relay mcp` lifecycle clients that share one + Rust gateway, subject to the Windows Job Object lifetime caveat below, with no + wrapper, login item, launchd agent, systemd user service, scheduled task, or + persistent supervisor. - Hermes does not require a static bundle in this directory. The setup wizard (`nemo-relay config`) merges hook commands into `.hermes/config.yaml` when hermes is selected. @@ -76,11 +77,23 @@ required. Claude Code can start the sidecar from plugin hooks or helper commands and route model traffic by setting `ANTHROPIC_BASE_URL` to the sidecar URL. -Codex does not use a daemon in plugin mode. The installed Codex hooks call the -`nemo-relay plugin-shim hook codex` command. The shim then checks `/healthz`, starts -the local `nemo-relay` sidecar, if needed, waits briefly for readiness, and then -forwards the hook payload. Codex model traffic is routed through the stable -provider alias at `http://127.0.0.1:47632`. +Codex's required plugin MCP entry starts `nemo-relay mcp`, a lightweight client +that starts or reuses a native `nemo-relay --bind 127.0.0.1:47632` sidecar. Relay +detaches the sidecar when host policy permits. A restrictive Windows Job Object +keeps the sidecar scoped to that host job instead of failing bootstrap. +MCP initialization waits for Relay identity, version, and bootstrap-protocol +readiness. Concurrent Codex processes share the gateway and heartbeat it while +their MCP stdio connections remain open; the gateway exits after the final +client's idle timeout. Plugin-owned hooks call the canonically resolved +`nemo-relay plugin-shim hook codex` command, and model traffic uses the same +stable provider alias. + +Persistent Codex mode loads system and user Relay configuration only and starts +the sidecar from the user configuration directory. Relative exporter paths are +therefore stable across projects. The generated MCP manifest forwards approved +provider, Relay, OpenTelemetry, AWS, proxy, certificate, and config-referenced +credential environment names without storing their values. Use transparent +`nemo-relay run` for project-specific configuration. Install the local host marketplaces with: @@ -106,9 +119,9 @@ codex plugin add nemo-relay-plugin@nemo-relay That path relies on `nemo-relay` being available on `PATH`; source plugin hooks invoke `nemo-relay plugin-shim hook codex` directly. -Use the source marketplace path for discovery or manifest validation. Remove -the source-installed Codex plugin before running `nemo-relay install codex`; -keeping both active can forward the same Codex hook twice. +Use the source marketplace path for discovery or manifest validation. Use +`nemo-relay install codex` for complete provider routing, environment +forwarding, and verified plugin-hook trust. Claude Code users can add this repository as a marketplace the same way: @@ -132,6 +145,9 @@ project `.nemo-relay/config.toml`, then `$XDG_CONFIG_HOME/nemo-relay/config.toml` or `~/.config/nemo-relay/config.toml`. +That layering applies to transparent runs. Persistent Codex plugin mode skips +the project layer and merges only system and user configuration. + ```toml [agents.codex] command = "codex" @@ -176,8 +192,9 @@ the same hook command reaches the ephemeral per-run gateway; hermes hooks fall back to an embedded `--gateway-url` when running outside the wrapper. Claude Code and Codex plugin hooks call `nemo-relay plugin-shim hook `. -The plugin shim ensures the local sidecar is reachable, then forwards the hook -payload to the plugin sidecar endpoint. +For Codex, the installed plugin file is the sole persistent Relay hook source; +installation does not add Relay groups to `~/.codex/hooks.json`. The shim +forwards each canonical payload to the verified shared sidecar. Since hook forwarding fails open by default, gateway or sidecar outages do not block the coding agent. The hook command exits successfully after logging the diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index 028608207..8fb7344af 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -13,40 +13,109 @@ supported only when they run locally and honor the same hook/plugin config and provider routing. Cloud or remote Codex tasks are partial or unsupported for local gateway LLM capture. -Requires `codex-cli >= 0.129.0` (introduced the `features.hooks` flag and the -provider alias surface the gateway relies on). +Requires `codex-cli >= 0.143.0`, including the complete required lifecycle-hook +set, plugin app-server metadata, `features.hooks`, and provider alias surfaces +used by the installer. ## Files - `.codex-plugin/plugin.json` describes the Codex plugin package. +- `.mcp.json` starts the native `nemo-relay mcp` lifecycle client and requires + successful gateway initialization. - `hooks/hooks.json` contains Codex hook entries that run `nemo-relay plugin-shim hook codex`. - `nemo-relay install codex` creates the local marketplace, installs the plugin, - and persists Codex hook and provider configuration using `nemo-relay` from - `PATH`. + and persists Codex provider and exact plugin-hook trust using `nemo-relay` + from `PATH`. ## Captured Events -With `codex-cli >= 0.129.0`, the minimum supported installed hooks are -`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, -`PermissionRequest`, `Stop`, `PreCompact`, and `PostCompact`. +With `codex-cli >= 0.143.0`, persistent installation requires `SessionStart`, +`UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, +`SubagentStart`, `SubagentStop`, `Stop`, `PreCompact`, and `PostCompact`. Relay +requires exactly one enabled and trusted app-server handler for every generated +event and forwards delivered hooks as scope, tool, mark, or private LLM +correlation events. `PostToolUseFailure`, `Notification`, and `SessionEnd` are not +in the Codex 0.143 plugin hook schema and are not generated. + +Each delivered `Stop` closes the active turn and writes a cumulative ATIF +snapshot. Because the plugin schema does not expose `SessionEnd`, the final +`Stop` is the final session snapshot. + +Transparent setup injects hooks with CLI config overrides. Persistent setup +writes `features.hooks = true` in `.codex/config.toml`, configures the +`nemo-relay-openai` provider alias, and uses this plugin's `hooks/hooks.json` as +the sole persistent Relay hook source. It does not add Relay groups to +`.codex/hooks.json`. -The hook template also documents events used by newer or broader host hook -surfaces, including `SessionEnd`, `PostToolUseFailure`, `SubagentStart`, -`SubagentStop`, and `Notification`. Relay forwards any delivered supported hook -as scope, tool, mark, or private LLM correlation events, but the v1 plugin -manifest does not depend on Codex exposing those broader events. +Persistent installation opens the stable Codex app-server interface and +selects only hooks whose source is `plugin`, plugin ID is +`nemo-relay-plugin@nemo-relay-local`, and command exactly matches the generated +canonical Relay shim command. It requires exactly one handler for each event +in the complete 10-event supported set listed above. + +Unrelated user, project, and plugin hooks are never trusted. If installation +fails after a trust write, it restores every targeted hook's prior trusted, +modified, disabled, or absent state together with Codex config and backups. +Upgrade removes legacy Relay user-hook groups while preserving unrelated hooks. + +Codex plugin mode starts the native `nemo-relay mcp` subcommand through the +plugin MCP configuration. That Rust process is a lightweight lifecycle client: +it starts or reuses a detached native `nemo-relay --bind 127.0.0.1:47632` +sidecar, rejects foreign listeners, and completes the MCP handshake only after +Relay identity, version, bootstrap protocol, and effective persistent +configuration are verified. Compatibility uses a one-way fingerprint of the +resolved settings and relevant environment values without exposing secrets. +The complete dynamic plugin activation snapshot, including adjacent runtime +files and a copied managed Python environment, is limited to 100,000 filesystem +entries and 512 MiB in total, with a maximum directory traversal depth of 128. +If startup reports an activation snapshot budget error, remove unrelated files +from the manifest or load-target directory, flatten deeply nested directories, +or reduce the managed Python environment before retrying. Concurrent Codex +processes share the gateway and heartbeat it every 30 seconds. The sidecar +remains available for 300 idle seconds after the final client closes. If it dies +while MCP remains open, one coordinated restart is attempted. The MCP server +advertises no tools. + +On Windows, Relay requests Job Object breakaway only when the host job permits +it. Under a restrictive Job Object that permits nested jobs, Relay keeps the +sidecar scoped to the host job and retains a nested cleanup job for the sidecar +process tree. The sidecar cannot outlive the host job, so the 300-second idle +reuse window can end early. If Relay cannot create or configure the cleanup job, +or the host rejects nested assignment, persistent bootstrap fails actionably +instead of running without process-tree cleanup guarantees. + +Persistent mode loads only system and user Relay configuration and starts from +the user configuration directory. Project `.nemo-relay` layers remain specific +to transparent `nemo-relay run` invocations. The MCP manifest forwards approved +provider, Relay, OpenTelemetry, AWS, proxy, certificate, and config-referenced +credential variable names without storing values. + +Plugin-owned hook commands pin `http://127.0.0.1:47632` explicitly, so an +ambient `NEMO_RELAY_PLUGIN_GATEWAY_URL` cannot split hook traffic from the +required MCP-managed gateway. + +If the Relay version, user configuration, or forwarded credentials change, an +MCP client refuses to reuse the incompatible sidecar. `nemo-relay install codex +--force` retires an installer-owned sidecar through its private shutdown token +before refreshing the plugin. + +An existing install without MCP generation fencing cannot be retired safely, +even when Codex reports that the plugin is no longer registered, because an +already-running MCP process can outlive registration. If upgrade or uninstall +reports a missing MCP generation marker, close every Codex client and +standalone `nemo-relay mcp` process, then run: -Transparent setup injects these hooks with CLI config overrides. Plugin setup -does not install hooks from the package template directly. It writes -`features.hooks = true` in `.codex/config.toml`, configures the -`nemo-relay-openai` provider alias, and merges hook shim entries into -`.codex/hooks.json`. +```bash +codex plugin remove nemo-relay-plugin@nemo-relay-local +codex plugin marketplace remove nemo-relay-local +``` -Codex plugin mode uses hook-supervised on-demand startup only. It does not install a -user-level daemon, launchd agent, systemd user service, scheduled task, login -item, wrapper, or persistent supervisor. The sidecar starts only when a Codex -hook invokes `nemo-relay plugin-shim hook codex`. +Remove `codex-marketplace` and `codex.json` from the install directory named in +the error, then retry `nemo-relay install codex --force` to install a fenced +generation. If removal was the original goal, run `nemo-relay uninstall codex` +immediately afterward; the fenced reinstall lets Relay remove provider and hook +trust state transactionally. ## Transparent Setup @@ -73,10 +142,9 @@ nemo-relay run \ -- codex ``` -## Shared Config +## Configure Transparent Runs -Use `.nemo-relay/config.toml` for project defaults or -`~/.config/nemo-relay/config.toml` for user defaults: +Use `.nemo-relay/config.toml` for project defaults: ```toml [agents.codex] @@ -104,6 +172,32 @@ Then run: nemo-relay run --agent codex ``` +This example writes ATIF files under the project at `.nemo-relay/atif`. + +## Configure the Persistent Plugin + +Use `~/.config/nemo-relay/config.toml`, or +`$XDG_CONFIG_HOME/nemo-relay/config.toml` when `XDG_CONFIG_HOME` is set, for +persistent provider defaults. Run `nemo-relay plugins edit` without +`--project` to write user-scoped observability configuration. For example: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = true +output_directory = "atif" +``` + +Persistent mode ignores project layers and starts the sidecar in the user Relay +configuration directory. The relative path above resolves to +`$XDG_CONFIG_HOME/nemo-relay/atif`, or `~/.config/nemo-relay/atif` when +`XDG_CONFIG_HOME` is not set. + ## Standalone Gateway Use the long-running gateway only when you do not want to launch Codex through @@ -141,13 +235,19 @@ for context. ## Verify -Run a Codex session that starts, uses one simple tool, and ends. Confirm that -ATIF was written: +Run a Codex session that starts, uses one simple tool, and ends. For a +transparent project run, confirm that ATIF was written: ```bash ls .nemo-relay/atif ``` +For the persistent user-scoped configuration above, enter: + +```bash +ls "${XDG_CONFIG_HOME:-$HOME/.config}/nemo-relay/atif" +``` + For a direct endpoint smoke test against a manually started gateway: ```bash @@ -174,9 +274,10 @@ nemo-relay install codex `nemo-relay install codex` writes a local Codex marketplace, registers `nemo-relay-plugin`, enables Codex hooks, and configures the -`nemo-relay-openai` provider alias. Codex sidecar lifecycle remains -hook-supervised on-demand startup only; the installer does not create a wrapper or -daemon. +`nemo-relay-openai` provider alias. It writes a required MCP server entry that +invokes the resolved native `nemo-relay` binary. Installation automatically +trusts the exact plugin-owned hook definitions through `codex app-server` and +rolls back files and original trust state if activation cannot be verified. The install command requires `nemo-relay` to be available on `PATH`. It does not require launching Codex through the `nemo-relay` wrapper and does not install a @@ -193,16 +294,16 @@ That path reads `.agents/plugins/marketplace.json` from the repository and installs this Codex plugin from `integrations/coding-agents/codex`. Source hooks invoke `nemo-relay plugin-shim hook codex` directly. -Treat the source marketplace path as discovery or manifest validation. For the -complete provider and generated-hook setup, remove the source-installed plugin -first and then run `nemo-relay install codex`. Keeping both the source plugin -and the generated install active can forward the same Codex hook twice. +Treat the source marketplace path as discovery or manifest validation. Use +`nemo-relay install codex` for the complete provider, environment-forwarding, +and verified-trust setup. Package or unpack the plugin so the plugin root contains: ```text nemo-relay-plugin/ .codex-plugin/plugin.json + .mcp.json hooks/hooks.json ``` @@ -249,11 +350,8 @@ codex plugin marketplace add "$MARKETPLACE_ROOT" codex plugin add nemo-relay-plugin@nemo-relay-local ``` -For end-to-end installation, we recommend using`nemo-relay install codex`; it performs the -marketplace registration and the persistent Codex provider/hook setup together. -If you used the manual source marketplace commands above, remove that plugin -before running the full installer so source hook templates and generated -persistent hooks do not both forward the same event. +For end-to-end installation, use `nemo-relay install codex`; it performs the +marketplace registration and persistent provider/plugin-hook setup together. The installer writes a provider alias like: @@ -274,16 +372,23 @@ Run read-only plugin checks: nemo-relay doctor --plugin codex ``` +Doctor reports the generated MCP server, native `nemo-relay mcp` support, +plugin hook installation, environment forwarding, and live Codex trust state. +In JSON mode, inspect `checks.codex_hooks_trusted` and `codex_hook_trust` for +untrusted, modified, disabled, or missing required hook entries. + Start a normal Codex session: ```bash codex ``` -The installed hooks start the Relay sidecar lazily on -`http://127.0.0.1:47632`, and the Codex provider alias routes model traffic -through that sidecar. No launchd agent, systemd user service, scheduled task, -login item, wrapper, or persistent supervisor is installed. +Start a new CLI process after install, or restart the Codex desktop app if it +was already open, so the provider selection and hooks are reloaded. + +The required plugin MCP server starts or reuses the shared native Relay gateway +on `http://127.0.0.1:47632` before Codex begins the captured turn, and the +provider alias routes model traffic through it. To upgrade, replace the plugin directory contents with the new package for the same host, keep the same `MARKETPLACE_ROOT`, refresh the local marketplace @@ -297,14 +402,16 @@ codex plugin add nemo-relay-plugin@nemo-relay-local nemo-relay install codex ``` -To uninstall, remove NeMo Relay's Codex config and hook entries, remove the -marketplace registration, and remove the generated marketplace directory: +To uninstall, remove NeMo Relay's Codex config and exact plugin-hook trust, +remove the marketplace registration, and remove the generated marketplace +directory: ```bash nemo-relay uninstall codex ``` -Full first-request LLM capture depends on Codex firing one of the installed -hooks, especially `SessionStart` or `UserPromptSubmit`, before its first model -provider request. If a Codex version sends the provider request first, the first -request may fail or may not be captured until the next hook starts Relay. +Codex can perform provider discovery before it launches plugin MCP servers. A +cold start can therefore produce transient `/models` connection failures that +Codex retries. Because the MCP server is required, Codex does not begin the +captured turn or send its `/responses` request until the native Relay gateway is +ready; if startup fails, the turn fails instead of silently bypassing Relay. From 0363f129f713777f32b17bed509dd6a46de44799 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Fri, 10 Jul 2026 22:58:16 -0400 Subject: [PATCH 004/102] feat(claude): share native MCP gateway lifecycle Signed-off-by: Will Killian --- .claude-plugin/marketplace.json | 2 +- crates/cli/src/config.rs | 30 +- crates/cli/src/install_generation.rs | 4 +- crates/cli/src/main.rs | 2 +- crates/cli/src/mcp.rs | 9 +- crates/cli/src/mcp/gateway.rs | 10 +- crates/cli/src/mcp/protocol.rs | 2 +- crates/cli/src/mcp_environment.rs | 2 +- crates/cli/src/plugin_install/host.rs | 2 +- crates/cli/src/plugin_install/marketplace.rs | 55 ++-- crates/cli/src/plugin_install/mod.rs | 193 +++++++++---- crates/cli/src/plugin_install/setup.rs | 10 +- crates/cli/src/plugin_shim/claude.rs | 30 +- crates/cli/src/plugin_shim/mod.rs | 26 +- crates/cli/src/sidecar.rs | 84 +++--- crates/cli/src/sidecar/state.rs | 2 +- crates/cli/tests/cli_tests.rs | 148 +++++----- crates/cli/tests/coverage/config_tests.rs | 10 +- crates/cli/tests/coverage/installer_tests.rs | 17 +- crates/cli/tests/coverage/main_tests.rs | 17 +- crates/cli/tests/coverage/mcp_tests.rs | 2 +- .../coverage/plugin_install_setup_tests.rs | 9 - .../tests/coverage/plugin_install_tests.rs | 236 ++++++++++++++- .../cli/tests/coverage/plugin_shim_tests.rs | 30 ++ .../cli/tests/coverage/sidecar_state_tests.rs | 36 +-- crates/cli/tests/coverage/sidecar_tests.rs | 15 +- .../release-notes/known-issues.mdx | 2 +- docs/nemo-relay-cli/basic-usage.mdx | 2 +- docs/nemo-relay-cli/claude-code.mdx | 33 ++- docs/nemo-relay-cli/codex.mdx | 7 +- docs/nemo-relay-cli/plugin-installation.mdx | 51 ++-- integrations/coding-agents/README.md | 60 ++-- .../claude-code/.claude-plugin/plugin.json | 3 +- .../coding-agents/claude-code/.mcp.json | 15 + .../coding-agents/claude-code/README.md | 35 ++- integrations/coding-agents/codex/.mcp.json | 4 +- integrations/coding-agents/codex/README.md | 5 +- justfile | 4 + scripts/test-claude-plugin-e2e.sh | 271 ++++++++++++++++++ scripts/test-support/codex_mock_provider.py | 101 ++++++- 40 files changed, 1219 insertions(+), 357 deletions(-) create mode 100644 integrations/coding-agents/claude-code/.mcp.json create mode 100755 scripts/test-claude-plugin-e2e.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a276b9452..4446870e3 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "plugins": [ { "name": "nemo-relay-plugin", - "description": "Forward Claude Code lifecycle hooks to a local NeMo Relay sidecar.", + "description": "Run the shared native Relay gateway and capture Claude Code lifecycle events.", "source": "./integrations/coding-agents/claude-code", "category": "development" } diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 8507c6b6d..174341545 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -101,7 +101,7 @@ pub(crate) enum Command { nemo-relay mcp\n \ nemo-relay --bind 127.0.0.1:4041 mcp # explicit standalone/test bind" )] - Mcp, + Mcp(McpCommand), /// Run the interactive setup (writes `.nemo-relay/config.toml`) Config(ConfigCommand), /// Create or edit plugin configuration (writes `plugins.toml`) @@ -128,6 +128,32 @@ pub(crate) enum Command { PluginShim(PluginShimCommand), } +/// Host identity for the lifecycle-bound MCP client. +#[derive(Debug, Clone, Args)] +pub(crate) struct McpCommand { + /// Coding-agent host that launched this MCP client. + #[arg(long, value_enum, default_value = "codex")] + pub(crate) agent: McpAgent, +} + +/// Hosts whose plugin format can own the lifecycle-bound MCP client. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub(crate) enum McpAgent { + #[value(name = "claude", alias = "claude-code")] + ClaudeCode, + Codex, +} + +impl From for CodingAgent { + fn from(agent: McpAgent) -> Self { + match agent { + McpAgent::ClaudeCode => Self::ClaudeCode, + McpAgent::Codex => Self::Codex, + } + } +} + /// Args for `nemo-relay doctor`. `--json` is on this command (rather than as a global flag) /// so it doesn't pollute the help output of subcommands where it has no meaning. #[derive(Debug, Clone, Args)] @@ -728,7 +754,7 @@ pub(crate) fn resolve_server_config(args: &ServerArgs) -> Result Result { diff --git a/crates/cli/src/install_generation.rs b/crates/cli/src/install_generation.rs index 2b59c530f..b060fbc24 100644 --- a/crates/cli/src/install_generation.rs +++ b/crates/cli/src/install_generation.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Private install-generation fencing for lifecycle-bound Codex MCP supervisors. +//! Private install-generation fencing for lifecycle-bound plugin MCP clients. use std::env; use std::fs::{self, File, OpenOptions}; @@ -293,7 +293,7 @@ fn read_generation_marker(file: &File, path: &Path) -> Result String { format!( - "Codex plugin MCP install generation at {} has been retired", + "plugin MCP install generation at {} has been retired", path.display() ) } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 15439b4fa..71e8d22ba 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -89,7 +89,7 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result { launcher::easy_path(CodingAgent::Hermes, command, Some(server)).await } - Command::Mcp => mcp::run(server).await, + Command::Mcp(command) => mcp::run(command.agent.into(), 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/mcp.rs b/crates/cli/src/mcp.rs index cf259689b..d395c3a45 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -11,13 +11,16 @@ mod transport; use std::net::SocketAddr; use std::process::ExitCode; -use crate::config::ServerArgs; +use crate::config::{CodingAgent, ServerArgs}; use crate::error::CliError; -pub(crate) async fn run(server_args: &ServerArgs) -> Result { +pub(crate) async fn run( + agent: CodingAgent, + server_args: &ServerArgs, +) -> Result { // Configuration is resolved before reading stdin, but the gateway process is not acquired // until the session receives a valid MCP initialize request. - let gateway = gateway::GatewayPlan::resolve(server_args).await?; + let gateway = gateway::GatewayPlan::resolve(agent, server_args).await?; let frames = transport::spawn_stdin_reader()?; session::run(gateway, frames, tokio::io::stdout()).await?; Ok(ExitCode::SUCCESS) diff --git a/crates/cli/src/mcp/gateway.rs b/crates/cli/src/mcp/gateway.rs index f5bddcecd..0b1029128 100644 --- a/crates/cli/src/mcp/gateway.rs +++ b/crates/cli/src/mcp/gateway.rs @@ -1,14 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Acquisition and liveness lease for the shared Codex gateway. +//! Acquisition and liveness lease for a shared coding-agent gateway. #[cfg(test)] use std::ffi::OsString; use std::net::SocketAddr; use std::time::Duration; -#[cfg(test)] use crate::config::CodingAgent; use crate::config::ServerArgs; use crate::error::CliError; @@ -25,7 +24,10 @@ pub(super) struct GatewayPlan { } impl GatewayPlan { - pub(super) async fn resolve(server_args: &ServerArgs) -> Result { + pub(super) async fn resolve( + agent: CodingAgent, + server_args: &ServerArgs, + ) -> Result { let generation = tokio::task::spawn_blocking(InstallGeneration::capture_from_env) .await .map_err(|error| { @@ -33,7 +35,7 @@ impl GatewayPlan { })? .map_err(CliError::Launch)?; let bind = server_args.bind.unwrap_or_else(super::default_mcp_bind); - let launch = crate::sidecar::resolve_codex_gateway(server_args, bind)?; + let launch = crate::sidecar::resolve_plugin_gateway(agent, server_args, bind)?; let heartbeat_interval = crate::sidecar::plugin_heartbeat_interval().map_err(CliError::Launch)?; Ok(Self { diff --git a/crates/cli/src/mcp/protocol.rs b/crates/cli/src/mcp/protocol.rs index 80d7c3df1..cff8fedde 100644 --- a/crates/cli/src/mcp/protocol.rs +++ b/crates/cli/src/mcp/protocol.rs @@ -71,7 +71,7 @@ pub(super) fn response_for(message: &Value) -> Option { "id": id, "result": { "protocolVersion": protocol_version, - "capabilities": { "tools": {} }, + "capabilities": {}, "serverInfo": { "name": "nemo-relay", "version": env!("CARGO_PKG_VERSION") diff --git a/crates/cli/src/mcp_environment.rs b/crates/cli/src/mcp_environment.rs index 781da7d25..733aae7a7 100644 --- a/crates/cli/src/mcp_environment.rs +++ b/crates/cli/src/mcp_environment.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Environment names shared by Codex MCP generation and gateway compatibility checks. +//! Environment names shared by MCP generation and gateway compatibility checks. use std::collections::BTreeSet; diff --git a/crates/cli/src/plugin_install/host.rs b/crates/cli/src/plugin_install/host.rs index 709194cb9..2a6d7f33f 100644 --- a/crates/cli/src/plugin_install/host.rs +++ b/crates/cli/src/plugin_install/host.rs @@ -331,7 +331,7 @@ pub(super) fn validate_relay_mcp( Ok(()) } else { Err(format!( - "{} failed with exit code {status}; the Codex plugin requires native `nemo-relay mcp` support", + "{} failed with exit code {status}; coding-agent plugins require native `nemo-relay mcp` support", format_command(&relay.display().to_string(), &args) )) } diff --git a/crates/cli/src/plugin_install/marketplace.rs b/crates/cli/src/plugin_install/marketplace.rs index 5009dfe8d..c13891331 100644 --- a/crates/cli/src/plugin_install/marketplace.rs +++ b/crates/cli/src/plugin_install/marketplace.rs @@ -35,7 +35,7 @@ pub(super) fn write_plugin_marketplace_for_generation( if options.dry_run { println!("write {}", layout.marketplace_manifest.display()); println!("write {}", layout.plugin_manifest.display()); - if matches!(host, PluginHost::Codex) { + if plugin_uses_mcp(host) { println!("write {}", layout.mcp_config.display()); println!("write {}", layout.generation_fence.display()); } @@ -59,12 +59,13 @@ pub(super) fn write_plugin_marketplace_for_generation( } write_json(&layout.marketplace_manifest, &marketplace_manifest(host))?; write_json(&layout.plugin_manifest, &plugin_manifest(host))?; - if matches!(host, PluginHost::Codex) { + if plugin_uses_mcp(host) { write_new_generation(&layout.generation_fence)?; } - if let Some(mcp_config) = plugin_mcp_config(host, relay, active_generation_fence)? { - write_json(&layout.mcp_config, &mcp_config)?; - } + write_json( + &layout.mcp_config, + &plugin_mcp_config(host, relay, active_generation_fence)?, + )?; if plugin_has_hooks_template(host) { write_json(&layout.hooks_path, &plugin_hooks(host, relay))?; } @@ -102,7 +103,7 @@ pub(super) fn marketplace_manifest(host: PluginHost) -> Value { }, "plugins": [{ "name": PLUGIN_NAME, - "description": "Forward Claude Code lifecycle hooks to a local NeMo Relay sidecar.", + "description": "Run the shared native Relay gateway and capture Claude Code lifecycle events.", "source": "./plugins/nemo-relay-plugin", "category": "development" }] @@ -117,7 +118,7 @@ pub(super) fn plugin_manifest(host: PluginHost) -> Value { "Native Relay gateway lifecycle and Codex hooks for complete local observability." } PluginHost::ClaudeCode => { - "Claude Code hooks that forward canonical lifecycle payloads to nemo-relay." + "Native Relay gateway lifecycle and Claude Code hooks for complete local observability." } PluginHost::All => unreachable!("all is expanded before manifest generation"), }; @@ -139,8 +140,10 @@ pub(super) fn plugin_manifest(host: PluginHost) -> Value { "license": "Apache-2.0", "keywords": keywords }); - if matches!(host, PluginHost::Codex) { + if plugin_uses_mcp(host) { manifest["mcpServers"] = json!("./.mcp.json"); + } + if matches!(host, PluginHost::Codex) { manifest["interface"] = json!({ "displayName": "NeMo Relay Plugin", "shortDescription": "Run the native Relay gateway and capture Codex lifecycle events.", @@ -160,15 +163,12 @@ pub(super) fn plugin_mcp_config( host: PluginHost, relay: &Path, generation_fence: &Path, -) -> Result, String> { - if !matches!(host, PluginHost::Codex) { - return Ok(None); - } +) -> Result { let generation_fence = absolute_or_self(generation_fence); - Ok(Some(json!({ - "nemo-relay": { + let server = match host { + PluginHost::Codex => json!({ "command": relay, - "args": ["mcp"], + "args": ["mcp", "--agent", "codex"], "env": { "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632", (GENERATION_FILE_ENV): generation_fence @@ -176,8 +176,22 @@ pub(super) fn plugin_mcp_config( "env_vars": plugin_mcp_env_vars()?, "required": true, "startup_timeout_sec": 20 - } - }))) + }), + PluginHost::ClaudeCode => json!({ + "command": relay, + "args": ["mcp", "--agent", "claude"], + "env": { + "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632", + (GENERATION_FILE_ENV): generation_fence + } + }), + PluginHost::All => unreachable!("all is expanded before MCP generation"), + }; + Ok(match host { + PluginHost::Codex => json!({ "nemo-relay": server }), + PluginHost::ClaudeCode => json!({ "mcpServers": { "nemo-relay": server } }), + PluginHost::All => unreachable!("all is expanded before MCP generation"), + }) } fn absolute_or_self(path: &Path) -> std::path::PathBuf { @@ -222,3 +236,10 @@ pub(super) fn plugin_has_hooks_template(host: PluginHost) -> bool { PluginHost::All => unreachable!("all is expanded before hook generation"), } } + +pub(super) fn plugin_uses_mcp(host: PluginHost) -> bool { + match host { + PluginHost::Codex | PluginHost::ClaudeCode => true, + PluginHost::All => unreachable!("all is expanded before MCP generation"), + } +} diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/plugin_install/mod.rs index 25bc304c1..9cd3795d2 100644 --- a/crates/cli/src/plugin_install/mod.rs +++ b/crates/cli/src/plugin_install/mod.rs @@ -28,7 +28,7 @@ use host::{ run_host_plugin_removal, validate_relay_mcp, validate_relay_plugin_shim, }; use marketplace::{ - marketplace_manifest, plugin_hooks, plugin_manifest, plugin_mcp_config, + marketplace_manifest, plugin_hooks, plugin_manifest, plugin_mcp_config, plugin_uses_mcp, write_plugin_marketplace, write_plugin_marketplace_for_generation, }; use operation_lock::{DEFAULT_OPERATION_LOCK_TIMEOUT, PluginOperationLock}; @@ -424,7 +424,7 @@ fn install_host_locked( ) -> Result<(), String> { let relay = require_relay(options, runner)?; validate_relay_plugin_shim(&relay, options, runner)?; - if matches!(host, PluginHost::Codex) { + if plugin_uses_mcp(host) { validate_relay_mcp(&relay, options, runner)?; } require_host_cli(host, options, runner)?; @@ -432,21 +432,21 @@ fn install_host_locked( host::validate_codex_version(options, runner)?; } let layout = PluginLayout::new(host, &options.install_dir); - let codex_preflight = if !options.dry_run && matches!(host, PluginHost::Codex) { - Some(prepare_codex_install(&layout, options, runner)?) + let plugin_preflight = if !options.dry_run && plugin_uses_mcp(host) { + Some(prepare_plugin_install(host, &layout, options, runner)?) } else { None }; if !options.force - && codex_preflight + && plugin_preflight .as_ref() .is_some_and(|preflight| preflight.previous_install_exists) { - return Err(existing_codex_install_requires_force_error()); + return Err(existing_plugin_install_requires_force_error(host)); } let mut force_snapshot = None; - let staged = if options.force && !options.dry_run && matches!(host, PluginHost::Codex) { - let preflight = codex_preflight.expect("Codex force install has preflight state"); + let staged = if options.force && !options.dry_run && plugin_uses_mcp(host) { + let preflight = plugin_preflight.expect("MCP plugin force install has preflight state"); let staged = stage_plugin_marketplace(host, &relay, &layout, options)?; match begin_force_replacement(host, &layout, preflight, options, runner, setup_runner) { Ok(mut snapshot) => { @@ -474,7 +474,7 @@ fn install_host_locked( None }; if options.force && staged.is_none() { - if !options.dry_run && matches!(host, PluginHost::Codex) { + if !options.dry_run && plugin_uses_mcp(host) { setup_runner.refresh_gateway(host)?; } force_cleanup_existing_install(host, &layout, options, runner, setup_runner)?; @@ -671,7 +671,7 @@ fn retire_installed_generation( runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, ) -> Result, String> { - if options.dry_run || !matches!(host, PluginHost::Codex) { + if options.dry_run || !plugin_uses_mcp(host) { return Ok(None); } let generation_fence = plugin_root.join(GENERATION_FILE_NAME); @@ -680,19 +680,19 @@ fn retire_installed_generation( let registration = host_registration_report(host, options, runner)?; existing_install |= registration.host_plugin_registered || registration.host_marketplace_registered; - if existing_install { - return Err(missing_generation_fence_error(&generation_fence)); + if existing_install && !legacy_plugin_without_mcp(host, plugin_root)? { + return Err(missing_generation_fence_error(host, &generation_fence)); } } let retirement = GenerationRetirement::acquire(&generation_fence) - .map_err(|cause| invalid_generation_fence_error(&generation_fence, &cause))?; + .map_err(|cause| invalid_generation_fence_error(host, &generation_fence, &cause))?; if retirement.is_none() && !existing_install { let registration = host_registration_report(host, options, runner)?; existing_install = registration.host_plugin_registered || registration.host_marketplace_registered; } - if retirement.is_none() && existing_install { - return Err(missing_generation_fence_error(&generation_fence)); + if retirement.is_none() && existing_install && !legacy_plugin_without_mcp(host, plugin_root)? { + return Err(missing_generation_fence_error(host, &generation_fence)); } setup_runner.refresh_gateway(host)?; Ok(retirement) @@ -704,12 +704,12 @@ fn retire_replacement_before_rollback( options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, ) -> Result, String> { - if options.dry_run || !matches!(host, PluginHost::Codex) { + if options.dry_run || !plugin_uses_mcp(host) { return Ok(None); } let mut retirement = GenerationRetirement::acquire(&layout.generation_fence) - .map_err(|cause| invalid_generation_fence_error(&layout.generation_fence, &cause))? - .ok_or_else(|| missing_generation_fence_error(&layout.generation_fence))?; + .map_err(|cause| invalid_generation_fence_error(host, &layout.generation_fence, &cause))? + .ok_or_else(|| missing_generation_fence_error(host, &layout.generation_fence))?; setup_runner.refresh_gateway(host)?; retirement.invalidate_for_replacement().map_err(|error| { format!( @@ -720,26 +720,69 @@ fn retire_replacement_before_rollback( Ok(Some(retirement)) } -fn existing_codex_install_requires_force_error() -> String { - "an existing fenced Codex plugin install was found; rerun `nemo-relay install codex --force` to replace it safely" - .into() +fn existing_plugin_install_requires_force_error(host: PluginHost) -> String { + format!( + "an existing fenced {} plugin install was found; rerun `nemo-relay install {} --force` to replace it safely", + host_label(host), + host_arg(host) + ) } -fn missing_generation_fence_error(generation_fence: &Path) -> String { - unsafe_generation_fence_error(&format!("is missing at {}", generation_fence.display())) +fn missing_generation_fence_error(host: PluginHost, generation_fence: &Path) -> String { + unsafe_generation_fence_error( + host, + &format!("is missing at {}", generation_fence.display()), + ) } -fn invalid_generation_fence_error(generation_fence: &Path, cause: &str) -> String { - unsafe_generation_fence_error(&format!( - "at {} is invalid or unreadable: {cause}", - generation_fence.display() - )) +fn invalid_generation_fence_error( + host: PluginHost, + generation_fence: &Path, + cause: &str, +) -> String { + unsafe_generation_fence_error( + host, + &format!( + "at {} is invalid or unreadable: {cause}", + generation_fence.display() + ), + ) } -fn unsafe_generation_fence_error(problem: &str) -> String { - format!( - "cannot safely replace or uninstall an existing Codex plugin because its MCP generation marker {problem}; close all Codex clients and standalone `nemo-relay mcp` processes, run `codex plugin remove nemo-relay-plugin@nemo-relay-local` and `codex plugin marketplace remove nemo-relay-local`, remove the stale marketplace and state from the selected install directory, then run `nemo-relay install codex --force` to create a fenced install (and `nemo-relay uninstall codex` afterward if removal was intended)" - ) +fn unsafe_generation_fence_error(host: PluginHost, problem: &str) -> String { + match host { + PluginHost::Codex => format!( + "cannot safely replace or uninstall an existing Codex plugin because its MCP generation marker {problem}; close all Codex clients and standalone `nemo-relay mcp` processes, run `codex plugin remove nemo-relay-plugin@nemo-relay-local` and `codex plugin marketplace remove nemo-relay-local`, remove the stale marketplace and state from the selected install directory, then run `nemo-relay install codex --force` to create a fenced install (and `nemo-relay uninstall codex` afterward if removal was intended)" + ), + PluginHost::ClaudeCode => format!( + "cannot safely replace or uninstall an existing Claude Code plugin because its MCP generation marker {problem}; close all Claude Code clients and standalone `nemo-relay mcp` processes, run `claude plugin uninstall nemo-relay-plugin` and `claude plugin marketplace remove nemo-relay-local`, remove the stale marketplace and state from the selected install directory, then run `nemo-relay install claude-code --force` to create a fenced install (and `nemo-relay uninstall claude-code` afterward if removal was intended)" + ), + PluginHost::All => unreachable!("all is expanded before generation validation"), + } +} + +fn legacy_plugin_without_mcp(host: PluginHost, plugin_root: &Path) -> Result { + if !matches!(host, PluginHost::ClaudeCode) || plugin_root.join(".mcp.json").exists() { + return Ok(false); + } + let manifest_path = plugin_manifest_path(host, plugin_root); + let raw = match fs::read_to_string(&manifest_path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "failed to inspect legacy plugin manifest {}: {error}", + manifest_path.display() + )); + } + }; + let manifest = serde_json::from_str::(&raw).map_err(|error| { + format!( + "failed to inspect legacy plugin manifest {}: {error}", + manifest_path.display() + ) + })?; + Ok(manifest.get("mcpServers").is_none()) } fn uninstall_host_with_setup_override( @@ -917,7 +960,7 @@ fn collect_host_plugin_readiness( ), ); } - if matches!(host, PluginHost::Codex) { + if plugin_uses_mcp(host) { readiness.push( "Relay MCP support", validate_relay_mcp(&relay, options, runner) @@ -932,10 +975,7 @@ fn collect_host_plugin_readiness( .map(|_| format!("valid generation at {}", generation_fence.display())), ); let check = plugin_mcp_config(host, &relay, &generation_fence) - .and_then(|expected| { - expected.ok_or_else(|| "Codex MCP configuration was not generated".into()) - }) - .and_then(|expected| generated_mcp_config_check(&mcp_config, &expected)); + .and_then(|expected| generated_mcp_config_check(host, &mcp_config, &expected)); readiness.push("Generated MCP server", check); } } @@ -1050,7 +1090,11 @@ fn generated_manifest_check(path: &Path, expected: &Value, label: &str) -> Resul } } -fn generated_mcp_config_check(path: &Path, expected: &Value) -> Result { +fn generated_mcp_config_check( + host: PluginHost, + path: &Path, + expected: &Value, +) -> Result { let raw = std::fs::read_to_string(path).map_err(|error| { format!( "missing or unreadable MCP server manifest {}: {error}", @@ -1062,13 +1106,23 @@ fn generated_mcp_config_check(path: &Path, expected: &Value) -> Result &expected["nemo-relay"], + PluginHost::ClaudeCode => &expected["mcpServers"]["nemo-relay"], + PluginHost::All => unreachable!("all is expanded before MCP validation"), + }; + let actual_server = match host { + PluginHost::Codex => &actual["nemo-relay"], + PluginHost::ClaudeCode => &actual["mcpServers"]["nemo-relay"], + PluginHost::All => unreachable!("all is expanded before MCP validation"), + }; + let expected_vars = expected_server["env_vars"] .as_array() .into_iter() .flatten() .filter_map(Value::as_str) .collect::>(); - let actual_vars = actual["nemo-relay"]["env_vars"] + let actual_vars = actual_server["env_vars"] .as_array() .into_iter() .flatten() @@ -1080,9 +1134,10 @@ fn generated_mcp_config_check(path: &Path, expected: &Value) -> Result>(); if !missing.is_empty() { return Err(format!( - "MCP server at {} is missing forwarded environment variables: {}; run `nemo-relay install codex --force`", + "MCP server at {} is missing forwarded environment variables: {}; run `nemo-relay install {} --force`", path.display(), - missing.join(", ") + missing.join(", "), + host_arg(host) )); } Err(format!( @@ -1135,7 +1190,7 @@ impl StagedPluginMarketplace { } } -struct CodexInstallPreflight { +struct PluginInstallPreflight { persisted: Option, state_bytes: Option>, previous_marketplace_root: PathBuf, @@ -1148,13 +1203,14 @@ struct CodexInstallPreflight { generation_retirement: Option, } -fn prepare_codex_install( +fn prepare_plugin_install( + host: PluginHost, layout: &PluginLayout, options: &PluginInstallOptions, runner: &dyn CommandRunner, -) -> Result { - let persisted = read_state(PluginHost::Codex, &options.install_dir); - let registration = host_registration_report(PluginHost::Codex, options, runner)?; +) -> Result { + let persisted = read_state(host, &options.install_dir); + let registration = host_registration_report(host, options, runner)?; let plugin_registered = registration.host_plugin_registered; let marketplace_registered = registration.host_marketplace_registered; let state_bytes = match fs::read(&layout.state_path) { @@ -1180,25 +1236,44 @@ fn prepare_codex_install( .map(|state| state.plugin_root.clone()) .unwrap_or_else(|| layout.plugin_root.clone()); let previous_generation_fence = previous_plugin_root.join(GENERATION_FILE_NAME); + let local_install_exists = match host { + PluginHost::Codex => layout.marketplace_root.exists(), + PluginHost::ClaudeCode => { + plugin_manifest_path(host, &previous_plugin_root).exists() + || previous_plugin_root.join(".mcp.json").exists() + || previous_generation_fence.exists() + } + PluginHost::All => unreachable!("all is expanded before install preflight"), + }; let previous_install_exists = state_bytes.is_some() - || layout.marketplace_root.exists() + || local_install_exists || plugin_registered || marketplace_registered; let generation_retirement = if previous_install_exists { if !previous_generation_fence.exists() { - return Err(missing_generation_fence_error(&previous_generation_fence)); + if legacy_plugin_without_mcp(host, &previous_plugin_root)? { + None + } else { + return Err(missing_generation_fence_error( + host, + &previous_generation_fence, + )); + } + } else { + Some( + GenerationRetirement::acquire(&previous_generation_fence) + .map_err(|cause| { + invalid_generation_fence_error(host, &previous_generation_fence, &cause) + })? + .ok_or_else(|| { + missing_generation_fence_error(host, &previous_generation_fence) + })?, + ) } - Some( - GenerationRetirement::acquire(&previous_generation_fence) - .map_err(|cause| { - invalid_generation_fence_error(&previous_generation_fence, &cause) - })? - .ok_or_else(|| missing_generation_fence_error(&previous_generation_fence))?, - ) } else { None }; - Ok(CodexInstallPreflight { + Ok(PluginInstallPreflight { persisted, state_bytes, previous_marketplace_root, @@ -1288,12 +1363,12 @@ fn stage_plugin_marketplace( fn begin_force_replacement( host: PluginHost, layout: &PluginLayout, - preflight: CodexInstallPreflight, + preflight: PluginInstallPreflight, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, ) -> Result { - let CodexInstallPreflight { + let PluginInstallPreflight { persisted, state_bytes, previous_marketplace_root, diff --git a/crates/cli/src/plugin_install/setup.rs b/crates/cli/src/plugin_install/setup.rs index 2c4b3e985..c988d0ea0 100644 --- a/crates/cli/src/plugin_install/setup.rs +++ b/crates/cli/src/plugin_install/setup.rs @@ -112,6 +112,7 @@ pub(super) struct RealPluginSetupRunner; pub(super) enum PluginSetupSnapshot { Codex(plugin_shim::CodexSetupSnapshot), + Claude(plugin_shim::ClaudeSetupSnapshot), #[cfg(test)] Mock, } @@ -122,7 +123,9 @@ impl PluginSetupRunner for RealPluginSetupRunner { PluginHost::Codex => plugin_shim::snapshot_codex_setup() .map(PluginSetupSnapshot::Codex) .map(Some), - PluginHost::ClaudeCode => Ok(None), + PluginHost::ClaudeCode => plugin_shim::snapshot_claude_setup() + .map(PluginSetupSnapshot::Claude) + .map(Some), PluginHost::All => unreachable!("all is expanded before plugin setup"), } } @@ -130,6 +133,7 @@ impl PluginSetupRunner for RealPluginSetupRunner { fn restore_snapshot(&self, snapshot: &PluginSetupSnapshot) -> Result<(), String> { match snapshot { PluginSetupSnapshot::Codex(snapshot) => plugin_shim::restore_codex_setup(snapshot), + PluginSetupSnapshot::Claude(snapshot) => plugin_shim::restore_claude_setup(snapshot), #[cfg(test)] PluginSetupSnapshot::Mock => Ok(()), } @@ -137,8 +141,8 @@ impl PluginSetupRunner for RealPluginSetupRunner { fn refresh_gateway(&self, host: PluginHost) -> Result<(), String> { match host { - PluginHost::Codex => plugin_shim::stop_codex_gateway(), - PluginHost::ClaudeCode => Ok(()), + PluginHost::Codex => plugin_shim::stop_plugin_gateway(CodingAgent::Codex), + PluginHost::ClaudeCode => plugin_shim::stop_plugin_gateway(CodingAgent::ClaudeCode), PluginHost::All => unreachable!("all is expanded before plugin setup"), } } diff --git a/crates/cli/src/plugin_shim/claude.rs b/crates/cli/src/plugin_shim/claude.rs index 24751fadb..f18da40bc 100644 --- a/crates/cli/src/plugin_shim/claude.rs +++ b/crates/cli/src/plugin_shim/claude.rs @@ -11,10 +11,36 @@ use serde_json::{Value, json}; use super::command::PluginShimProviderAction; use super::shared::{ - backup, backup_path, home_dir, read_json_object, remove_backup, restore_file_snapshot, - snapshot_optional_file, write_json, + FileSnapshot, backup, backup_path, home_dir, read_json_object, remove_backup, + restore_file_snapshot, snapshot_optional_file, write_json, }; +pub(crate) struct ClaudeSetupSnapshot { + files: Vec, +} + +pub(crate) fn snapshot_claude_setup() -> Result { + let settings = claude_settings_path()?; + let files = [settings.clone(), backup_path(&settings)] + .iter() + .map(|path| snapshot_optional_file(path)) + .collect::, _>>()?; + Ok(ClaudeSetupSnapshot { files }) +} + +pub(crate) fn restore_claude_setup(snapshot: &ClaudeSetupSnapshot) -> Result<(), String> { + let errors = snapshot + .files + .iter() + .filter_map(|file| restore_file_snapshot(file).err()) + .collect::>(); + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + pub(super) fn claude_provider( action: PluginShimProviderAction, gateway_url: &str, diff --git a/crates/cli/src/plugin_shim/mod.rs b/crates/cli/src/plugin_shim/mod.rs index 5b1ade0d8..c3d9a15b8 100644 --- a/crates/cli/src/plugin_shim/mod.rs +++ b/crates/cli/src/plugin_shim/mod.rs @@ -9,6 +9,7 @@ mod codex_app_server; mod command; mod shared; +pub(crate) use claude::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; pub(crate) use codex::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; pub(crate) use shared::portable_executable_path; #[cfg(test)] @@ -71,15 +72,16 @@ fn serve(args: Vec) -> Result { fn hook(agent: CodingAgent, explicit_gateway_url: Option<&str>) -> Result { let url = gateway_url(agent, explicit_gateway_url); - let codex_launch = if matches!(agent, CodingAgent::Codex) { - Some(crate::sidecar::loopback_bind(&url).and_then(|bind| { - crate::sidecar::resolve_codex_gateway(&ServerArgs::default(), bind) - .map_err(|error| error.to_string()) - })) - } else { - None + let plugin_launch = match agent { + CodingAgent::Codex | CodingAgent::ClaudeCode => { + Some(crate::sidecar::loopback_bind(&url).and_then(|bind| { + crate::sidecar::resolve_plugin_gateway(agent, &ServerArgs::default(), bind) + .map_err(|error| error.to_string()) + })) + } + CodingAgent::Hermes => None, }; - let max_hook_payload_bytes = codex_launch + let max_hook_payload_bytes = plugin_launch .as_ref() .and_then(|launch| launch.as_ref().ok()) .map_or(DEFAULT_HOOK_STDIN_BYTES, |launch| { @@ -96,7 +98,7 @@ fn hook(agent: CodingAgent, explicit_gateway_url: Option<&str>) -> Result launch.gateway.ensure().map(|_| ()), Some(Err(error)) => Err(error.clone()), None => crate::sidecar::loopback_bind(url) @@ -243,8 +245,8 @@ pub(crate) fn install_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Res install_codex(gateway_url, &plugin_root.join("hooks").join("hooks.json")).map(|_| ()) } -pub(crate) fn stop_codex_gateway() -> Result<(), String> { - crate::sidecar::stop_owned_sidecar(CodingAgent::Codex) +pub(crate) fn stop_plugin_gateway(agent: CodingAgent) -> Result<(), String> { + crate::sidecar::stop_owned_sidecar(agent) } pub(crate) fn codex_plugin_hook_command(relay: &std::path::Path) -> String { @@ -365,7 +367,7 @@ fn doctor_ok( } else { print_info( "sidecar health", - "not running; hooks start it lazily on first use", + "not running; the plugin MCP or first hook starts it lazily", ); } match agent { diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs index 8e953117a..ea26f48ed 100644 --- a/crates/cli/src/sidecar.rs +++ b/crates/cli/src/sidecar.rs @@ -93,6 +93,7 @@ pub(crate) struct GatewaySpec { bind: SocketAddr, sidecar_args: Vec, bootstrap_fingerprint: Option, + user_config_scope: bool, } impl GatewaySpec { @@ -102,6 +103,7 @@ impl GatewaySpec { bind, sidecar_args: Vec::new(), bootstrap_fingerprint: None, + user_config_scope: false, } } @@ -115,6 +117,11 @@ impl GatewaySpec { self } + pub(crate) fn with_user_config_scope(mut self) -> Self { + self.user_config_scope = true; + self + } + pub(crate) fn bind(&self) -> SocketAddr { self.bind } @@ -128,16 +135,17 @@ impl GatewaySpec { } } -/// Persistent Codex gateway settings shared by MCP bootstrap and hook recovery. -pub(crate) struct CodexGatewaySpec { +/// Persistent plugin gateway settings shared by MCP bootstrap and hook recovery. +pub(crate) struct PluginGatewaySpec { pub(crate) gateway: GatewaySpec, pub(crate) max_hook_payload_bytes: usize, } -pub(crate) fn resolve_codex_gateway( +pub(crate) fn resolve_plugin_gateway( + agent: CodingAgent, server_args: &ServerArgs, bind: SocketAddr, -) -> Result { +) -> Result { let mut persistent_args = server_args.clone(); persistent_args.bind = Some(bind); let resolved = resolve_persistent_server_config(&persistent_args)?; @@ -160,10 +168,11 @@ pub(crate) fn resolve_codex_gateway( .into_iter() .flat_map(|(flag, value)| [OsString::from(flag), OsString::from(value)]) .collect(); - Ok(CodexGatewaySpec { - gateway: GatewaySpec::new(CodingAgent::Codex, bind) + Ok(PluginGatewaySpec { + gateway: GatewaySpec::new(agent, bind) .with_launch_args(sidecar_args) - .with_fingerprint(bootstrap_fingerprint), + .with_fingerprint(bootstrap_fingerprint) + .with_user_config_scope(), max_hook_payload_bytes, }) } @@ -181,7 +190,6 @@ pub(crate) fn ensure_sidecar_bind( fn ensure_gateway(spec: &GatewaySpec) -> Result { let agent = spec.agent; let bind = spec.bind; - let sidecar_args = &spec.sidecar_args; let bootstrap_fingerprint = spec.bootstrap_fingerprint.as_deref(); if !bind.ip().is_loopback() { return Err(format!( @@ -208,16 +216,8 @@ fn ensure_gateway(spec: &GatewaySpec) -> Result { ) })?; if bind.port() == 0 { - return start_sidecar_bind( - agent, - bind, - &runtime, - &state, - sidecar_args, - bootstrap_fingerprint, - None, - ) - .map_err(|error| sidecar_start_error(agent, &url, &runtime, &error)); + return start_sidecar_bind(spec, &runtime, &state, None) + .map_err(|error| sidecar_start_error(agent, &url, &runtime, &error)); } let lock_path = sidecar_lock_path(&state, &url); let initial_health = probe_relay_health(&url, bootstrap_fingerprint); @@ -285,19 +285,11 @@ fn ensure_gateway(spec: &GatewaySpec) -> Result { started: false, }); } - RelayHealth::Incompatible => return Err(incompatible_relay_error(&url)), + RelayHealth::Incompatible => return Err(incompatible_relay_error(agent, &url)), RelayHealth::Foreign => return Err(foreign_listener_error(&url)), RelayHealth::Unavailable => {} } - let result = start_sidecar_bind( - agent, - bind, - &runtime, - &state, - sidecar_args, - bootstrap_fingerprint, - Some(lock), - ); + let result = start_sidecar_bind(spec, &runtime, &state, Some(lock)); result.map_err(|error| sidecar_start_error(agent, &url, &runtime, &error)) } @@ -307,9 +299,14 @@ fn foreign_listener_error(url: &str) -> String { ) } -fn incompatible_relay_error(url: &str) -> String { +fn incompatible_relay_error(agent: CodingAgent, url: &str) -> String { + let remediation = match agent { + CodingAgent::Codex => "run `nemo-relay install codex --force`", + CodingAgent::ClaudeCode => "run `nemo-relay install claude-code --force`", + CodingAgent::Hermes => "restart the configured Relay gateway", + }; format!( - "{url} is occupied by NeMo Relay with a different version or persistent configuration; stop it, wait for its idle shutdown, or run `nemo-relay install codex --force` before retrying" + "{url} is occupied by NeMo Relay with a different version or persistent configuration; stop it, wait for its idle shutdown, or {remediation} before retrying" ) } @@ -326,16 +323,8 @@ fn sidecar_start_error(agent: CodingAgent, url: &str, runtime: &Path, error: &st #[cfg(all(test, unix))] pub(super) fn start_sidecar(agent: CodingAgent, url: &str, runtime: &Path) -> Result<(), String> { - start_sidecar_bind( - agent, - loopback_bind(url)?, - runtime, - runtime, - &[], - None, - None, - ) - .map(|_| ()) + let spec = GatewaySpec::new(agent, loopback_bind(url)?); + start_sidecar_bind(&spec, runtime, runtime, None).map(|_| ()) } struct ArmedSidecarChild { @@ -415,14 +404,15 @@ fn cleanup_sidecar_records_for_pid(runtime: &Path, agent: CodingAgent, pid: u32) } fn start_sidecar_bind( - agent: CodingAgent, - bind: SocketAddr, + spec: &GatewaySpec, runtime: &Path, state: &Path, - sidecar_args: &[OsString], - bootstrap_fingerprint: Option<&str>, mut startup_lock: Option, ) -> Result { + let agent = spec.agent; + let bind = spec.bind; + let sidecar_args = &spec.sidecar_args; + let bootstrap_fingerprint = spec.bootstrap_fingerprint.as_deref(); let requested_url = format!("http://{bind}"); if bind.port() != 0 { match probe_relay_health(&requested_url, bootstrap_fingerprint) { @@ -436,7 +426,7 @@ fn start_sidecar_bind( }); } RelayHealth::Incompatible => { - return Err(incompatible_relay_error(&requested_url)); + return Err(incompatible_relay_error(agent, &requested_url)); } RelayHealth::Foreign => return Err(foreign_listener_error(&requested_url)), RelayHealth::Unavailable => {} @@ -486,12 +476,12 @@ fn start_sidecar_bind( .stderr(Stdio::from(err_log)); #[cfg(windows)] sidecar_job.configure_child(&mut command); - if matches!(agent, CodingAgent::Codex) { + if spec.user_config_scope { command.env("NEMO_RELAY_CONFIG_SCOPE", "user"); if let Some(config_dir) = crate::config::user_config_dir() { fs::create_dir_all(&config_dir).map_err(|error| { format!( - "failed to create Codex sidecar working directory {}: {error}", + "failed to create plugin sidecar working directory {}: {error}", config_dir.display() ) })?; diff --git a/crates/cli/src/sidecar/state.rs b/crates/cli/src/sidecar/state.rs index 0edb27200..4caecdc45 100644 --- a/crates/cli/src/sidecar/state.rs +++ b/crates/cli/src/sidecar/state.rs @@ -222,7 +222,7 @@ pub(crate) fn publish_owner_from_env(address: SocketAddr) -> Result<(), String> } let agent = match agent.as_deref() { Some("codex") => CodingAgent::Codex, - Some("claude-code") => CodingAgent::ClaudeCode, + Some("claude" | "claude-code") => CodingAgent::ClaudeCode, Some(other) => return Err(format!("unsupported bootstrap agent {other}")), None => { return Err(format!( diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 575c1c1b3..f0b60e9f1 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -250,16 +250,25 @@ fn cli_mcp_does_not_launch_gateway_when_stdio_closes_before_request() { } fn start_mcp_client(temp: &std::path::Path, bind: SocketAddr) -> (Child, ChildStdin) { - start_mcp_client_with_idle_timeout(temp, bind, "1") + start_mcp_client_for_agent(temp, bind, "codex") +} + +fn start_mcp_client_for_agent( + temp: &std::path::Path, + bind: SocketAddr, + agent: &str, +) -> (Child, ChildStdin) { + start_mcp_client_with_idle_timeout(temp, bind, agent, "1") } fn start_mcp_client_with_idle_timeout( temp: &std::path::Path, bind: SocketAddr, + agent: &str, idle_timeout_secs: &str, ) -> (Child, ChildStdin) { let mut child = Command::new(gateway_bin()) - .args(["--bind", &bind.to_string(), "mcp"]) + .args(["--bind", &bind.to_string(), "mcp", "--agent", agent]) .env("HOME", temp) .env("XDG_CONFIG_HOME", temp.join("xdg")) .env("XDG_RUNTIME_DIR", temp.join("runtime")) @@ -299,50 +308,47 @@ fn start_mcp_client_with_idle_timeout( } #[test] -fn cli_codex_hook_cold_recovery_uses_the_mcp_persistent_identity() { - let temp = tempfile::tempdir().unwrap(); - let probe = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = probe.local_addr().unwrap(); - drop(probe); - let gateway_url = format!("http://{address}"); - let mut hook = Command::new(gateway_bin()) - .args([ - "plugin-shim", - "hook", - "codex", - "--gateway-url", - &gateway_url, - ]) - .env("HOME", temp.path()) - .env("XDG_CONFIG_HOME", temp.path().join("xdg")) - .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) - .env("TMPDIR", temp.path()) - .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "10") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - hook.stdin - .take() - .unwrap() - .write_all(b"{\"session_id\":\"cold-hook\",\"hook_event_name\":\"SessionStart\"}") - .unwrap(); - let hook_output = wait_child_with_output(hook); - assert!( - hook_output.status.success(), - "cold hook recovery failed: {}", - String::from_utf8_lossy(&hook_output.stderr) - ); +fn cli_hooks_and_mcp_share_the_same_persistent_identity_for_each_host() { + for agent in ["codex", "claude"] { + let temp = tempfile::tempdir().unwrap(); + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = probe.local_addr().unwrap(); + drop(probe); + let gateway_url = format!("http://{address}"); + let mut hook = Command::new(gateway_bin()) + .args(["plugin-shim", "hook", agent, "--gateway-url", &gateway_url]) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "10") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + hook.stdin + .take() + .unwrap() + .write_all(b"{\"session_id\":\"cold-hook\",\"hook_event_name\":\"SessionStart\"}") + .unwrap(); + let hook_output = wait_child_with_output(hook); + assert!( + hook_output.status.success(), + "{agent} cold hook recovery failed: {}", + String::from_utf8_lossy(&hook_output.stderr) + ); - let (mut mcp, mcp_stdin) = start_mcp_client_with_idle_timeout(temp.path(), address, "10"); - drop(mcp_stdin); - assert!(wait_child(&mut mcp).success()); + let (mut mcp, mcp_stdin) = + start_mcp_client_with_idle_timeout(temp.path(), address, agent, "10"); + drop(mcp_stdin); + assert!(wait_child(&mut mcp).success()); - let owner = wait_for_owned_sidecar(temp.path(), None); - assert_eq!(owner["url"], gateway_url); - stop_owned_sidecar(&owner); - wait_for_port_closed(address); + let owner = wait_for_owned_sidecar(temp.path(), agent, None); + assert_eq!(owner["url"], gateway_url); + stop_owned_sidecar(&owner); + wait_for_port_closed(address); + } } #[derive(Clone, Copy)] @@ -578,10 +584,10 @@ fn cli_codex_hook_launch_resolution_error_retains_default_payload_cap() { } } -fn sidecar_address(temp: &std::path::Path) -> SocketAddr { +fn sidecar_address(temp: &std::path::Path, agent: &str) -> SocketAddr { let deadline = Instant::now() + Duration::from_secs(5); loop { - let log_path = find_runtime_file(temp, "codex-sidecar.log"); + let log_path = find_runtime_file(temp, &format!("{agent}-sidecar.log")); if let Some(log_path) = log_path.as_ref() && let Ok(log) = std::fs::read_to_string(log_path) && let Some(address) = log.lines().find_map(|line| { @@ -700,10 +706,14 @@ fn wait_for_port_closed(address: SocketAddr) { } } -fn wait_for_owned_sidecar(temp: &std::path::Path, previous_pid: Option) -> serde_json::Value { +fn wait_for_owned_sidecar( + temp: &std::path::Path, + agent: &str, + previous_pid: Option, +) -> serde_json::Value { let deadline = Instant::now() + Duration::from_secs(10); loop { - for path in find_runtime_files_matching(temp, "codex-sidecar", ".owner.json") { + for path in find_runtime_files_matching(temp, &format!("{agent}-sidecar"), ".owner.json") { if let Ok(raw) = std::fs::read(path) && let Ok(owner) = serde_json::from_slice::(&raw) && owner["pid"] @@ -715,7 +725,7 @@ fn wait_for_owned_sidecar(temp: &std::path::Path, previous_pid: Option) -> } assert!( Instant::now() < deadline, - "owned Codex sidecar was not published under {}", + "owned {agent} sidecar was not published under {}", temp.display() ); thread::sleep(Duration::from_millis(20)); @@ -769,32 +779,40 @@ fn relay_health(address: SocketAddr) -> serde_json::Value { #[test] fn cli_mcp_clients_share_gateway_until_final_idle_shutdown() { - let temp = tempfile::tempdir().unwrap(); - let (mut first, first_stdin) = start_mcp_client(temp.path(), "127.0.0.1:0".parse().unwrap()); - let address = sidecar_address(temp.path()); - let (mut second, second_stdin) = start_mcp_client(temp.path(), address); - - drop(first_stdin); - assert!(wait_child(&mut first).success()); - let health = relay_health(address); - assert_eq!(health["service"], "nemo-relay"); - assert_eq!(health["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(health["bootstrap_protocol"], 1); + for (first_agent, second_agent) in [("codex", "claude"), ("claude", "codex")] { + let temp = tempfile::tempdir().unwrap(); + let (mut first, first_stdin) = + start_mcp_client_for_agent(temp.path(), "127.0.0.1:0".parse().unwrap(), first_agent); + let address = sidecar_address(temp.path(), first_agent); + let (mut second, second_stdin) = + start_mcp_client_for_agent(temp.path(), address, second_agent); + + drop(first_stdin); + assert!(wait_child(&mut first).success()); + let health = relay_health(address); + assert_eq!(health["service"], "nemo-relay"); + assert_eq!(health["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(health["bootstrap_protocol"], 1); + assert!( + find_runtime_file(temp.path(), &format!("{second_agent}-sidecar.log")).is_none(), + "the second MCP client should adopt the first host's gateway" + ); - drop(second_stdin); - assert!(wait_child(&mut second).success()); - wait_for_port_closed(address); + drop(second_stdin); + assert!(wait_child(&mut second).success()); + wait_for_port_closed(address); + } } #[test] fn cli_mcp_restarts_one_stopped_gateway_then_fails_after_the_second_stop() { let temp = tempfile::tempdir().unwrap(); let (mut client, _stdin) = start_mcp_client(temp.path(), "127.0.0.1:0".parse().unwrap()); - let first = wait_for_owned_sidecar(temp.path(), None); + let first = wait_for_owned_sidecar(temp.path(), "codex", None); let first_pid = first["pid"].as_u64().unwrap(); stop_owned_sidecar(&first); - let second = wait_for_owned_sidecar(temp.path(), Some(first_pid)); + let second = wait_for_owned_sidecar(temp.path(), "codex", Some(first_pid)); assert_ne!(second["pid"], first["pid"]); stop_owned_sidecar(&second); diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index d56bfdc84..fce617cfe 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -1672,7 +1672,7 @@ fn managed_bootstrap_canonicalizes_unset_and_zero_padded_default_idle_timeout() } #[test] -fn codex_launch_carries_effective_hook_limit_below_and_above_default() { +fn plugin_launch_carries_effective_hook_limit_below_and_above_default() { let temp = tempfile::tempdir().unwrap(); let xdg = temp.path().join("xdg"); let user_config = xdg.join("nemo-relay/config.toml"); @@ -1686,8 +1686,12 @@ fn codex_launch_carries_effective_hook_limit_below_and_above_default() { format!("[gateway]\nmax_hook_payload_bytes = {limit}\n"), ) .unwrap(); - let launch = crate::sidecar::resolve_codex_gateway(&ServerArgs::default(), bind).unwrap(); - assert_eq!(launch.max_hook_payload_bytes, limit); + for agent in [CodingAgent::Codex, CodingAgent::ClaudeCode] { + let launch = + crate::sidecar::resolve_plugin_gateway(agent, &ServerArgs::default(), bind) + .unwrap(); + assert_eq!(launch.max_hook_payload_bytes, limit); + } } } diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs index 45189645d..eabc1db49 100644 --- a/crates/cli/tests/coverage/installer_tests.rs +++ b/crates/cli/tests/coverage/installer_tests.rs @@ -183,6 +183,8 @@ fn packaged_hook_configs_are_valid_json() { root.join("../../.claude-plugin/marketplace.json"), root.join("claude-code/hooks/hooks.json"), root.join("codex/hooks/hooks.json"), + root.join("claude-code/.mcp.json"), + root.join("codex/.mcp.json"), root.join("claude-code/.claude-plugin/plugin.json"), root.join("codex/.codex-plugin/plugin.json"), ] { @@ -260,6 +262,7 @@ fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { assert_eq!(claude["name"], json!("nemo-relay-plugin")); assert_eq!(claude["version"], json!(env!("CARGO_PKG_VERSION"))); assert!(claude.get("hooks").is_none()); + assert_eq!(claude["mcpServers"], json!("./.mcp.json")); let codex_path = root.join("codex/.codex-plugin/plugin.json"); let codex = @@ -274,7 +277,7 @@ fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { serde_json::from_str::(&std::fs::read_to_string(&codex_mcp_path).unwrap()).unwrap(); let server = &codex_mcp["nemo-relay"]; assert_eq!(server["command"], json!("nemo-relay")); - assert_eq!(server["args"], json!(["mcp"])); + assert_eq!(server["args"], json!(["mcp", "--agent", "codex"])); assert_eq!( server["env"], json!({"NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632"}) @@ -285,6 +288,17 @@ fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { assert!(env_vars.contains(&json!("OPENAI_API_KEY"))); assert!(env_vars.contains(&json!("XDG_CONFIG_HOME"))); + let claude_mcp_path = root.join("claude-code/.mcp.json"); + let claude_mcp = + serde_json::from_str::(&std::fs::read_to_string(&claude_mcp_path).unwrap()).unwrap(); + let claude_server = &claude_mcp["mcpServers"]["nemo-relay"]; + assert_eq!(claude_server["command"], json!("nemo-relay")); + assert_eq!(claude_server["args"], json!(["mcp", "--agent", "claude"])); + assert_eq!( + claude_server["env"], + json!({"NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632"}) + ); + let codex_marketplace_path = root.join("../../.agents/plugins/marketplace.json"); let codex_marketplace = serde_json::from_str::(&std::fs::read_to_string(&codex_marketplace_path).unwrap()) @@ -321,6 +335,7 @@ fn packaged_plugin_helpers_are_present() { for path in [ root.join("claude-code/hooks/hooks.json"), root.join("codex/hooks/hooks.json"), + root.join("claude-code/.mcp.json"), root.join("codex/.mcp.json"), ] { let metadata = std::fs::metadata(&path) diff --git a/crates/cli/tests/coverage/main_tests.rs b/crates/cli/tests/coverage/main_tests.rs index 134f586ef..f30dede4c 100644 --- a/crates/cli/tests/coverage/main_tests.rs +++ b/crates/cli/tests/coverage/main_tests.rs @@ -6,7 +6,7 @@ use std::ffi::OsString; use super::*; use crate::config::{ - CompletionsCommand, PluginsCommand, PluginsEditCommand, PluginsInspectCommand, + CompletionsCommand, McpAgent, PluginsCommand, PluginsEditCommand, PluginsInspectCommand, PluginsListCommand, PluginsSubcommand, PluginsValidateCommand, PricingSubcommand, PricingValidateCommand, ServerArgs, }; @@ -86,12 +86,23 @@ fn completions_helper_reports_missing_shell_and_generates_requested_shell() { #[test] fn cli_parses_native_mcp_subcommand_and_bind_override() { let cli = Cli::try_parse_from(["nemo-relay", "mcp"]).unwrap(); - assert!(matches!(cli.command, Some(Command::Mcp))); + assert!(matches!( + cli.command, + Some(Command::Mcp(command)) if command.agent == McpAgent::Codex + )); assert!(cli.server.bind.is_none()); let cli = Cli::try_parse_from(["nemo-relay", "--bind", "127.0.0.1:4041", "mcp"]).unwrap(); - assert!(matches!(cli.command, Some(Command::Mcp))); + assert!(matches!(cli.command, Some(Command::Mcp(_)))); assert_eq!(cli.server.bind.unwrap().to_string(), "127.0.0.1:4041"); + + let cli = Cli::try_parse_from(["nemo-relay", "mcp", "--agent", "claude"]).unwrap(); + assert!(matches!( + cli.command, + Some(Command::Mcp(command)) if command.agent == McpAgent::ClaudeCode + )); + + assert!(Cli::try_parse_from(["nemo-relay", "mcp", "--agent", "hermes"]).is_err()); } #[test] diff --git a/crates/cli/tests/coverage/mcp_tests.rs b/crates/cli/tests/coverage/mcp_tests.rs index 01d6fb364..9cf0fa50b 100644 --- a/crates/cli/tests/coverage/mcp_tests.rs +++ b/crates/cli/tests/coverage/mcp_tests.rs @@ -93,7 +93,7 @@ fn initialize_reports_native_server_and_supported_protocol() { response["result"]["protocolVersion"], json!(MCP_PROTOCOL_VERSION) ); - assert_eq!(response["result"]["capabilities"], json!({ "tools": {} })); + assert_eq!(response["result"]["capabilities"], json!({})); assert_eq!( response["result"]["serverInfo"]["name"], json!("nemo-relay") diff --git a/crates/cli/tests/coverage/plugin_install_setup_tests.rs b/crates/cli/tests/coverage/plugin_install_setup_tests.rs index 9381b3c66..a92be7cce 100644 --- a/crates/cli/tests/coverage/plugin_install_setup_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_setup_tests.rs @@ -54,15 +54,6 @@ fn setup_runner_defaults_are_explicit_no_ops() { runner.refresh_gateway(PluginHost::Codex).unwrap(); } -#[test] -fn real_runner_has_no_claude_snapshot_or_gateway_to_restore() { - let runner = RealPluginSetupRunner; - - assert!(runner.snapshot(PluginHost::ClaudeCode).unwrap().is_none()); - runner.restore_snapshot(&PluginSetupSnapshot::Mock).unwrap(); - runner.refresh_gateway(PluginHost::ClaudeCode).unwrap(); -} - #[test] fn setup_descriptions_reject_unexpanded_hosts_and_unknown_actions() { assert!( diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index 463a9af65..4e693505a 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -401,6 +401,28 @@ impl MockRunner { self } + fn with_claude_registration(mut self, plugin: bool, marketplace: bool) -> Self { + let plugins = if plugin { + json!([{ "id": "nemo-relay-plugin@nemo-relay-local" }]) + } else { + json!([]) + }; + let marketplaces = if marketplace { + json!([{ "name": "nemo-relay-local" }]) + } else { + json!([]) + }; + self.capture_outputs.insert( + "/bin/claude plugin list --json".into(), + CommandOutput::success(plugins.to_string()), + ); + self.capture_outputs.insert( + "/bin/claude plugin marketplace list --json".into(), + CommandOutput::success(marketplaces.to_string()), + ); + self + } + fn with_codex_registration_sequence(mut self, states: &[(bool, bool)]) -> Self { let plugin_outputs = states .iter() @@ -527,6 +549,10 @@ impl CommandRunner for MockRunner { .unwrap_or_else(|| { if rendered.ends_with("codex --version") { CommandOutput::success("codex-cli 0.143.0\n".into()) + } else if rendered.ends_with("claude plugin list --json") + || rendered.ends_with("claude plugin marketplace list --json") + { + CommandOutput::success("[]\n".into()) } else { CommandOutput::success(String::new()) } @@ -1015,11 +1041,10 @@ fn plugin_manifests_and_hooks_use_path_based_relay_command() { Path::new("/bin/nemo-relay"), &generation_fence, ) - .unwrap() .unwrap(); let server = &mcp["nemo-relay"]; assert_eq!(server["command"], json!("/bin/nemo-relay")); - assert_eq!(server["args"], json!(["mcp"])); + assert_eq!(server["args"], json!(["mcp", "--agent", "codex"])); assert_eq!( server["env"], json!({ @@ -1035,14 +1060,17 @@ fn plugin_manifests_and_hooks_use_path_based_relay_command() { .unwrap() .contains(&json!("OPENAI_API_KEY")) ); - assert!( - plugin_mcp_config( - PluginHost::ClaudeCode, - Path::new("/bin/nemo-relay"), - &generation_fence, - ) - .unwrap() - .is_none() + let claude_mcp = plugin_mcp_config( + PluginHost::ClaudeCode, + Path::new("/bin/nemo-relay"), + &generation_fence, + ); + let claude_server = &claude_mcp.unwrap()["mcpServers"]["nemo-relay"]; + assert_eq!(claude_server["command"], json!("/bin/nemo-relay")); + assert_eq!(claude_server["args"], json!(["mcp", "--agent", "claude"])); + assert_eq!( + claude_server["env"]["NEMO_RELAY_MCP_GENERATION_FILE"], + json!(generation_fence) ); assert_eq!( plugin_hooks(PluginHost::Codex, Path::new("/bin/nemo-relay"))["hooks"]["SessionStart"][0]["hooks"] @@ -1078,7 +1106,6 @@ fn relay_identity_uses_running_executable_when_path_points_elsewhere() { &relay, Path::new("/plugins/nemo-relay-plugin/.nemo-relay-generation"), ) - .unwrap() .unwrap()["nemo-relay"]["command"], json!(relay) ); @@ -1723,7 +1750,6 @@ fn install_codex_generates_marketplace_and_runs_setup() { &layout.generation_fence, ) .unwrap() - .unwrap() ); assert_eq!( setup_runner.calls(), @@ -1960,6 +1986,157 @@ fn force_install_retires_previous_mcp_generation() { ); } +#[test] +fn claude_force_install_retires_and_replaces_its_mcp_generation() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_claude_registration(true, true); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(PluginHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + + install_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap(); + + assert!(previous.verify_current().unwrap_err().contains("retired")); + InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + let mcp = serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) + .unwrap(); + assert_eq!( + mcp["mcpServers"]["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION_FILE"], + json!(layout.generation_fence) + ); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == "refresh claude-code") + ); +} + +#[test] +fn claude_force_install_rollback_restores_generation_files_and_setup_snapshot() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_claude_registration(true, true); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("doctor claude-code {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + let options = PluginInstallOptions { + force: true, + skip_doctor: false, + ..options(dir.path()) + }; + write_installed_state(PluginHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + let sentinel = layout.plugin_root.join("previous-install"); + std::fs::write(&sentinel, "restore-exactly").unwrap(); + let original_state = std::fs::read(&layout.state_path).unwrap(); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + + let error = install_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("doctor claude-code"), "{error}"); + assert_eq!( + std::fs::read_to_string(&sentinel).unwrap(), + "restore-exactly" + ); + assert_eq!(std::fs::read(&layout.state_path).unwrap(), original_state); + previous.verify_current().unwrap(); + let setup_calls = setup_runner.calls(); + assert!( + setup_calls + .iter() + .any(|call| call == "snapshot claude-code") + ); + assert!(setup_calls.iter().any(|call| call == "restore snapshot")); + assert_eq!( + setup_calls + .iter() + .filter(|call| call.as_str() == "refresh claude-code") + .count(), + 2, + "the previous and replacement gateway generations must both be retired: {setup_calls:?}" + ); + assert_no_force_replacement_residue(dir.path()); +} + +#[test] +fn claude_force_install_migrates_a_legacy_hook_only_plugin() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_claude_registration(true, true); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + std::fs::create_dir_all(layout.plugin_manifest.parent().unwrap()).unwrap(); + write_json( + &layout.marketplace_manifest, + &marketplace_manifest(PluginHost::ClaudeCode), + ) + .unwrap(); + let mut legacy_manifest = plugin_manifest(PluginHost::ClaudeCode); + legacy_manifest + .as_object_mut() + .unwrap() + .remove("mcpServers"); + write_json(&layout.plugin_manifest, &legacy_manifest).unwrap(); + write_state(&layout, &options).unwrap(); + mark_plugin_setup_installed(PluginHost::ClaudeCode, &layout, &options).unwrap(); + + install_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap(); + + InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + assert!(layout.mcp_config.is_file()); + let installed = + serde_json::from_str::(&std::fs::read_to_string(&layout.plugin_manifest).unwrap()) + .unwrap(); + assert_eq!(installed["mcpServers"], json!("./.mcp.json")); +} + +#[test] +fn ordinary_claude_reinstall_requires_force_for_a_fenced_plugin() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_claude_registration(true, true); + let setup_runner = MockSetupRunner::default(); + write_installed_state(PluginHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + let original_generation = std::fs::read(&layout.generation_fence).unwrap(); + + let error = install_host( + PluginHost::ClaudeCode, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("nemo-relay install claude-code --force")); + assert_eq!( + std::fs::read(&layout.generation_fence).unwrap(), + original_generation + ); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); +} + #[test] fn force_install_replaces_a_relocated_fenced_install_without_old_residue() { let dir = tempdir().unwrap(); @@ -2438,7 +2615,10 @@ fn install_claude_enables_provider_routing() { "/bin/claude plugin install nemo-relay-plugin@nemo-relay-local --scope user".into(), ] ); - assert_eq!(runner.quiet_commands(), vec![relay_validation_command()]); + assert_eq!( + runner.quiet_commands(), + vec![relay_validation_command(), relay_mcp_validation_command()] + ); assert_eq!( setup_runner.calls(), vec![format!("setup claude-code {DEFAULT_GATEWAY_URL}")] @@ -3113,6 +3293,35 @@ fn readiness_report_rejects_missing_mcp_generation_fence() { })); } +#[test] +fn claude_readiness_requires_its_mcp_server_and_generation_fence() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_claude_registration(true, true); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(PluginHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + std::fs::remove_file(layout.mcp_config).unwrap(); + std::fs::remove_file(layout.generation_fence).unwrap(); + + let report = + collect_host_plugin_readiness(PluginHost::ClaudeCode, &options, &runner, &setup_runner); + + assert!(!report.ok()); + for name in ["Generated MCP server", "MCP generation fence"] { + assert!( + report + .checks + .iter() + .any(|check| check.name == name && !check.ok), + "missing failed readiness check for {name}" + ); + } +} + #[test] fn readiness_report_rejects_mcp_server_for_different_binary() { let dir = tempdir().unwrap(); @@ -3130,7 +3339,6 @@ fn readiness_report_rejects_mcp_server_for_different_binary() { Path::new("/tmp/other-relay"), &layout.generation_fence, ) - .unwrap() .unwrap(), ) .unwrap(); diff --git a/crates/cli/tests/coverage/plugin_shim_tests.rs b/crates/cli/tests/coverage/plugin_shim_tests.rs index 17744c373..70021246e 100644 --- a/crates/cli/tests/coverage/plugin_shim_tests.rs +++ b/crates/cli/tests/coverage/plugin_shim_tests.rs @@ -3943,6 +3943,36 @@ fn claude_provider_enable_status_and_restore_cover_managed_backup_paths() { assert!(!backup_path(&settings_path).exists()); } +#[test] +fn claude_setup_snapshot_restores_settings_and_backup_exactly() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = claude_settings_path().unwrap(); + let backup = backup_path(&settings); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + let original_settings = br#"{"env":{"ANTHROPIC_BASE_URL":"https://original"}}"#; + let original_backup = br#"{"env":{"ANTHROPIC_BASE_URL":"https://backup"}}"#; + fs::write(&settings, original_settings).unwrap(); + fs::write(&backup, original_backup).unwrap(); + let snapshot = snapshot_claude_setup().unwrap(); + + fs::write(&settings, b"replacement-settings").unwrap(); + fs::remove_file(&backup).unwrap(); + restore_claude_setup(&snapshot).unwrap(); + + assert_eq!(fs::read(settings).unwrap(), original_settings); + assert_eq!(fs::read(backup).unwrap(), original_backup); + + fs::remove_file(claude_settings_path().unwrap()).unwrap(); + fs::remove_file(backup_path(&claude_settings_path().unwrap())).unwrap(); + let absent = snapshot_claude_setup().unwrap(); + fs::write(claude_settings_path().unwrap(), b"created").unwrap(); + fs::write(backup_path(&claude_settings_path().unwrap()), b"created").unwrap(); + restore_claude_setup(&absent).unwrap(); + assert!(!claude_settings_path().unwrap().exists()); + assert!(!backup_path(&claude_settings_path().unwrap()).exists()); +} + #[test] fn claude_provider_restore_noops_without_matching_backup_or_managed_value() { let dir = tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/sidecar_state_tests.rs b/crates/cli/tests/coverage/sidecar_state_tests.rs index e6cbb48c1..65b232049 100644 --- a/crates/cli/tests/coverage/sidecar_state_tests.rs +++ b/crates/cli/tests/coverage/sidecar_state_tests.rs @@ -179,23 +179,25 @@ fn managed_owner_supports_claude_code_identity() { let state = dir.path().join("state"); let environment = Environment::isolated(); configure_managed_bootstrap(&environment, &state); - environment.set(BOOTSTRAP_AGENT_ENV, "claude-code"); - let address = "127.0.0.1:47633".parse().unwrap(); - - publish_owner_from_env(address).unwrap(); - - let url = format!("http://{address}"); - let owner = owner_path(&state, CodingAgent::ClaudeCode, &url); - let pid = pid_path(&state, CodingAgent::ClaudeCode, &url); - validate_owner( - &owner, - &pid, - std::process::id(), - &url, - "shutdown-token", - Some("fingerprint"), - ) - .unwrap(); + for (identity, port) in [("claude", 47633), ("claude-code", 47634)] { + environment.set(BOOTSTRAP_AGENT_ENV, identity); + let address = format!("127.0.0.1:{port}").parse().unwrap(); + + publish_owner_from_env(address).unwrap(); + + let url = format!("http://{address}"); + let owner = owner_path(&state, CodingAgent::ClaudeCode, &url); + let pid = pid_path(&state, CodingAgent::ClaudeCode, &url); + validate_owner( + &owner, + &pid, + std::process::id(), + &url, + "shutdown-token", + Some("fingerprint"), + ) + .unwrap(); + } } #[test] diff --git a/crates/cli/tests/coverage/sidecar_tests.rs b/crates/cli/tests/coverage/sidecar_tests.rs index 8bf385ffc..f7d627ee3 100644 --- a/crates/cli/tests/coverage/sidecar_tests.rs +++ b/crates/cli/tests/coverage/sidecar_tests.rs @@ -200,12 +200,9 @@ fn direct_sidecar_start_classifies_existing_listeners_before_spawning() { ); let (address, server) = one_health_response(compatible_body); let bootstrap = start_sidecar_bind( - CodingAgent::Codex, - address, + &GatewaySpec::new(CodingAgent::Codex, address), dir.path(), dir.path(), - &[], - None, None, ) .unwrap(); @@ -219,12 +216,9 @@ fn direct_sidecar_start_classifies_existing_listeners_before_spawning() { ); let (address, server) = one_health_response(incompatible_body); let error = start_sidecar_bind( - CodingAgent::Codex, - address, + &GatewaySpec::new(CodingAgent::Codex, address), dir.path(), dir.path(), - &[], - None, None, ) .unwrap_err(); @@ -233,12 +227,9 @@ fn direct_sidecar_start_classifies_existing_listeners_before_spawning() { let (address, server) = one_health_response("{}".into()); let error = start_sidecar_bind( - CodingAgent::Codex, - address, + &GatewaySpec::new(CodingAgent::Codex, address), dir.path(), dir.path(), - &[], - None, None, ) .unwrap_err(); diff --git a/docs/about-nemo-relay/release-notes/known-issues.mdx b/docs/about-nemo-relay/release-notes/known-issues.mdx index d04d6dc5f..bd6f68cac 100644 --- a/docs/about-nemo-relay/release-notes/known-issues.mdx +++ b/docs/about-nemo-relay/release-notes/known-issues.mdx @@ -15,7 +15,7 @@ documentation set. required plugin MCP servers. That cold-start request can fail and be retried; the captured turn and `/responses` traffic wait for verified Relay gateway readiness. -- On Windows, a restrictive host Job Object can prevent the Codex plugin's +- On Windows, a restrictive host Job Object can prevent a coding-agent plugin's Relay sidecar from breaking away. When nested job assignment is supported, the sidecar remains scoped to the host job and the shared gateway might not remain available for the full 300-second idle reuse window. If Relay cannot diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index 4686a2c84..828d74bb9 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -113,7 +113,7 @@ nemo-relay uninstall codex ``` Refer to [Plugin Installation](/nemo-relay-cli/plugin-installation) for install -directories, host-specific behavior, and Codex shared-sidecar lifecycle. +directories, host-specific behavior, and the shared-sidecar lifecycle. ## Shared Configuration diff --git a/docs/nemo-relay-cli/claude-code.mdx b/docs/nemo-relay-cli/claude-code.mdx index abc80726a..5e0bd5e01 100644 --- a/docs/nemo-relay-cli/claude-code.mdx +++ b/docs/nemo-relay-cli/claude-code.mdx @@ -54,6 +54,26 @@ The installer creates a local marketplace, installs provider routing through the local NeMo Relay sidecar. It uses the existing `nemo-relay` binary on `PATH`; it does not install a plugin-local Relay binary. +The plugin starts `nemo-relay mcp`, a lightweight Rust lifecycle client that +starts or reuses the shared gateway on `127.0.0.1:47632`. The client verifies +the gateway identity and effective persistent configuration, heartbeats it +while MCP stdio remains open, and performs one coordinated restart if the +gateway becomes unhealthy. Claude Code and Codex MCP clients share a compatible +gateway, and the gateway exits after the final client's idle timeout. The MCP +server advertises no tools. + +Claude Code starts plugin MCP servers asynchronously, so `SessionStart` can run +before the MCP connection is ready. The installed command hook handles this +race by starting or reusing the same gateway and retrying the original payload +once. MCP owns the normal session-long lifecycle; hook recovery preserves the +earliest event. + +Persistent plugin mode loads system and user Relay configuration only and uses +the user configuration directory as its working directory. Use the transparent +wrapper for project-specific `.nemo-relay` configuration. Run +`nemo-relay install claude-code --force` to replace an existing +generation-fenced installation safely. + Check or remove the installed plugin with: ```bash @@ -113,10 +133,10 @@ claude ``` The gateway forwards Anthropic `/v1/messages`, `/v1/messages/count_tokens`, and -model routes without rewriting provider JSON. Hook events (tool calls, session -markers) are only captured when running through `nemo-relay claude` or -`nemo-relay run --agent claude`, which inject ephemeral hooks into the launched -process. +model routes without rewriting provider JSON. Hook events require either the +persistent NeMo Relay plugin or a transparent `nemo-relay claude` or +`nemo-relay run --agent claude` invocation, which injects ephemeral hooks into +the launched process. ## Captured Events @@ -202,8 +222,9 @@ mark under that turn. ## Hook Limitations -Claude Code hooks are available only when Claude Code loads the NeMo Relay -plugin, such as through `nemo-relay claude` or `nemo-relay run --agent claude`. +Claude Code hooks are available only when Claude Code loads the persistent NeMo +Relay plugin or the ephemeral plugin generated by `nemo-relay claude` or +`nemo-relay run --agent claude`. The standalone gateway can still observe Anthropic LLM traffic, but it cannot invent missing tool, prompt, compaction, notification, or subagent hooks. diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index b7240f7f6..b251a1035 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -100,9 +100,10 @@ managed Python environment, is limited to 100,000 filesystem entries and 512 MiB in total, with a maximum directory traversal depth of 128. When startup reports an activation snapshot budget error, remove unrelated files from the manifest or load-target directory, flatten deeply nested directories, or reduce -the managed Python environment before retrying. Concurrent Codex processes -share the sidecar. Each MCP client sends a heartbeat while its stdio connection -is open, and the sidecar exits after 300 seconds without activity by default. No +the managed Python environment before retrying. Concurrent Codex and Claude +Code plugin processes can share the sidecar. Each MCP client sends a heartbeat +while its stdio connection is open, and the sidecar exits after 300 seconds +without activity by default. No wrapper, launch agent, system user service, scheduled task, login item, or persistent supervisor is installed. diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index 6a568faf9..5f2118238 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -81,7 +81,8 @@ For Claude Code, install registers the local Claude marketplace, installs `nemo-relay-plugin@nemo-relay-local` at user scope, and enables provider routing through the local NeMo Relay sidecar. During installation, NeMo Relay preserves existing Claude authentication and model settings and backs them up only when -it adds the Relay provider route. +it adds the Relay provider route. The plugin also declares the native +`nemo-relay mcp` lifecycle client. For Codex, install registers the local Codex marketplace, installs `nemo-relay-plugin@nemo-relay-local`, enables Codex hooks, and configures the @@ -102,16 +103,22 @@ Upgrade removes legacy Relay groups from Start a new Codex CLI process after installation. Restart the Codex desktop app if it was already running so it reloads the provider and hook configuration. -Codex plugin mode uses a required `nemo-relay mcp` lifecycle client. The client -starts or reuses a detached native sidecar on `127.0.0.1:47632`, verifies Relay -identity, version, protocol readiness, and effective user-level configuration, -and heartbeats it every 30 seconds. Concurrent -Codex processes share the gateway. After the final MCP client closes, the -sidecar exits after 300 idle seconds by default. If the gateway exits while MCP -stdio is open, the client performs one coordinated restart and fails if -recovery does not succeed. A foreign process on the configured port is rejected -with an actionable conflict instead of being accepted because it returned HTTP -200. +Claude Code and Codex plugin modes use the same `nemo-relay mcp` lifecycle +client. It starts or reuses a detached native sidecar on `127.0.0.1:47632`, +verifies Relay identity, version, protocol readiness, and effective user-level +configuration, and heartbeats it every 30 seconds. Concurrent processes from +either host share the gateway. After the final MCP client closes, the sidecar +exits after 300 idle seconds by default. If the gateway exits while MCP stdio is +open, the client performs one coordinated restart and fails if recovery does +not succeed. A foreign process on the configured port is rejected with an +actionable conflict instead of being accepted because it returned HTTP 200. + +Codex marks the MCP server as required, so the captured turn waits for verified +gateway readiness. Claude Code starts plugin MCP servers asynchronously; its +`SessionStart` hook can run first. The Claude hook shim closes that race by +starting or reusing the same gateway under the same startup lock and retrying +the original canonical payload once. The MCP server advertises no tools in +either host. On Windows, Relay requests Job Object breakaway only when the host job permits it. If the host applies a restrictive job but permits nested jobs, Relay keeps @@ -123,17 +130,19 @@ guarantees. The compatibility fingerprint covers resolved provider, exporter, dynamic plugin, and relevant environment settings without exposing their values in the -health response. `nemo-relay install codex --force` uses a private ownership -token to stop an existing installer-owned sidecar before refreshing the plugin; -it does not terminate an unrelated listener. +health response. `nemo-relay install --force` generation-fences the +replacement MCP process and uses a private ownership token to stop a sidecar +owned by the replaced host when necessary; it does not terminate an unrelated +listener. Persistent mode resolves only system and user Relay configuration and starts in the user configuration directory. Project `.nemo-relay` layers remain available -through transparent `nemo-relay run` invocations. The generated MCP manifest -forwards environment variable names for provider credentials, Relay runtime, -OpenTelemetry, AWS, proxies, certificates, approved prefixes, and credential -variables referenced by user observability configuration. It never stores -their values. +through transparent `nemo-relay run` invocations. Codex requires an explicit +forwarding allowlist, so its generated MCP manifest stores variable names—not +values—for provider credentials, Relay runtime, OpenTelemetry, AWS, proxies, +certificates, approved prefixes, and credential variables referenced by user +observability configuration. Claude Code supplies its normal environment to +plugin MCP processes. ## Diagnose @@ -163,6 +172,10 @@ the required plugin MCP starts it before the captured turn. Doctor also reports newly required forwarded environment names and recommends `nemo-relay install codex --force`. +For Claude Code, doctor also validates the generated MCP server and generation +marker. A stopped sidecar remains informational because the next MCP initialize +or hook retry starts it. + Use the focused plugin doctor when diagnosing one host or an installation that uses a custom directory: diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 1096ae96b..c627cdac6 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -23,15 +23,17 @@ environment variables, or shared TOML config. ## Packages - `claude-code/` is a Claude Code plugin package. The - `nemo-relay install claude-code` command installs hook entries targeting - `POST /hooks/claude-code` through `nemo-relay` on `PATH`. + `nemo-relay install claude-code` command installs a native MCP lifecycle + client and hook entries targeting `POST /hooks/claude-code` through + `nemo-relay` on `PATH`. - `codex/` is a Codex plugin package. `nemo-relay install codex` creates the marketplace, installs the plugin, enables `features.hooks = true`, and configures a local `nemo-relay-openai` provider alias. Codex plugin delivery - uses required native `nemo-relay mcp` lifecycle clients that share one - Rust gateway, subject to the Windows Job Object lifetime caveat below, with no - wrapper, login item, launchd agent, systemd user service, scheduled task, or - persistent supervisor. + uses required native `nemo-relay mcp` lifecycle clients. Claude Code starts + the same lifecycle client automatically from its plugin. Clients from either + host share one Rust gateway, subject to the Windows Job Object lifetime + caveat below, with no wrapper, login item, launchd agent, systemd user + service, scheduled task, or persistent supervisor. - Hermes does not require a static bundle in this directory. The setup wizard (`nemo-relay config`) merges hook commands into `.hermes/config.yaml` when hermes is selected. @@ -74,25 +76,24 @@ CLI must already be installed and discoverable on `$PATH` or `%PATH%`; no separate npm installer, release bundle download, or plugin-local Relay binary is required. -Claude Code can start the sidecar from plugin hooks or helper commands and route -model traffic by setting `ANTHROPIC_BASE_URL` to the sidecar URL. - -Codex's required plugin MCP entry starts `nemo-relay mcp`, a lightweight client -that starts or reuses a native `nemo-relay --bind 127.0.0.1:47632` sidecar. Relay +Each plugin MCP entry starts `nemo-relay mcp`, a lightweight client that starts +or reuses a native `nemo-relay --bind 127.0.0.1:47632` sidecar. Relay detaches the sidecar when host policy permits. A restrictive Windows Job Object keeps the sidecar scoped to that host job instead of failing bootstrap. MCP initialization waits for Relay identity, version, and bootstrap-protocol -readiness. Concurrent Codex processes share the gateway and heartbeat it while -their MCP stdio connections remain open; the gateway exits after the final -client's idle timeout. Plugin-owned hooks call the canonically resolved -`nemo-relay plugin-shim hook codex` command, and model traffic uses the same -stable provider alias. - -Persistent Codex mode loads system and user Relay configuration only and starts +readiness. Concurrent Codex and Claude Code processes share the gateway and +heartbeat it while their MCP stdio connections remain open; the gateway exits +after the final client's idle timeout. Codex requires MCP initialization before +the captured turn. Claude Code starts plugin MCP servers asynchronously, so its +command hook also starts or reuses the same gateway and retries the original +payload when it wins the startup race. The MCP client advertises no tools. + +Persistent plugin mode loads system and user Relay configuration only and starts the sidecar from the user configuration directory. Relative exporter paths are -therefore stable across projects. The generated MCP manifest forwards approved -provider, Relay, OpenTelemetry, AWS, proxy, certificate, and config-referenced -credential environment names without storing their values. Use transparent +therefore stable across projects. Codex's generated MCP manifest forwards +approved provider, Relay, OpenTelemetry, AWS, proxy, certificate, and +config-referenced credential environment names without storing their values; +Claude Code supplies its normal MCP process environment. Use transparent `nemo-relay run` for project-specific configuration. Install the local host marketplaces with: @@ -145,8 +146,8 @@ project `.nemo-relay/config.toml`, then `$XDG_CONFIG_HOME/nemo-relay/config.toml` or `~/.config/nemo-relay/config.toml`. -That layering applies to transparent runs. Persistent Codex plugin mode skips -the project layer and merges only system and user configuration. +That layering applies to transparent runs. Persistent plugin mode skips the +project layer and merges only system and user configuration. ```toml [agents.codex] @@ -244,3 +245,16 @@ ls .nemo-relay/atif The gateway writes `.atif.json` when it receives a session-end hook for a session with ATIF configured. + +Run the opt-in host E2E targets when the corresponding CLI is installed. These +targets are intentionally outside `test-rust` and mandatory CI: + +```bash +just test-claude-plugin-e2e +just test-codex-plugin-e2e +``` + +Each target uses an isolated home directory and local mock provider. The Claude +target runs 10 cold sessions plus two concurrent sessions and verifies MCP +connection, hook delivery, provider routing, session isolation, balanced ATOF +output, and final port release. diff --git a/integrations/coding-agents/claude-code/.claude-plugin/plugin.json b/integrations/coding-agents/claude-code/.claude-plugin/plugin.json index 9490f2a02..d28c3c78c 100644 --- a/integrations/coding-agents/claude-code/.claude-plugin/plugin.json +++ b/integrations/coding-agents/claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "nemo-relay-plugin", "version": "0.6.0", - "description": "Claude Code hooks that forward canonical lifecycle payloads to nemo-relay.", + "description": "Native Relay gateway lifecycle and Claude Code hooks for complete local observability.", "author": { "name": "NVIDIA Corporation and Affiliates", "url": "https://github.com/NVIDIA/NeMo-Relay" @@ -9,6 +9,7 @@ "homepage": "https://github.com/NVIDIA/NeMo-Relay", "repository": "https://github.com/NVIDIA/NeMo-Relay", "license": "Apache-2.0", + "mcpServers": "./.mcp.json", "keywords": [ "nemo-relay", "claude-code", diff --git a/integrations/coding-agents/claude-code/.mcp.json b/integrations/coding-agents/claude-code/.mcp.json new file mode 100644 index 000000000..fbfd83abf --- /dev/null +++ b/integrations/coding-agents/claude-code/.mcp.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "nemo-relay": { + "command": "nemo-relay", + "args": [ + "mcp", + "--agent", + "claude" + ], + "env": { + "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632" + } + } + } +} diff --git a/integrations/coding-agents/claude-code/README.md b/integrations/coding-agents/claude-code/README.md index 1fef347de..f5b622eb0 100644 --- a/integrations/coding-agents/claude-code/README.md +++ b/integrations/coding-agents/claude-code/README.md @@ -5,8 +5,9 @@ SPDX-License-Identifier: Apache-2.0 # NeMo Relay Plugin -This package contains Claude Code hook entries that forward canonical Claude -Code hook JSON to `nemo-relay` at `/hooks/claude-code`. +This package contains a native MCP lifecycle client and Claude Code hook entries +that forward canonical Claude Code hook JSON to `nemo-relay` at +`/hooks/claude-code`. Claude Code is the supported Claude integration target. Claude application, Claude web, and Claude desktop sessions are unsupported unless they expose the @@ -15,6 +16,7 @@ same local hook and gateway controls as Claude Code. ## Files - `.claude-plugin/plugin.json` describes the Claude Code hook package. +- `.mcp.json` starts the native `nemo-relay mcp` lifecycle client. - `hooks/hooks.json` contains hook entries that run `nemo-relay plugin-shim hook claude`. @@ -168,7 +170,15 @@ nemo-relay install claude-code `nemo-relay install claude-code` writes a local Claude Code marketplace, installs `nemo-relay-plugin` at user scope, and enables Claude Code provider -routing through NeMo Relay. +routing through NeMo Relay. Its plugin MCP process starts or reuses the shared +native gateway on `127.0.0.1:47632` and heartbeats it while MCP stdio remains +open. Codex and Claude Code MCP clients can share that gateway. + +Claude Code starts plugin MCP servers asynchronously. The `SessionStart` hook +can therefore run first. The command hook handles that race by starting or +reusing the same gateway under the same startup lock and retrying the original +canonical payload once. The MCP server advertises no tools and does not add +tool definitions to Claude's context. No separate provider-routing command is required when installing through `nemo-relay install`. @@ -181,10 +191,13 @@ Package or unpack the plugin so the plugin root contains: ```text nemo-relay-plugin/ .claude-plugin/plugin.json + .mcp.json hooks/hooks.json ``` -The hook shim starts the sidecar lazily if no gateway is already reachable. +Persistent mode uses system and user Relay configuration only and starts the +gateway in the user configuration directory. Use `nemo-relay run --agent +claude` when project-specific Relay configuration is required. Repo marketplace discovery is also supported: @@ -196,8 +209,9 @@ claude plugin install nemo-relay-plugin@nemo-relay --scope user That path reads `.claude-plugin/marketplace.json` from the repository and installs this Claude Code plugin from `integrations/coding-agents/claude-code`. -Source hooks invoke `nemo-relay plugin-shim hook claude` directly. Use -`nemo-relay install claude-code` for the complete provider-routing setup. +The source plugin starts `nemo-relay mcp` and its hooks invoke +`nemo-relay plugin-shim hook claude` directly. Use `nemo-relay install +claude-code` for complete provider routing and generation-fenced upgrades. Create a local Claude Code marketplace and copy the plugin under that marketplace root: @@ -224,7 +238,7 @@ Create `$MARKETPLACE_ROOT/.claude-plugin/marketplace.json`: "plugins": [ { "name": "nemo-relay-plugin", - "description": "Forward Claude Code lifecycle hooks to a local NeMo Relay sidecar.", + "description": "Run the shared native Relay gateway and capture Claude Code lifecycle events.", "source": "./plugins/nemo-relay-plugin", "category": "development" } @@ -271,8 +285,9 @@ Start a normal Claude Code session: claude ``` -The installed hooks start the Relay sidecar lazily, and provider traffic is -routed through `ANTHROPIC_BASE_URL=http://127.0.0.1:47632`. +The installed MCP client normally starts the Relay sidecar. The hook shim is the +startup-race fallback, and provider traffic is routed through +`ANTHROPIC_BASE_URL=http://127.0.0.1:47632`. To upgrade manually, replace the plugin directory contents with the new package, keep the same `MARKETPLACE_ROOT`, update the marketplace, and rerun the @@ -281,7 +296,7 @@ top-level installer: ```bash claude plugin marketplace update nemo-relay-local claude plugin update nemo-relay-plugin -nemo-relay install claude-code +nemo-relay install claude-code --force ``` To uninstall, restore Claude Code provider settings, uninstall the plugin, remove diff --git a/integrations/coding-agents/codex/.mcp.json b/integrations/coding-agents/codex/.mcp.json index f3ff39455..4562f04bb 100644 --- a/integrations/coding-agents/codex/.mcp.json +++ b/integrations/coding-agents/codex/.mcp.json @@ -2,7 +2,9 @@ "nemo-relay": { "command": "nemo-relay", "args": [ - "mcp" + "mcp", + "--agent", + "codex" ], "env": { "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632" diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index 8fb7344af..38f9b522d 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -71,8 +71,9 @@ files and a copied managed Python environment, is limited to 100,000 filesystem entries and 512 MiB in total, with a maximum directory traversal depth of 128. If startup reports an activation snapshot budget error, remove unrelated files from the manifest or load-target directory, flatten deeply nested directories, -or reduce the managed Python environment before retrying. Concurrent Codex -processes share the gateway and heartbeat it every 30 seconds. The sidecar +or reduce the managed Python environment before retrying. Concurrent Codex and +Claude Code plugin processes can share the gateway and heartbeat it every 30 +seconds. The sidecar remains available for 300 idle seconds after the final client closes. If it dies while MCP remains open, one coordinated restart is attempted. The MCP server advertises no tools. diff --git a/justfile b/justfile index d26d35f43..5a504b0df 100644 --- a/justfile +++ b/justfile @@ -1060,6 +1060,10 @@ clean: test-codex-plugin-e2e: ./scripts/test-codex-plugin-e2e.sh +# Opt-in: requires Claude Code 2.1.116+ and is intentionally outside test-rust/CI. +test-claude-plugin-e2e: + ./scripts/test-claude-plugin-e2e.sh + # --set [output_dir=] [ci=true|false] test-rust: #!/usr/bin/env bash diff --git a/scripts/test-claude-plugin-e2e.sh b/scripts/test-claude-plugin-e2e.sh new file mode 100755 index 000000000..5c17f68ac --- /dev/null +++ b/scripts/test-claude-plugin-e2e.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! command -v claude >/dev/null 2>&1; then + echo "SKIP: claude is not installed; Claude Code plugin E2E requires 2.1.116+" + exit 0 +fi + +claude_version="$(claude --version 2>/dev/null || true)" +if ! python3 - "$claude_version" <<'PY' +import re +import sys + +match = re.search(r"(\d+)\.(\d+)\.(\d+)", sys.argv[1]) +raise SystemExit(0 if match and tuple(map(int, match.groups())) >= (2, 1, 116) else 1) +PY +then + echo "SKIP: Claude Code plugin E2E requires 2.1.116+; found: ${claude_version:-unknown}" + exit 0 +fi + +cargo build -p nemo-relay-cli --bin nemo-relay + +work="$(mktemp -d)" +provider_pid="" +background_pids=("") + +cleanup() { + for pid in "${background_pids[@]}"; do + [[ -n "$pid" ]] || continue + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + if [[ -d "$work/install" ]]; then + nemo-relay uninstall claude-code --install-dir "$work/install" >/dev/null 2>&1 || true + fi + if [[ -n "$provider_pid" ]]; then + kill "$provider_pid" 2>/dev/null || true + wait "$provider_pid" 2>/dev/null || true + fi + rm -rf "$work" +} +trap cleanup EXIT + +while IFS='=' read -r name _; do + if [[ "$name" == NEMO_RELAY_* ]]; then + unset "$name" + fi +done < <(env) + +export HOME="$work/home" +export XDG_CONFIG_HOME="$work/xdg" +export XDG_DATA_HOME="$work/data" +export XDG_RUNTIME_DIR="$work/runtime" +export TMPDIR="$work/tmp" +export PATH="$repo_root/target/debug:$PATH" +export ANTHROPIC_API_KEY="relay-claude-e2e-key" +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +export DISABLE_AUTOUPDATER=1 +export NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS=1 + +mkdir -p \ + "$HOME" \ + "$XDG_CONFIG_HOME/nemo-relay" \ + "$XDG_DATA_HOME" \ + "$XDG_RUNTIME_DIR" \ + "$TMPDIR" \ + "$work/atof" \ + "$work/provider-barrier" \ + "$work/workspace" + +provider_ready="$work/provider-ready.json" +provider_log="$work/provider-requests.jsonl" +python3 "$repo_root/scripts/test-support/codex_mock_provider.py" \ + --ready-file "$provider_ready" \ + --log-file "$provider_log" \ + --barrier-dir "$work/provider-barrier" & +provider_pid=$! + +for _ in $(seq 1 100); do + [[ -s "$provider_ready" ]] && break + sleep 0.05 +done +[[ -s "$provider_ready" ]] +provider_address="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["address"])' "$provider_ready")" + +cat >"$XDG_CONFIG_HOME/nemo-relay/config.toml" <"$XDG_CONFIG_HOME/nemo-relay/plugins.toml" <"$output" 2>"$stderr" + python3 - "$output" "$stderr" "$debug" <<'PY' +import json +import sys +from pathlib import Path + +output, stderr, debug = map(Path, sys.argv[1:]) +result = json.loads(output.read_text()) +assert result["subtype"] == "success", (result, stderr.read_text()) +assert result["result"] == "pong", result +log = debug.read_text() +assert log.count("Hook SessionStart:startup") == 1, log +assert log.count("Hook UserPromptSubmit") == 1, log +assert log.count('Hook Stop (Stop) success') == 1, log +assert log.count("SessionEnd:other") == 1, log +assert log.count('MCP server "plugin:nemo-relay-plugin:nemo-relay": Successfully connected') == 1, log +assert '"hasTools":false' in log, log +PY +} + +wait_for_relay_port_release +for run_id in $(seq 1 10); do + run_claude "$run_id" + wait_for_relay_port_release +done + +touch "$work/provider-barrier/enabled" +run_claude concurrent-a & +background_pids+=("$!") +run_claude concurrent-b & +background_pids+=("$!") + +python3 - "$work/provider-barrier/arrivals" <<'PY' +import sys +import time +from pathlib import Path + +arrivals = Path(sys.argv[1]) +deadline = time.monotonic() + 20 +while time.monotonic() < deadline: + if arrivals.exists() and int(arrivals.read_text() or "0") >= 2: + raise SystemExit(0) + time.sleep(0.05) +raise SystemExit("concurrent Claude requests did not reach the provider barrier") +PY +touch "$work/provider-barrier/release" + +for pid in "${background_pids[@]}"; do + [[ -n "$pid" ]] || continue + wait "$pid" +done +background_pids=("") +wait_for_relay_port_release + +python3 - "$provider_log" "$work/atof/events.jsonl" "$work" <<'PY' +import json +import sys +from pathlib import Path +from urllib.parse import urlparse + +provider_log, atof_path, work = map(Path, sys.argv[1:]) +requests = [json.loads(line) for line in provider_log.read_text().splitlines()] +messages = [row for row in requests if urlparse(row["path"]).path.endswith("/messages")] +assert len(messages) == 12, messages +assert all(row["x_api_key"] == "relay-claude-e2e-key" for row in messages), messages + +events = [json.loads(line) for line in atof_path.read_text().splitlines()] +turn_starts = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("name") == "claude-code-turn" + and event.get("scope_category") == "start" +] +turn_ends = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("name") == "claude-code-turn" + and event.get("scope_category") == "end" +] +llm_starts = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("name") == "anthropic.messages" + and event.get("scope_category") == "start" +] +llm_ends = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("name") == "anthropic.messages" + and event.get("scope_category") == "end" +] +assert len(turn_starts) == len(turn_ends) == 12, (len(turn_starts), len(turn_ends)) +assert len(llm_starts) == len(llm_ends) == 12, (len(llm_starts), len(llm_ends)) +session_ids = {event["metadata"]["session_id"] for event in turn_starts} +assert len(session_ids) == 12, session_ids + +debug_logs = list(work.glob("claude-*.debug.log")) +assert len(debug_logs) == 12, debug_logs +PY + +echo "Claude Code plugin E2E passed: 10 cold runs and 2 concurrent runs" diff --git a/scripts/test-support/codex_mock_provider.py b/scripts/test-support/codex_mock_provider.py index 3d530bd95..1d5f3c68b 100644 --- a/scripts/test-support/codex_mock_provider.py +++ b/scripts/test-support/codex_mock_provider.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Local OpenAI Responses API fixture for the opt-in Codex plugin E2E test.""" +"""Local OpenAI and Anthropic fixture for coding-agent plugin E2E tests.""" from __future__ import annotations @@ -13,6 +13,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any +from urllib.parse import urlparse def response_events(request: dict[str, Any]) -> list[dict[str, Any]]: @@ -125,6 +126,55 @@ def response_events(request: dict[str, Any]) -> list[dict[str, Any]]: ] +def anthropic_events(request: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: + message_id = f"msg_{uuid.uuid4().hex}" + model = request.get("model", "claude-sonnet-4-5") + return [ + ( + "message_start", + { + "type": "message_start", + "message": { + "id": message_id, + "type": "message", + "role": "assistant", + "content": [], + "model": model, + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 0}, + }, + }, + ), + ( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + ( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "pong"}, + }, + ), + ("content_block_stop", {"type": "content_block_stop", "index": 0}), + ( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 1}, + }, + ), + ("message_stop", {"type": "message_stop"}), + ] + + class Provider(ThreadingHTTPServer): def __init__(self, address: tuple[str, int], log_path: Path, barrier_dir: Path) -> None: super().__init__(address, Handler) @@ -161,6 +211,7 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A002 del format, args def do_GET(self) -> None: # noqa: N802 + path = urlparse(self.path).path self.server.log_request_record( { "method": "GET", @@ -168,7 +219,7 @@ def do_GET(self) -> None: # noqa: N802 "authorization": self.headers.get("authorization"), } ) - if not self.path.endswith("/models"): + if not path.endswith("/models"): self.send_error(404) return body = json.dumps( @@ -187,28 +238,62 @@ def do_POST(self) -> None: # noqa: N802 length = int(self.headers.get("content-length", "0")) raw = self.rfile.read(length) request = json.loads(raw or b"{}") - events = response_events(request) if self.path.endswith("/responses") else None + path = urlparse(self.path).path + response_stream = response_events(request) if path.endswith("/responses") else None + anthropic_stream = anthropic_events(request) if path.endswith("/messages") else None self.server.log_request_record( { "method": "POST", "path": self.path, "authorization": self.headers.get("authorization"), + "x_api_key": self.headers.get("x-api-key"), "model": request.get("model"), - "response_id": events[-1]["response"]["id"] if events else None, + "response_id": (response_stream[-1]["response"]["id"] if response_stream else None), } ) - if events is None: + if path.endswith("/messages/count_tokens"): + body = json.dumps({"input_tokens": 1}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + if response_stream is None and anthropic_stream is None: self.send_error(404) return self.server.wait_at_barrier_if_enabled() + if anthropic_stream is not None and not request.get("stream", False): + body = json.dumps( + { + "id": f"msg_{uuid.uuid4().hex}", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "pong"}], + "model": request.get("model", "claude-sonnet-4-5"), + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") self.send_header("Connection", "close") self.end_headers() - for event in events: - self.wfile.write(f"data: {json.dumps(event)}\n\n".encode()) - self.wfile.write(b"data: [DONE]\n\n") + if response_stream is not None: + for event in response_stream: + self.wfile.write(f"data: {json.dumps(event)}\n\n".encode()) + self.wfile.write(b"data: [DONE]\n\n") + else: + for event_name, event in anthropic_stream or []: + self.wfile.write(f"event: {event_name}\ndata: {json.dumps(event)}\n\n".encode()) self.wfile.flush() From 02b41690196a093de332ae27039d3a3fa2a029dc Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sat, 11 Jul 2026 00:25:42 -0400 Subject: [PATCH 005/102] feat(hermes): share native MCP gateway lifecycle Signed-off-by: Will Killian --- Cargo.lock | 1 + README.md | 10 +- crates/cli/Cargo.toml | 1 + crates/cli/src/config.rs | 21 +- crates/cli/src/doctor.rs | 27 +- crates/cli/src/gateway.rs | 50 +- crates/cli/src/hermes.rs | 408 +++++++++++++ crates/cli/src/hermes/config.rs | 255 ++++++++ crates/cli/src/hermes/files.rs | 185 ++++++ crates/cli/src/hermes/trust.rs | 105 ++++ crates/cli/src/installer.rs | 3 +- crates/cli/src/launcher.rs | 14 +- crates/cli/src/main.rs | 1 + crates/cli/src/mcp_environment.rs | 15 + crates/cli/src/plugin_shim/codex.rs | 54 +- crates/cli/src/plugin_shim/mod.rs | 43 +- crates/cli/src/plugin_shim/shared.rs | 59 +- crates/cli/src/session.rs | 93 ++- crates/cli/src/setup.rs | 13 +- crates/cli/src/setup/model.rs | 80 +-- crates/cli/src/sidecar.rs | 2 +- crates/cli/src/sidecar/state.rs | 1 + crates/cli/tests/cli_tests.rs | 130 +++- crates/cli/tests/coverage/gateway_tests.rs | 2 + crates/cli/tests/coverage/hermes_tests.rs | 554 ++++++++++++++++++ crates/cli/tests/coverage/launcher_tests.rs | 25 +- crates/cli/tests/coverage/main_tests.rs | 14 +- .../cli/tests/coverage/plugin_shim_tests.rs | 37 +- crates/cli/tests/coverage/server_tests.rs | 31 + crates/cli/tests/coverage/session_tests.rs | 82 ++- crates/cli/tests/coverage/setup_tests.rs | 58 +- .../cli/tests/coverage/sidecar_state_tests.rs | 23 + docs/getting-started/installation.mdx | 14 +- docs/nemo-relay-cli/about.mdx | 7 +- docs/nemo-relay-cli/basic-usage.mdx | 37 +- docs/nemo-relay-cli/claude-code.mdx | 6 +- docs/nemo-relay-cli/codex.mdx | 4 +- docs/nemo-relay-cli/hermes.mdx | 271 ++++----- docs/nemo-relay-cli/plugin-installation.mdx | 25 +- integrations/coding-agents/README.md | 58 +- .../coding-agents/claude-code/README.md | 3 +- integrations/coding-agents/codex/README.md | 6 +- justfile | 4 + scripts/test-hermes-mcp-e2e.sh | 296 ++++++++++ scripts/test-support/codex_mock_provider.py | 62 +- 45 files changed, 2707 insertions(+), 483 deletions(-) create mode 100644 crates/cli/src/hermes.rs create mode 100644 crates/cli/src/hermes/config.rs create mode 100644 crates/cli/src/hermes/files.rs create mode 100644 crates/cli/src/hermes/trust.rs create mode 100644 crates/cli/tests/coverage/hermes_tests.rs create mode 100755 scripts/test-hermes-mcp-e2e.sh diff --git a/Cargo.lock b/Cargo.lock index a2d373eaf..073e67e9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1546,6 +1546,7 @@ dependencies = [ "axum", "base64", "bytes", + "chrono", "clap", "clap_complete", "console 0.16.3", diff --git a/README.md b/README.md index c7d9234c6..fb1f9fa2e 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ trajectory file, you have concrete data to inspect, debug, and build on. ### 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 +`nemo-relay-cli`, turn on local exporters, run Codex, Claude Code, or Hermes through Relay, and check that Relay wrote both raw events and normalized trajectories. @@ -101,7 +101,7 @@ then configure these sections: > Run `nemo-relay plugins edit` without `--project` only when you want > user-level exporter settings that apply across projects. -#### 3. Run Codex or Claude Code Through Relay +#### 3. Run a Coding Agent Through Relay Run the Relay wrapper for the host CLI installed on your machine. For example: @@ -113,6 +113,10 @@ nemo-relay codex -- exec "Summarize this repository." nemo-relay claude -- "Summarize this repository." ``` +```bash +nemo-relay hermes -- -z "Summarize this repository." +``` + 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 @@ -293,7 +297,7 @@ coverage. |:--|:--:|:--:|:--:|:--| | Claude Code | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed LLM observability are supported. | | Codex | Yes | Yes | Partial | Persistent install verifies the exact plugin hooks. Each `Stop` finalizes a turn snapshot; `SessionEnd` availability remains Codex-version-dependent. | -| Hermes Agent | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed or hook-backed LLM observability are supported. | +| Hermes Agent | Yes | Yes | Partial | User config installs the shared native MCP gateway lifecycle plus exact trusted hooks; gateway-routed or hook-backed LLM observability is supported. | ### Public API Integrations diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index f1fce2c76..2a90b16aa 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -32,6 +32,7 @@ async-stream = "0.3" axum = "0.8" base64 = "0.22" bytes = "1" +chrono = "0.4" clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" console = "0.16" diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 174341545..529d248f4 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -80,10 +80,12 @@ pub(crate) enum Command { Codex(EasyPathCommand), /// Run Hermes with observability (setup on first use) #[command( - long_about = "Run NVIDIA's Hermes agent under a NeMo Relay gateway. Hermes reads hooks \ - from `.hermes/config.yaml`; first-run setup writes that file alongside \ - `.nemo-relay/config.toml` so every subsequent invocation traces \ - automatically. Re-run `nemo-relay config hermes` to refresh the hooks.", + long_about = "Run Hermes Agent under an ephemeral NeMo Relay gateway. Persistent setup \ + configures Hermes's user-level `mcp_servers` and shell hooks so bare Hermes \ + processes can share the native Relay gateway on 127.0.0.1:47632. This \ + wrapper temporarily suppresses that fixed MCP entry and uses a dynamic \ + gateway for project-specific Relay configuration. Re-run \ + `nemo-relay config hermes` to refresh the persistent integration.", after_help = "Examples:\n \ nemo-relay hermes\n \ nemo-relay hermes -- chat --provider custom" @@ -99,6 +101,7 @@ pub(crate) enum Command { advertises no MCP tools.", after_help = "Examples:\n \ nemo-relay mcp\n \ + nemo-relay mcp --agent hermes\n \ nemo-relay --bind 127.0.0.1:4041 mcp # explicit standalone/test bind" )] Mcp(McpCommand), @@ -136,13 +139,14 @@ pub(crate) struct McpCommand { pub(crate) agent: McpAgent, } -/// Hosts whose plugin format can own the lifecycle-bound MCP client. +/// Hosts that can own the lifecycle-bound MCP client through plugin or user configuration. #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] #[value(rename_all = "kebab-case")] pub(crate) enum McpAgent { #[value(name = "claude", alias = "claude-code")] ClaudeCode, Codex, + Hermes, } impl From for CodingAgent { @@ -150,6 +154,7 @@ impl From for CodingAgent { match agent { McpAgent::ClaudeCode => Self::ClaudeCode, McpAgent::Codex => Self::Codex, + McpAgent::Hermes => Self::Hermes, } } } @@ -233,9 +238,9 @@ pub(crate) struct ConfigCommand { /// only that agent's block from the existing config file. Omit to operate on all agents. #[arg(value_enum)] pub(crate) agent: Option, - /// Delete the project config file (or remove just the scoped agent's block when an agent - /// is named). The wizard does NOT run after a reset — invoke `nemo-relay config` again to - /// re-create the file from scratch. + /// Delete the project config file or the scoped agent block. A Hermes-scoped reset also + /// removes Relay-owned MCP, hooks, and trust from the user Hermes config. The wizard does not + /// run after reset; invoke `nemo-relay config` again to recreate configuration. #[arg(long)] pub(crate) reset: bool, } diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index 60edb4f45..e47bfdda0 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -527,12 +527,13 @@ fn hook_status( (Status::Pass, "hooks: injected during run".into()) } CodingAgent::Hermes => match agents.hermes.hooks_path.as_deref() { - Some(path) => hook_file_status( - Ok(path.to_path_buf()), - CodingAgent::Hermes, - readiness_required, - "hooks", - ), + Some(path) => match crate::hermes::diagnose_persistent(path) { + Ok(details) => (Status::Pass, details), + Err(error) => ( + Status::Fail, + format!("persistent MCP/hooks: {error}; rerun `nemo-relay config hermes`"), + ), + }, None if readiness_required => ( Status::Fail, "hooks: not installed; run `nemo-relay config hermes`".into(), @@ -542,6 +543,7 @@ fn hook_status( } } +#[cfg(test)] fn hook_file_status( path: Result, agent: CodingAgent, @@ -558,10 +560,15 @@ fn hook_file_status( } }; match std::fs::read_to_string(&path) { - Ok(raw) if raw.contains(&format!("hook-forward {}", agent.as_arg())) => ( - Status::Pass, - format!("{label}: installed at {}", path.display()), - ), + Ok(raw) + if raw.contains(&format!("hook-forward {}", agent.as_arg())) + || raw.contains(&format!("plugin-shim hook {}", agent.as_arg())) => + { + ( + Status::Pass, + format!("{label}: installed at {}", path.display()), + ) + } Ok(_) if readiness_required => ( Status::Fail, format!("{label}: missing NeMo Relay hook in {}", path.display()), diff --git a/crates/cli/src/gateway.rs b/crates/cli/src/gateway.rs index ca0b7f03e..a49270140 100644 --- a/crates/cli/src/gateway.rs +++ b/crates/cli/src/gateway.rs @@ -30,7 +30,7 @@ use crate::alignment::{self, GatewayRouteKind}; use crate::config::header_string; use crate::error::CliError; use crate::server::AppState; -use crate::session::{GatewayCallPrep, LlmGatewayStart, SessionManager}; +use crate::session::{GatewayCallPrep, GatewaySessionFinish, LlmGatewayStart, SessionManager}; /// Proxies supported LLM API requests through NeMo Relay's managed execution pipeline. /// @@ -198,7 +198,7 @@ async fn run_managed_gateway( ) -> Result, CliError> { if prep.bypass_managed_pipeline { let session_id = prep.session_id.clone(); - let prune_empty_session = prep.prune_empty_session_on_finish; + let session_finish = prep.session_finish; let model = prep.model_name.as_deref().unwrap_or(""); eprintln!( "nemo-relay CLI gateway: bypassing managed LLM observability for Claude Code startup probe session={session_id} provider={} model={model}", @@ -206,7 +206,7 @@ async fn run_managed_gateway( ); state .sessions - .finish_gateway_call(&session_id, prune_empty_session) + .finish_gateway_call(&session_id, session_finish) .await; return run_unmanaged_gateway(state, prepared).await; } @@ -291,7 +291,7 @@ async fn run_managed_buffered( model_name, owner_subagent_id, bypass_managed_pipeline: _, - prune_empty_session_on_finish: _, + session_finish, } = prep; let provider_for_event = provider_name.clone(); let params = LlmCallExecuteParams::builder() @@ -313,7 +313,10 @@ async fn run_managed_buffered( .sessions .record_gateway_response_hints(&session_id, owner_subagent_id, response_json) .await; - state.sessions.finish_gateway_call(&session_id, false).await; + state + .sessions + .finish_gateway_call(&session_id, session_finish) + .await; let (status, headers) = upstream_info .lock() .expect("upstream info lock poisoned") @@ -327,7 +330,10 @@ async fn run_managed_buffered( build_response(status, headers, Body::from(bytes)) } Err(error) => { - state.sessions.finish_gateway_call(&session_id, false).await; + state + .sessions + .finish_gateway_call(&session_id, session_finish) + .await; Err(translate_runtime_error(error, &upstream_error)) } } @@ -421,9 +427,10 @@ async fn run_managed_streaming( // collector and finalizer for managed streaming, so without a codec we cannot use the managed // pipeline. This keeps non-LLM streaming paths working while typed codecs remain optional. let Some(streaming_codec) = codecs.streaming else { + let session_finish = prep.session_finish; state .sessions - .finish_gateway_call(&prep.session_id, false) + .finish_gateway_call(&prep.session_id, session_finish) .await; return passthrough_streaming(state, prepared).await; }; @@ -450,7 +457,7 @@ async fn run_managed_streaming( model_name, owner_subagent_id, bypass_managed_pipeline: _, - prune_empty_session_on_finish: _, + session_finish, } = prep; let params = LlmStreamCallExecuteParams::builder() .name(provider_name) @@ -473,7 +480,10 @@ async fn run_managed_streaming( let json_stream = match json_stream_result { Ok(json_stream) => json_stream, Err(error) => { - state.sessions.finish_gateway_call(&session_id, false).await; + state + .sessions + .finish_gateway_call(&session_id, session_finish) + .await; return Err(translate_runtime_error(error, &upstream_error)); } }; @@ -489,6 +499,7 @@ async fn run_managed_streaming( session_id.clone(), owner_subagent_id, final_response, + session_finish, ); // Streamed responses are finalized inside the runtime stream wrapper. The small finalizer tap @@ -599,9 +610,16 @@ fn client_sse_body( session_id: String, owner_subagent_id: Option, final_response: Arc>>, + session_finish: GatewaySessionFinish, ) -> Body { let mut json_stream = json_stream; - let mut guard = GatewayCallGuard::new(sessions, session_id, owner_subagent_id, final_response); + let mut guard = GatewayCallGuard::new( + sessions, + session_id, + owner_subagent_id, + final_response, + session_finish, + ); let stream = stream! { while let Some(item) = json_stream.next().await { match item { @@ -632,6 +650,7 @@ struct GatewayCallGuard { session_id: String, owner_subagent_id: Option, final_response: Arc>>, + session_finish: GatewaySessionFinish, } impl GatewayCallGuard { @@ -640,12 +659,14 @@ impl GatewayCallGuard { session_id: String, owner_subagent_id: Option, final_response: Arc>>, + session_finish: GatewaySessionFinish, ) -> Self { Self { sessions: Some(sessions), session_id, owner_subagent_id, final_response, + session_finish, } } @@ -665,7 +686,9 @@ impl GatewayCallGuard { ) .await; } - sessions.finish_gateway_call(&self.session_id, false).await; + sessions + .finish_gateway_call(&self.session_id, self.session_finish) + .await; } } } @@ -677,6 +700,7 @@ impl Drop for GatewayCallGuard { }; let session_id = self.session_id.clone(); let owner_subagent_id = self.owner_subagent_id.clone(); + let session_finish = self.session_finish; let response = self .final_response .lock() @@ -689,7 +713,9 @@ impl Drop for GatewayCallGuard { .record_gateway_response_hints(&session_id, owner_subagent_id, response) .await; } - sessions.finish_gateway_call(&session_id, false).await; + sessions + .finish_gateway_call(&session_id, session_finish) + .await; }); } } diff --git a/crates/cli/src/hermes.rs b/crates/cli/src/hermes.rs new file mode 100644 index 000000000..e9c782a2f --- /dev/null +++ b/crates/cli/src/hermes.rs @@ -0,0 +1,408 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Hermes-owned MCP and lifecycle-hook configuration. + +mod config; +mod files; +mod trust; + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use serde_json::{Map, Value, json}; + +#[cfg(test)] +use self::config::persistent_hook_command_for_platform; +use self::config::{ + MCP_SERVER_NAME, expected_mcp_server, forwarded_environment_names, is_managed_hook_command, + is_managed_mcp_server, parse_yaml_object, persistent_config, relay_is_executable, + remove_managed_mcp, strip_managed_hooks, user_config_path_with_override, yaml_bytes, +}; +use self::files::{ + FileSnapshot, INSTALL_LOCK_TIMEOUT, PersistentPaths, acquire_allowlist_lock, + acquire_install_lock, read_optional_utf8, remove_optional_file, replace_optional_file, +}; +use self::trust::{json_bytes, parse_json_object, trusted_hooks, verify_trust}; +use crate::error::CliError; +use crate::file_io::atomic_write; +use crate::install_generation::GENERATION_FILE_ENV; +#[cfg(test)] +use crate::install_generation::GENERATION_FILE_NAME; +use crate::installer::HERMES_HOOK_EVENTS; +use crate::sidecar::DEFAULT_BIND; +pub(crate) use config::{persistent_hook_command, transparent_config}; + +/// Hermes host configuration is user-owned even when Relay itself uses project configuration. +/// Project-specific Relay behavior remains available through transparent `nemo-relay run`. +pub(crate) fn user_config_path(default_home: &Path) -> PathBuf { + user_config_path_with_override(default_home, env::var_os("HERMES_HOME")) +} + +pub(crate) fn install_persistent(config: &Path, relay: &Path) -> Result, CliError> { + let relay = relay.canonicalize().unwrap_or_else(|_| relay.to_path_buf()); + let relay = crate::plugin_shim::portable_executable_path(relay); + if !relay_is_executable(&relay) { + return Err(CliError::Install(format!( + "nemo-relay executable is missing or not executable at {}", + relay.display() + ))); + } + let paths = PersistentPaths::for_config(config.to_path_buf())?; + let _lock = + acquire_install_lock(&paths.config, INSTALL_LOCK_TIMEOUT).map_err(CliError::Install)?; + let _allowlist_lock = acquire_allowlist_lock(&paths.allowlist, INSTALL_LOCK_TIMEOUT) + .map_err(CliError::Install)?; + let plugin_config = crate::config::user_plugin_runtime_config()?; + let environment = env::vars_os() + .filter_map(|(name, _)| name.into_string().ok()) + .collect::>(); + install_persistent_with( + paths, + &relay, + &environment, + plugin_config.as_ref(), + SystemTime::now(), + atomic_write, + ) +} + +pub(crate) fn uninstall_persistent(config: &Path) -> Result, CliError> { + let paths = PersistentPaths::for_config(config.to_path_buf())?; + if paths.all().iter().all(|path| !path.exists()) { + return Ok(Vec::new()); + } + let _lock = + acquire_install_lock(&paths.config, INSTALL_LOCK_TIMEOUT).map_err(CliError::Install)?; + let _allowlist_lock = acquire_allowlist_lock(&paths.allowlist, INSTALL_LOCK_TIMEOUT) + .map_err(CliError::Install)?; + uninstall_persistent_with(paths, atomic_write) +} + +pub(crate) fn diagnose_persistent(config_path: &Path) -> Result { + let paths = PersistentPaths::for_config(config_path.to_path_buf()) + .map_err(|error| error.to_string())?; + let raw = fs::read_to_string(&paths.config) + .map_err(|error| format!("failed to read {}: {error}", paths.config.display()))?; + let config = parse_yaml_object(Some(&raw), "Hermes config").map_err(|e| e.to_string())?; + let server = config + .pointer("/mcp_servers/nemo-relay") + .ok_or_else(|| "Hermes MCP server `nemo-relay` is missing".to_string())?; + if !is_managed_mcp_server(server) { + return Err("Hermes MCP server `nemo-relay` is not a managed Relay MCP client".into()); + } + let relay = PathBuf::from( + server + .get("command") + .and_then(Value::as_str) + .expect("managed MCP server has a string command"), + ); + if !relay_is_executable(&relay) { + return Err(format!( + "configured nemo-relay executable is missing or not executable at {}", + relay.display() + )); + } + let command = persistent_hook_command(&relay); + verify_hook_definitions(&config, &command)?; + verify_trust(&paths.allowlist, &command)?; + + let mcp_env = server + .get("env") + .and_then(Value::as_object) + .ok_or_else(|| "Hermes Relay MCP environment is missing".to_string())?; + if mcp_env.get("NEMO_RELAY_GATEWAY_BIND") != Some(&json!(DEFAULT_BIND)) { + return Err(format!( + "Hermes Relay MCP must use the shared gateway bind {DEFAULT_BIND}" + )); + } + let configured_generation = mcp_env + .get(GENERATION_FILE_ENV) + .and_then(Value::as_str) + .ok_or_else(|| "Hermes Relay MCP generation fence is missing".to_string())?; + if Path::new(configured_generation) != paths.generation { + return Err("Hermes Relay MCP generation fence points at the wrong file".into()); + } + let generation = fs::read_to_string(&paths.generation) + .map_err(|error| format!("failed to read {}: {error}", paths.generation.display()))?; + if generation.trim().is_empty() || generation.trim().starts_with("retired:") { + return Err("Hermes Relay MCP generation fence is not active".into()); + } + + let plugin_config = crate::config::user_plugin_runtime_config().map_err(|e| e.to_string())?; + let environment = env::vars_os() + .filter_map(|(name, _)| name.into_string().ok()) + .collect::>(); + let missing = forwarded_environment_names(&environment, plugin_config.as_ref()) + .into_iter() + .filter(|name| !mcp_env.contains_key(name)) + .collect::>(); + if !missing.is_empty() { + return Err(format!( + "Hermes Relay MCP is missing environment names {}; rerun `nemo-relay config hermes`", + missing.join(", ") + )); + } + Ok(format!( + "MCP lifecycle and {} hooks trusted at {}", + HERMES_HOOK_EVENTS.len(), + paths.config.display() + )) +} + +fn install_persistent_with( + paths: PersistentPaths, + relay: &Path, + environment: &[String], + plugin_config: Option<&Value>, + now: SystemTime, + mut write: W, +) -> Result, CliError> +where + W: FnMut(&Path, &[u8]) -> Result<(), String>, +{ + let snapshots = paths + .all() + .iter() + .map(|path| FileSnapshot::capture(path)) + .collect::, _>>()?; + let existing_config = read_optional_utf8(&paths.config)?; + let existing_allowlist = read_optional_utf8(&paths.allowlist)?; + let command = persistent_hook_command(relay); + let environment = forwarded_environment_names(environment, plugin_config); + let token = uuid::Uuid::now_v7().to_string(); + let config = persistent_config( + existing_config.as_deref(), + relay, + &command, + &paths.generation, + &environment, + )?; + let allowlist = trusted_hooks(existing_allowlist.as_deref(), &command, relay, now)?; + let config = yaml_bytes(&config)?; + let allowlist = json_bytes(&allowlist)?; + let generation = format!("{token}\n").into_bytes(); + + let result = (|| { + // Trust is published before config so Hermes never observes a configured hook without + // its exact approval. The config write is the transaction's commit point. + write(&paths.generation, &generation)?; + write(&paths.allowlist, &allowlist)?; + write(&paths.config, &config)?; + verify_install(&paths, relay, &command, &environment, &token) + })(); + if let Err(error) = result { + return rollback_error("install", error, &snapshots, &mut write); + } + Ok(paths.all().into_iter().collect()) +} + +fn uninstall_persistent_with( + paths: PersistentPaths, + mut write: W, +) -> Result, CliError> +where + W: FnMut(&Path, &[u8]) -> Result<(), String>, +{ + let affected = paths + .all() + .into_iter() + .filter(|path| path.exists()) + .collect::>(); + let snapshots = paths + .all() + .iter() + .map(|path| FileSnapshot::capture(path)) + .collect::, _>>()?; + let config = read_optional_utf8(&paths.config)? + .map(|raw| { + let mut root = parse_yaml_object(Some(&raw), "Hermes config")?; + strip_managed_hooks(&mut root)?; + remove_managed_mcp(&mut root)?; + if root.as_object().is_some_and(Map::is_empty) { + Ok(None) + } else { + yaml_bytes(&root).map(Some) + } + }) + .transpose()? + .flatten(); + let allowlist = read_optional_utf8(&paths.allowlist)? + .map(|raw| { + let mut root = parse_json_object(Some(&raw), "Hermes shell-hook allowlist")?; + let object = root + .as_object_mut() + .expect("allowlist root checked as object"); + if let Some(approvals) = object.get_mut("approvals") { + let approvals = approvals.as_array_mut().ok_or_else(|| { + CliError::Install( + "Hermes shell-hook allowlist approvals must be an array".into(), + ) + })?; + approvals.retain(|entry| { + entry + .get("command") + .and_then(Value::as_str) + .is_none_or(|command| !is_managed_hook_command(command)) + }); + if approvals.is_empty() { + object.remove("approvals"); + } + } + if object.is_empty() { + Ok(None) + } else { + json_bytes(&root).map(Some) + } + }) + .transpose()? + .flatten(); + + let result = (|| { + remove_optional_file(&paths.generation)?; + replace_optional_file(&paths.allowlist, allowlist.as_deref(), &mut write)?; + replace_optional_file(&paths.config, config.as_deref(), &mut write)?; + verify_uninstall(&paths) + })(); + if let Err(error) = result { + return rollback_error("uninstall", error, &snapshots, &mut write); + } + Ok(affected) +} + +fn rollback_error( + operation: &str, + error: String, + snapshots: &[FileSnapshot], + write: &mut W, +) -> Result +where + W: FnMut(&Path, &[u8]) -> Result<(), String>, +{ + let rollback_errors = snapshots + .iter() + .rev() + .filter_map(|snapshot| snapshot.restore(write).err()) + .collect::>(); + let rollback = if rollback_errors.is_empty() { + String::new() + } else { + format!("; rollback also failed: {}", rollback_errors.join("; ")) + }; + Err(CliError::Install(format!( + "failed to {operation} Hermes MCP integration: {error}{rollback}" + ))) +} + +fn verify_install( + paths: &PersistentPaths, + relay: &Path, + command: &str, + environment: &[String], + token: &str, +) -> Result<(), String> { + let raw = fs::read_to_string(&paths.config) + .map_err(|error| format!("failed to verify {}: {error}", paths.config.display()))?; + let config = parse_yaml_object(Some(&raw), "Hermes config").map_err(|e| e.to_string())?; + let expected = expected_mcp_server(relay, &paths.generation, environment); + if config.pointer("/mcp_servers/nemo-relay") != Some(&expected) { + return Err("Hermes MCP server did not persist exactly".into()); + } + verify_hook_definitions(&config, command)?; + verify_trust(&paths.allowlist, command)?; + + let raw = fs::read_to_string(&paths.allowlist) + .map_err(|error| format!("failed to verify {}: {error}", paths.allowlist.display()))?; + let allowlist = + parse_json_object(Some(&raw), "Hermes shell-hook allowlist").map_err(|e| e.to_string())?; + let approvals = allowlist["approvals"] + .as_array() + .expect("trust verification checked approvals"); + if approvals + .iter() + .filter_map(|entry| entry.get("command").and_then(Value::as_str)) + .any(|candidate| is_managed_hook_command(candidate) && candidate != command) + { + return Err("stale Hermes Relay hook approvals remain".into()); + } + + let actual = fs::read_to_string(&paths.generation) + .map_err(|error| format!("failed to verify {}: {error}", paths.generation.display()))?; + if actual.trim() != token { + return Err("Hermes MCP generation did not persist exactly".into()); + } + Ok(()) +} + +fn verify_hook_definitions(config: &Value, command: &str) -> Result<(), String> { + for event in HERMES_HOOK_EVENTS { + let groups = config + .pointer(&format!("/hooks/{event}")) + .and_then(Value::as_array) + .ok_or_else(|| format!("Hermes hook {event} is missing"))?; + let matching = groups + .iter() + .filter(|group| group.get("command").and_then(Value::as_str) == Some(command)) + .count(); + let managed = groups + .iter() + .filter_map(|group| group.get("command").and_then(Value::as_str)) + .filter(|candidate| is_managed_hook_command(candidate)) + .count(); + if matching != 1 || managed != 1 { + return Err(format!( + "Hermes hook {event} expected exactly one trusted Relay handler" + )); + } + } + Ok(()) +} + +fn verify_uninstall(paths: &PersistentPaths) -> Result<(), String> { + if paths.generation.exists() { + return Err("Hermes MCP generation fence still exists".into()); + } + if let Some(raw) = read_optional_utf8(&paths.config).map_err(|error| error.to_string())? { + let config = parse_yaml_object(Some(&raw), "Hermes config").map_err(|e| e.to_string())?; + if config + .get("mcp_servers") + .and_then(|servers| servers.get(MCP_SERVER_NAME)) + .is_some_and(is_managed_mcp_server) + { + return Err("managed Hermes Relay MCP server still exists".into()); + } + if config + .get("hooks") + .and_then(Value::as_object) + .into_iter() + .flat_map(Map::values) + .filter_map(Value::as_array) + .flatten() + .filter_map(|entry| entry.get("command").and_then(Value::as_str)) + .any(is_managed_hook_command) + { + return Err("managed Hermes Relay hook still exists".into()); + } + } + if let Some(raw) = read_optional_utf8(&paths.allowlist).map_err(|error| error.to_string())? { + let allowlist = parse_json_object(Some(&raw), "Hermes shell-hook allowlist") + .map_err(|e| e.to_string())?; + if allowlist + .get("approvals") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|entry| entry.get("command").and_then(Value::as_str)) + .any(is_managed_hook_command) + { + return Err("managed Hermes Relay trust approval still exists".into()); + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "../tests/coverage/hermes_tests.rs"] +mod tests; diff --git a/crates/cli/src/hermes/config.rs b/crates/cli/src/hermes/config.rs new file mode 100644 index 000000000..1a39b9434 --- /dev/null +++ b/crates/cli/src/hermes/config.rs @@ -0,0 +1,255 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Pure Hermes YAML generation, migration, and ownership recognition. + +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_json::{Map, Value, json}; + +use crate::error::CliError; +use crate::install_generation::GENERATION_FILE_ENV; +use crate::installer::{hermes_hooks, merge_hooks}; +use crate::sidecar::DEFAULT_BIND; + +pub(super) const MCP_SERVER_NAME: &str = "nemo-relay"; +const ALWAYS_FORWARDED_CREDENTIALS: &[&str] = &["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]; + +pub(super) fn user_config_path_with_override( + default_home: &Path, + hermes_home: Option, +) -> PathBuf { + hermes_home + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| default_home.join(".hermes")) + .join("config.yaml") +} + +/// Rewrites the Relay-owned portion of a Hermes config for a transparent run. The fixed MCP +/// client is removed because the wrapper already owns a dynamic gateway. +pub(crate) fn transparent_config(existing: &str, relay: &Path) -> Result { + let mut root = parse_yaml_object(Some(existing), "Hermes config")?; + strip_managed_hooks(&mut root)?; + remove_managed_mcp(&mut root)?; + let root = merge_hooks(root, hermes_hooks(&persistent_hook_command(relay)))?; + serde_yaml::to_string(&root).map_err(|error| CliError::Install(error.to_string())) +} + +pub(crate) fn persistent_hook_command(relay: &Path) -> String { + persistent_hook_command_for_platform(relay, cfg!(windows)) +} + +pub(super) fn persistent_hook_command_for_platform(relay: &Path, windows: bool) -> String { + format!( + "{} plugin-shim hook hermes", + crate::plugin_shim::shell_quote_for_platform(relay, windows) + ) +} + +pub(super) fn persistent_config( + existing: Option<&str>, + relay: &Path, + command: &str, + generation: &Path, + environment: &[String], +) -> Result { + let mut root = parse_yaml_object(existing, "Hermes config")?; + strip_managed_hooks(&mut root)?; + root = merge_hooks(root, hermes_hooks(command))?; + let servers = object_field_mut(&mut root, "mcp_servers", "mcp_servers")?; + servers.insert( + MCP_SERVER_NAME.into(), + expected_mcp_server(relay, generation, environment), + ); + Ok(root) +} + +pub(super) fn expected_mcp_server( + relay: &Path, + generation: &Path, + environment: &[String], +) -> Value { + let mut forwarded = Map::from_iter([ + ("NEMO_RELAY_GATEWAY_BIND".into(), json!(DEFAULT_BIND)), + ( + GENERATION_FILE_ENV.into(), + json!(generation.display().to_string()), + ), + ]); + for name in environment { + forwarded.insert(name.clone(), json!(format!("${{{name}}}"))); + } + json!({ + "command": relay.display().to_string(), + "args": ["mcp", "--agent", "hermes"], + "env": forwarded, + }) +} + +pub(super) fn forwarded_environment_names( + environment: &[String], + plugin_config: Option<&Value>, +) -> Vec { + let present = environment.iter().cloned().collect::>(); + let referenced = crate::mcp_environment::config_referenced_names(plugin_config) + .into_iter() + .collect::>(); + crate::mcp_environment::forwarded_names(environment.iter().cloned(), plugin_config) + .into_iter() + .filter(|name| { + present.contains(name) + || referenced.contains(name) + || ALWAYS_FORWARDED_CREDENTIALS.contains(&name.as_str()) + }) + .collect() +} + +pub(super) fn strip_managed_hooks(root: &mut Value) -> Result<(), CliError> { + let Some(hooks) = root.get_mut("hooks") else { + return Ok(()); + }; + let remove_hooks = { + let hooks = hooks + .as_object_mut() + .ok_or_else(|| CliError::Install("Hermes hooks must be an object".into()))?; + let mut empty = Vec::new(); + for (event, groups) in hooks.iter_mut() { + let groups = groups.as_array_mut().ok_or_else(|| { + CliError::Install(format!("Hermes {event} hooks must be an array")) + })?; + groups.retain(|group| { + group + .get("command") + .and_then(Value::as_str) + .is_none_or(|command| !is_managed_hook_command(command)) + }); + if groups.is_empty() { + empty.push(event.clone()); + } + } + for event in empty { + hooks.remove(&event); + } + hooks.is_empty() + }; + if remove_hooks { + root.as_object_mut() + .expect("Hermes config root checked as object") + .remove("hooks"); + } + Ok(()) +} + +pub(super) fn remove_managed_mcp(root: &mut Value) -> Result<(), CliError> { + let Some(servers) = root.get_mut("mcp_servers") else { + return Ok(()); + }; + let servers = servers + .as_object_mut() + .ok_or_else(|| CliError::Install("Hermes mcp_servers must be an object".into()))?; + if servers + .get(MCP_SERVER_NAME) + .is_some_and(is_managed_mcp_server) + { + servers.remove(MCP_SERVER_NAME); + } + if servers.is_empty() { + root.as_object_mut() + .expect("Hermes config root checked as object") + .remove("mcp_servers"); + } + Ok(()) +} + +pub(super) fn is_managed_mcp_server(server: &Value) -> bool { + server + .get("command") + .and_then(Value::as_str) + .is_some_and(is_relay_executable) + && server.get("args") == Some(&json!(["mcp", "--agent", "hermes"])) +} + +pub(crate) fn is_managed_hook_command(command: &str) -> bool { + [" hook-forward hermes", " plugin-shim hook hermes"] + .into_iter() + .any(|separator| { + command + .trim() + .rsplit_once(separator) + .is_some_and(|(executable, arguments)| { + is_relay_executable(executable) + && (arguments.is_empty() + || arguments.starts_with(" --gateway-url ") + || arguments.starts_with(" --fail-closed")) + }) + }) +} + +fn is_relay_executable(raw: &str) -> bool { + let mut candidate = raw.trim().to_string(); + if candidate.starts_with('\'') && candidate.ends_with('\'') && candidate.len() >= 2 { + candidate = candidate[1..candidate.len() - 1].replace("'\\''", "'"); + } else if candidate.starts_with('"') && candidate.ends_with('"') && candidate.len() >= 2 { + candidate = candidate[1..candidate.len() - 1].to_string(); + } + candidate = candidate.replace('^', "").replace("%%", "%"); + let normalized = candidate.replace('\\', "/"); + matches!( + normalized.rsplit('/').next().map(str::to_ascii_lowercase), + Some(name) if name == "nemo-relay" || name == "nemo-relay.exe" + ) +} + +pub(super) fn relay_is_executable(path: &Path) -> bool { + if !path.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::metadata(path) + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + true + } +} + +pub(super) fn parse_yaml_object(raw: Option<&str>, description: &str) -> Result { + let value = match raw.filter(|raw| !raw.trim().is_empty()) { + Some(raw) => serde_yaml::from_str(raw) + .map_err(|error| CliError::Install(format!("invalid {description}: {error}")))?, + None => json!({}), + }; + if value.is_object() { + Ok(value) + } else { + Err(CliError::Install(format!( + "{description} must contain an object" + ))) + } +} + +pub(super) fn yaml_bytes(value: &Value) -> Result, CliError> { + serde_yaml::to_string(value) + .map(String::into_bytes) + .map_err(|error| CliError::Install(error.to_string())) +} + +fn object_field_mut<'a>( + root: &'a mut Value, + field: &str, + description: &str, +) -> Result<&'a mut Map, CliError> { + root.as_object_mut() + .expect("config root checked as object") + .entry(field) + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| CliError::Install(format!("Hermes {description} must be an object"))) +} diff --git a/crates/cli/src/hermes/files.rs b/crates/cli/src/hermes/files.rs new file mode 100644 index 000000000..ffd631e92 --- /dev/null +++ b/crates/cli/src/hermes/files.rs @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Serialized, rollback-capable filesystem operations for the Hermes integration. + +use std::fs::{self, File, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::error::CliError; +use crate::file_io::{LockAttempt, try_lock_exclusive}; +use crate::install_generation::GENERATION_FILE_NAME; + +const ALLOWLIST_FILE_NAME: &str = "shell-hooks-allowlist.json"; +const INSTALL_LOCK_FILE_NAME: &str = ".nemo-relay-operation.lock"; +const INSTALL_LOCK_RETRY: Duration = Duration::from_millis(25); +pub(super) const INSTALL_LOCK_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct PersistentPaths { + pub(super) config: PathBuf, + pub(super) allowlist: PathBuf, + pub(super) generation: PathBuf, +} + +impl PersistentPaths { + pub(super) fn for_config(config: PathBuf) -> Result { + let home = config.parent().ok_or_else(|| { + CliError::Install(format!( + "Hermes config path {} has no parent directory", + config.display() + )) + })?; + Ok(Self { + allowlist: home.join(ALLOWLIST_FILE_NAME), + generation: home.join(GENERATION_FILE_NAME), + config, + }) + } + + pub(super) fn all(&self) -> [PathBuf; 3] { + [ + self.config.clone(), + self.allowlist.clone(), + self.generation.clone(), + ] + } +} + +pub(super) fn acquire_install_lock(config: &Path, timeout: Duration) -> Result { + let home = config.parent().ok_or_else(|| { + format!( + "Hermes config path {} has no parent directory", + config.display() + ) + })?; + acquire_lock_file( + &home.join(INSTALL_LOCK_FILE_NAME), + timeout, + "another Hermes integration update", + ) +} + +/// Uses Hermes's own sibling allowlist lock so Relay cannot lose an unrelated approval that +/// Hermes records concurrently. +pub(super) fn acquire_allowlist_lock(allowlist: &Path, timeout: Duration) -> Result { + let mut lock = allowlist.as_os_str().to_os_string(); + lock.push(".lock"); + acquire_lock_file( + &PathBuf::from(lock), + timeout, + "a Hermes shell-hook approval update", + ) +} + +fn acquire_lock_file(path: &Path, timeout: Duration, contention: &str) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + let mut options = OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(path).map_err(|error| { + format!( + "failed to open Hermes install lock {}: {error}", + path.display() + ) + })?; + let deadline = Instant::now() + timeout; + loop { + match try_lock_exclusive(&file) { + Ok(LockAttempt::Acquired) => return Ok(file), + Ok(LockAttempt::Contended) if Instant::now() < deadline => { + thread::sleep( + INSTALL_LOCK_RETRY.min(deadline.saturating_duration_since(Instant::now())), + ); + } + Ok(LockAttempt::Contended) => { + return Err(format!( + "timed out waiting for {contention} at {}; wait for it to finish and retry", + path.display() + )); + } + Err(error) => { + return Err(format!( + "failed to lock Hermes integration state {}: {error}", + path.display() + )); + } + } + } +} + +pub(super) fn read_optional_utf8(path: &Path) -> Result, CliError> { + match fs::read_to_string(path) { + Ok(raw) => Ok(Some(raw)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(CliError::Install(format!( + "failed to read {}: {error}", + path.display() + ))), + } +} + +pub(super) fn replace_optional_file( + path: &Path, + bytes: Option<&[u8]>, + write: &mut W, +) -> Result<(), String> +where + W: FnMut(&Path, &[u8]) -> Result<(), String>, +{ + match bytes { + Some(bytes) => write(path, bytes), + None => remove_optional_file(path), + } +} + +pub(super) fn remove_optional_file(path: &Path) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("failed to remove {}: {error}", path.display())), + } +} + +pub(super) struct FileSnapshot { + path: PathBuf, + bytes: Option>, +} + +impl FileSnapshot { + pub(super) fn capture(path: &Path) -> Result { + match fs::read(path) { + Ok(bytes) => Ok(Self { + path: path.to_path_buf(), + bytes: Some(bytes), + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self { + path: path.to_path_buf(), + bytes: None, + }), + Err(error) => Err(CliError::Install(format!( + "failed to snapshot {}: {error}", + path.display() + ))), + } + } + + pub(super) fn restore(&self, write: &mut W) -> Result<(), String> + where + W: FnMut(&Path, &[u8]) -> Result<(), String>, + { + if let Some(bytes) = self.bytes.as_deref() { + return write(&self.path, bytes); + } + remove_optional_file(&self.path) + } +} diff --git a/crates/cli/src/hermes/trust.rs b/crates/cli/src/hermes/trust.rs new file mode 100644 index 000000000..0aaafabef --- /dev/null +++ b/crates/cli/src/hermes/trust.rs @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Exact Hermes shell-hook trust generation and verification. + +use std::fs; +use std::path::Path; +use std::time::SystemTime; + +use chrono::{DateTime, SecondsFormat, Utc}; +use serde_json::{Value, json}; + +use super::config::is_managed_hook_command; +use crate::error::CliError; +use crate::installer::HERMES_HOOK_EVENTS; + +pub(super) fn trusted_hooks( + existing: Option<&str>, + command: &str, + relay: &Path, + now: SystemTime, +) -> Result { + let mut root = parse_json_object(existing, "Hermes shell-hook allowlist")?; + let approvals = root + .as_object_mut() + .expect("JSON root checked as object") + .entry("approvals") + .or_insert_with(|| json!([])) + .as_array_mut() + .ok_or_else(|| { + CliError::Install("Hermes shell-hook allowlist approvals must be an array".into()) + })?; + approvals.retain(|entry| { + entry + .get("command") + .and_then(Value::as_str) + .is_none_or(|candidate| !is_managed_hook_command(candidate)) + }); + let approved_at = timestamp(now); + let script_mtime_at_approval = fs::metadata(relay) + .and_then(|metadata| metadata.modified()) + .ok() + .map(timestamp); + approvals.extend(HERMES_HOOK_EVENTS.iter().map(|event| { + json!({ + "event": event, + "command": command, + "approved_at": approved_at, + "script_mtime_at_approval": script_mtime_at_approval, + }) + })); + Ok(root) +} + +fn timestamp(time: SystemTime) -> String { + DateTime::::from(time).to_rfc3339_opts(SecondsFormat::Micros, true) +} + +pub(super) fn verify_trust(allowlist_path: &Path, command: &str) -> Result<(), String> { + let raw = fs::read_to_string(allowlist_path) + .map_err(|error| format!("failed to read {}: {error}", allowlist_path.display()))?; + let allowlist = + parse_json_object(Some(&raw), "Hermes shell-hook allowlist").map_err(|e| e.to_string())?; + let approvals = allowlist + .get("approvals") + .and_then(Value::as_array) + .ok_or_else(|| "Hermes shell-hook approvals are missing".to_string())?; + for event in HERMES_HOOK_EVENTS { + let matching = approvals + .iter() + .filter(|entry| { + entry.get("event").and_then(Value::as_str) == Some(event) + && entry.get("command").and_then(Value::as_str) == Some(command) + }) + .count(); + if matching != 1 { + return Err(format!( + "Hermes hook {event} expected exactly one trust approval" + )); + } + } + Ok(()) +} + +pub(super) fn parse_json_object(raw: Option<&str>, description: &str) -> Result { + let value = match raw.filter(|raw| !raw.trim().is_empty()) { + Some(raw) => serde_json::from_str(raw) + .map_err(|error| CliError::Install(format!("invalid {description}: {error}")))?, + None => json!({}), + }; + if value.is_object() { + Ok(value) + } else { + Err(CliError::Install(format!( + "{description} must contain a JSON object" + ))) + } +} + +pub(super) fn json_bytes(value: &Value) -> Result, CliError> { + let mut bytes = + serde_json::to_vec_pretty(value).map_err(|error| CliError::Install(error.to_string()))?; + bytes.push(b'\n'); + Ok(bytes) +} diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 9a1a2274b..7e5781af8 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -49,7 +49,7 @@ const CODEX_HOOK_EVENTS: &[&str] = &[ const HOOK_FORWARD_TIMEOUT: Duration = Duration::from_secs(2); -const HERMES_HOOK_EVENTS: &[&str] = &[ +pub(crate) const HERMES_HOOK_EVENTS: &[&str] = &[ "on_session_start", "on_session_end", "on_session_finalize", @@ -364,6 +364,7 @@ fn merge_event_hook_groups( /// Parses Hermes YAML, merges generated hooks through the shared JSON hook merger, and serializes /// back to YAML. Empty input is treated as no existing configuration. +#[cfg(test)] pub(crate) fn merge_hermes_config(existing: &str, generated: Value) -> Result { let existing = if existing.trim().is_empty() { Value::Null diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index 58a51fe01..fc0c948c3 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -21,7 +21,7 @@ use crate::config::{ ServerArgs, any_config_file_exists, resolve_run_config, }; use crate::error::CliError; -use crate::installer::{generated_hooks, hook_forward_command, merge_hermes_config}; +use crate::installer::{generated_hooks, hook_forward_command}; use crate::plugins::lifecycle::ActiveDynamicPluginComponent; use crate::server; @@ -893,13 +893,11 @@ fn write_merged_hermes_hooks(path: &Path) -> Result<(), CliError> { Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), Err(error) => return Err(CliError::Io(error)), }; - let contents = merge_hermes_config( - &existing, - generated_hooks( - CodingAgent::Hermes, - &hook_forward_command(&transparent_hook_executable(), CodingAgent::Hermes), - ), - )?; + let relay = std::env::current_exe() + .map(|path| path.canonicalize().unwrap_or(path)) + .map(crate::plugin_shim::portable_executable_path) + .unwrap_or_else(|_| PathBuf::from("nemo-relay")); + let contents = crate::hermes::transparent_config(&existing, &relay)?; std::fs::write(path, contents)?; Ok(()) } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 71e8d22ba..dcba2d517 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -12,6 +12,7 @@ mod doctor; mod error; mod file_io; mod gateway; +mod hermes; mod install_generation; mod installer; mod json_path; diff --git a/crates/cli/src/mcp_environment.rs b/crates/cli/src/mcp_environment.rs index 733aae7a7..95f4cff2f 100644 --- a/crates/cli/src/mcp_environment.rs +++ b/crates/cli/src/mcp_environment.rs @@ -110,6 +110,21 @@ pub(crate) fn forwarded_names_for_platform( names.into_iter().collect() } +pub(crate) fn config_referenced_names(config: Option<&Value>) -> Vec { + config_referenced_names_for_platform(config, cfg!(windows)) +} + +pub(crate) fn config_referenced_names_for_platform( + config: Option<&Value>, + windows: bool, +) -> Vec { + let mut names = BTreeSet::new(); + if let Some(config) = config { + collect_config_names(config, &mut names, windows); + } + names.into_iter().collect() +} + fn prefix_allowed(name: &str, windows: bool) -> bool { ["NEMO_RELAY_", "OTEL_", "AWS_"].iter().any(|prefix| { if windows { diff --git a/crates/cli/src/plugin_shim/codex.rs b/crates/cli/src/plugin_shim/codex.rs index f9c2e5d0a..d2bc04f9d 100644 --- a/crates/cli/src/plugin_shim/codex.rs +++ b/crates/cli/src/plugin_shim/codex.rs @@ -20,8 +20,8 @@ use crate::installer::merge_hooks; use super::codex_app_server::{CodexAppServerClient, CodexHookMetadata, CodexHooksClient}; use super::shared::{ FileSnapshot, atomic_write, backup, backup_path, current_exe, ensure_table, home_dir, - portable_executable_path, read_json_object, remove_backup, restore_file_snapshot, - snapshot_optional_file, write_json, + portable_executable_path, read_json_object, remove_backup, restore_file_snapshot, shell_quote, + shell_quote_arg_for_platform, shell_quote_for_platform, snapshot_optional_file, write_json, }; pub(super) const CODEX_PLUGIN_ID: &str = "nemo-relay-plugin@nemo-relay-local"; @@ -1114,56 +1114,6 @@ pub(super) fn codex_home_dir() -> Result { Ok(home_dir()?.join(".codex")) } -pub(super) fn shell_quote(path: &Path) -> String { - shell_quote_for_platform(path, cfg!(windows)) -} - -pub(super) fn shell_quote_for_platform(path: &Path, windows: bool) -> String { - shell_quote_arg_for_platform(&path.display().to_string(), windows) -} - -pub(super) fn shell_quote_arg_for_platform(raw: &str, windows: bool) -> String { - if windows { - return cmd_quote_arg(raw); - } - posix_quote_arg(raw) -} - -pub(super) fn posix_quote_arg(raw: &str) -> String { - if raw.is_empty() { - "''".into() - } else if raw - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | ':' | '.' | '_' | '-')) - { - raw.to_string() - } else { - format!("'{}'", raw.replace('\'', "'\\''")) - } -} - -pub(super) fn cmd_quote_arg(raw: &str) -> String { - if raw.chars().all(|ch| { - ch.is_ascii_alphanumeric() - || matches!(ch, '/' | '\\' | ':' | '.' | '_' | '-' | '=' | '@' | '+') - }) { - raw.to_string() - } else { - let mut escaped = String::new(); - for ch in raw.chars() { - match ch { - '%' => escaped.push_str("%%"), - '"' | '^' | '&' | '|' | '<' | '>' => { - escaped.push('^'); - escaped.push(ch); - } - _ => escaped.push(ch), - } - } - format!("\"{escaped}\"") - } -} - pub(super) fn codex_hook_command(gateway_url: &str) -> String { format!( "nemo-relay plugin-shim hook codex --gateway-url {}", diff --git a/crates/cli/src/plugin_shim/mod.rs b/crates/cli/src/plugin_shim/mod.rs index c3d9a15b8..7f45f015e 100644 --- a/crates/cli/src/plugin_shim/mod.rs +++ b/crates/cli/src/plugin_shim/mod.rs @@ -12,6 +12,7 @@ mod shared; pub(crate) use claude::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; pub(crate) use codex::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; pub(crate) use shared::portable_executable_path; +pub(crate) use shared::shell_quote_for_platform; #[cfg(test)] pub(crate) use shared::strip_windows_verbatim_prefix; @@ -34,7 +35,7 @@ use command::{ #[cfg(test)] use shared::MAX_HOOK_RESPONSE_BYTES; use shared::{ - ExecOrStatus, HookForwardError, current_exe, fail_closed, gateway_url, healthz, + ExecOrStatus, HookForwardError, current_exe, fail_closed, gateway_url, healthz, home_dir, plugin_idle_timeout, post_hook, print_check, print_info, relay_binary, }; @@ -72,18 +73,12 @@ fn serve(args: Vec) -> Result { fn hook(agent: CodingAgent, explicit_gateway_url: Option<&str>) -> Result { let url = gateway_url(agent, explicit_gateway_url); - let plugin_launch = match agent { - CodingAgent::Codex | CodingAgent::ClaudeCode => { - Some(crate::sidecar::loopback_bind(&url).and_then(|bind| { - crate::sidecar::resolve_plugin_gateway(agent, &ServerArgs::default(), bind) - .map_err(|error| error.to_string()) - })) - } - CodingAgent::Hermes => None, - }; + let plugin_launch = crate::sidecar::loopback_bind(&url).and_then(|bind| { + crate::sidecar::resolve_plugin_gateway(agent, &ServerArgs::default(), bind) + .map_err(|error| error.to_string()) + }); let max_hook_payload_bytes = plugin_launch .as_ref() - .and_then(|launch| launch.as_ref().ok()) .map_or(DEFAULT_HOOK_STDIN_BYTES, |launch| { launch.max_hook_payload_bytes }); @@ -98,12 +93,9 @@ fn hook(agent: CodingAgent, explicit_gateway_url: Option<&str>) -> Result launch.gateway.ensure().map(|_| ()), - Some(Err(error)) => Err(error.clone()), - None => crate::sidecar::loopback_bind(url) - .and_then(|bind| crate::sidecar::GatewaySpec::new(agent, bind).ensure()) - .map(|_| ()), + |_agent, _url| match plugin_launch.as_ref() { + Ok(launch) => launch.gateway.ensure().map(|_| ()), + Err(error) => Err(error.clone()), }, post_hook, fail_closed, @@ -201,8 +193,15 @@ fn read_hook_payload( fn install(command: PluginShimInstallCommand) -> Result { match command.agent { CodingAgent::Codex => install_codex(&command.gateway_url, &plugin_hooks_path_from_env()?), + CodingAgent::Hermes => { + let home = home_dir()?; + let relay = relay_binary()?; + crate::hermes::install_persistent(&crate::hermes::user_config_path(&home), &relay) + .map_err(|error| error.to_string())?; + Ok(ExitCode::SUCCESS) + } other => Err(format!( - "plugin install supports codex, got {}", + "plugin install supports codex and hermes, got {}", other.as_arg() )), } @@ -211,8 +210,14 @@ fn install(command: PluginShimInstallCommand) -> Result { fn uninstall(command: PluginShimUninstallCommand) -> Result { match command.agent { CodingAgent::Codex => uninstall_codex(&command.gateway_url, &plugin_hooks_path_from_env()?), + CodingAgent::Hermes => { + let home = home_dir()?; + crate::hermes::uninstall_persistent(&crate::hermes::user_config_path(&home)) + .map_err(|error| error.to_string())?; + Ok(ExitCode::SUCCESS) + } other => Err(format!( - "plugin uninstall supports codex, got {}", + "plugin uninstall supports codex and hermes, got {}", other.as_arg() )), } diff --git a/crates/cli/src/plugin_shim/shared.rs b/crates/cli/src/plugin_shim/shared.rs index 8462f718d..3d5ad45d3 100644 --- a/crates/cli/src/plugin_shim/shared.rs +++ b/crates/cli/src/plugin_shim/shared.rs @@ -21,6 +21,56 @@ pub(super) use crate::sidecar::{current_exe, healthz, plugin_idle_timeout, relay pub(super) const MAX_HOOK_RESPONSE_BYTES: usize = 1024 * 1024; +pub(crate) fn shell_quote(path: &Path) -> String { + shell_quote_for_platform(path, cfg!(windows)) +} + +pub(crate) fn shell_quote_for_platform(path: &Path, windows: bool) -> String { + shell_quote_arg_for_platform(&path.display().to_string(), windows) +} + +pub(crate) fn shell_quote_arg_for_platform(raw: &str, windows: bool) -> String { + if windows { + return cmd_quote_arg(raw); + } + posix_quote_arg(raw) +} + +fn posix_quote_arg(raw: &str) -> String { + if raw.is_empty() { + "''".into() + } else if raw + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | ':' | '.' | '_' | '-')) + { + raw.to_string() + } else { + format!("'{}'", raw.replace('\'', "'\\''")) + } +} + +fn cmd_quote_arg(raw: &str) -> String { + if raw.chars().all(|ch| { + ch.is_ascii_alphanumeric() + || matches!(ch, '/' | '\\' | ':' | '.' | '_' | '-' | '=' | '@' | '+') + }) { + raw.to_string() + } else { + let mut escaped = String::new(); + for ch in raw.chars() { + match ch { + '%' => escaped.push_str("%%"), + '"' | '^' | '&' | '|' | '<' | '>' => { + escaped.push('^'); + escaped.push(ch); + } + _ => escaped.push(ch), + } + } + format!("\"{escaped}\"") + } +} + pub(super) fn ensure_table<'a>(doc: &'a mut DocumentMut, name: &str) -> &'a mut Table { if !doc.as_table().contains_key(name) || !doc[name].is_table() { doc[name] = Item::Table(Table::new()); @@ -178,12 +228,7 @@ pub(super) fn post_hook( let hook_path = match agent { CodingAgent::ClaudeCode => "/hooks/claude-code", CodingAgent::Codex => "/hooks/codex", - _ => { - return Err(HookForwardError::not_retryable(format!( - "plugin shim hook forwarding supports claude and codex, got {}", - agent.as_arg() - ))); - } + CodingAgent::Hermes => "/hooks/hermes", }; let (host, port) = parse_loopback_url(url).map_err(HookForwardError::not_retryable)?; let addrs = (host.as_str(), port) @@ -273,7 +318,7 @@ pub(super) fn gateway_url(agent: CodingAgent, explicit: Option<&str>) -> String if let Some(url) = explicit { return url.to_string(); } - if matches!(agent, CodingAgent::ClaudeCode) + if matches!(agent, CodingAgent::ClaudeCode | CodingAgent::Hermes) && let Ok(url) = env::var("NEMO_RELAY_GATEWAY_URL") { return url; diff --git a/crates/cli/src/session.rs b/crates/cli/src/session.rs index cf2a90bae..1c93a3411 100644 --- a/crates/cli/src/session.rs +++ b/crates/cli/src/session.rs @@ -94,7 +94,18 @@ pub(crate) struct GatewayCallPrep { pub(crate) model_name: Option, pub(crate) owner_subagent_id: Option, pub(crate) bypass_managed_pipeline: bool, - pub(crate) prune_empty_session_on_finish: bool, + pub(crate) session_finish: GatewaySessionFinish, +} + +/// Cleanup policy for the session selected by one gateway request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GatewaySessionFinish { + /// Keep an explicit, correlated, or sole active session for later lifecycle events. + Retain, + /// Remove a startup-probe session when it never opened observable scopes. + PruneIfEmpty, + /// Close an isolated synthetic session as soon as its only gateway call completes. + Close, } struct Session { @@ -389,11 +400,7 @@ impl SessionManager { let config = self.default_config.session_config_from_headers(headers); self.resolve_start_alias(&mut start, config.clone()).await?; let mut sessions = self.inner.lock().await; - let session_id = start - .session_id - .clone() - .or_else(|| single_active_session_id(&sessions)) - .unwrap_or_else(|| format!("{}-gateway", AgentKind::Gateway.as_str())); + let (session_id, session_finish) = gateway_session_for_call(&start, &sessions); // Match `start_llm`: when this path creates a brand-new session (real agent's gateway // request beats its SessionStart hook), label the session by the provider so ATIF and // Phoenix scopes carry the agent identity instead of freezing on "gateway". @@ -405,10 +412,15 @@ impl SessionManager { let result = session.prepare_gateway_call(start).await; match result { Ok(mut prep) => { - prep.prune_empty_session_on_finish = prep.bypass_managed_pipeline + prep.session_finish = if prep.bypass_managed_pipeline && sessions .get(&session_id) - .is_some_and(|session| session.is_empty()); + .is_some_and(|session| session.is_empty()) + { + GatewaySessionFinish::PruneIfEmpty + } else { + session_finish + }; Ok(prep) } Err(error) => { @@ -429,25 +441,41 @@ impl SessionManager { /// Runtime-managed LLM spans are emitted outside the session lock, so the session keeps a small /// in-flight counter to prevent the idle sweeper from closing a turn while an upstream /// provider request or streaming response is still active. - pub(crate) async fn finish_gateway_call(&self, session_id: &str, prune_empty_session: bool) { + pub(crate) async fn finish_gateway_call(&self, session_id: &str, finish: GatewaySessionFinish) { let mut sessions = self.inner.lock().await; if let Some(session) = sessions.get_mut(session_id) { session.finish_gateway_call(); } - if prune_empty_session - && sessions - .get(session_id) - .is_some_and(|session| session.is_empty() && session.active_gateway_calls == 0) + let completed = sessions.get(session_id).is_some_and(|session| { + session.active_gateway_calls == 0 + && match finish { + GatewaySessionFinish::Retain => false, + GatewaySessionFinish::PruneIfEmpty => session.is_empty(), + GatewaySessionFinish::Close => true, + } + }); + let mut closing = completed.then(|| sessions.remove(session_id)).flatten(); + drop(sessions); + + if finish == GatewaySessionFinish::Close + && let Some(session) = closing.as_mut() + && let Err(error) = session + .close_for_shutdown("uncorrelated_gateway_call_complete") + .await { - sessions.remove(session_id); + eprintln!( + "nemo-relay CLI gateway: failed to close isolated session {session_id}: {error}" + ); } } /// Returns true while any session still owns active observable work. /// - /// Codex plugin sessions can emit `SessionStart` without a matching `SessionEnd`, so metadata-only - /// sessions must not keep the hook-supervised sidecar alive forever. Open scopes and in-flight - /// tool, LLM, or gateway work still block plugin idle shutdown. + /// Host sessions can remain durable after their current turn ends: Codex may omit `SessionEnd`, + /// while Hermes keeps a session open for later resumption. A dormant agent scope must therefore + /// not keep the hook-supervised sidecar alive forever. Active turns, subagents, tools, LLMs, and + /// gateway calls still block idle shutdown; [`Self::close_all`] balances the dormant agent scope + /// when the gateway exits. pub(crate) async fn has_open_sessions(&self) -> bool { self.inner .lock() @@ -898,8 +926,7 @@ impl Session { } fn blocks_plugin_idle_shutdown(&self) -> bool { - self.agent_scope.is_some() - || self.turn_scope.is_some() + self.turn_scope.is_some() || !self.subagents.is_empty() || !self.subagent_stacks.is_empty() || !self.subagent_stack.is_empty() @@ -1074,7 +1101,7 @@ impl Session { model_name: start.model_name, owner_subagent_id: owner.subagent_id, bypass_managed_pipeline: policy.bypasses_managed_pipeline(), - prune_empty_session_on_finish: false, + session_finish: GatewaySessionFinish::Retain, }) }) .await; @@ -2549,6 +2576,32 @@ fn single_active_session_id(sessions: &HashMap) -> Option, +) -> (String, GatewaySessionFinish) { + if let Some(session_id) = start.session_id.clone() { + return (session_id, GatewaySessionFinish::Retain); + } + if let Some(session_id) = single_active_session_id(sessions) { + return (session_id, GatewaySessionFinish::Retain); + } + if sessions.is_empty() { + return ( + format!("{}-gateway", AgentKind::Gateway.as_str()), + GatewaySessionFinish::Retain, + ); + } + ( + format!("gateway-isolated-{}", uuid::Uuid::now_v7()), + GatewaySessionFinish::Close, + ) +} + #[cfg(test)] #[path = "../tests/coverage/session_tests.rs"] mod tests; diff --git a/crates/cli/src/setup.rs b/crates/cli/src/setup.rs index 9a717cc36..46c5ee08e 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/setup.rs @@ -26,7 +26,7 @@ mod model; pub(crate) use self::model::reset; use self::model::{ ConfigScope, SetupAnswers, agent_key_and_command, build_config, detect_installed_agents, - hermes_hook_targets, hermes_hooks_path_for_scope, home_dir, install_hermes_hooks, + hermes_config_path_for_agents, home_dir, install_hermes_integration, plugins_edit_command_for_scope, plugins_resume_command, preview_paths, read_existing_defaults, save_config, }; @@ -110,16 +110,11 @@ pub(crate) async fn run(agent_hint: Option) -> Result<(), CliError> let home = home_dir().ok_or_else(|| { CliError::Config("cannot determine home directory (set $HOME or $USERPROFILE)".into()) })?; - answers.hermes_hooks_path = - hermes_hooks_path_for_scope(&answers.agents, answers.scope, &cwd, &home); + answers.hermes_hooks_path = hermes_config_path_for_agents(&answers.agents, &home); let doc = build_config(&answers); let mut preview_paths = preview_paths(answers.scope, &cwd, &home); - preview_paths.extend( - hermes_hook_targets(answers.scope, &cwd, &home) - .into_iter() - .filter(|_| answers.agents.contains(&CodingAgent::Hermes)), - ); + preview_paths.extend(answers.hermes_hooks_path.iter().cloned()); if !confirm_summary(&preview_paths, &doc)? { return Err(CliError::Config("setup cancelled — no config saved".into())); @@ -127,7 +122,7 @@ pub(crate) async fn run(agent_hint: Option) -> Result<(), CliError> let mut written = save_config(&doc, answers.scope, &cwd, &home, agent_hint)?; if answers.agents.contains(&CodingAgent::Hermes) { - written.extend(install_hermes_hooks(answers.scope, &cwd, &home)?); + written.extend(install_hermes_integration(&home)?); } println!(); println!(" ✓ Saved:"); diff --git a/crates/cli/src/setup/model.rs b/crates/cli/src/setup/model.rs index fe03003e7..8167615a2 100644 --- a/crates/cli/src/setup/model.rs +++ b/crates/cli/src/setup/model.rs @@ -10,7 +10,6 @@ use toml_edit::{DocumentMut, Item, Table, value}; use crate::config::CodingAgent; use crate::config::{PluginsEditCommand, PluginsScopeArgs}; use crate::error::CliError; -use crate::installer::{hermes_hooks, hook_forward_command, merge_hermes_config}; /// Where the setup saves its output. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -67,9 +66,8 @@ pub(super) fn plugins_resume_command(scope: ConfigScope) -> &'static str { pub(crate) struct SetupAnswers { pub scope: ConfigScope, pub agents: Vec, - /// Path recorded under `[agents.hermes].hooks_path` when hermes is selected. Set by `run` - /// from `hermes_hooks_path_for_scope` so the wizard preview shows the file the launcher - /// will reference. `None` when hermes wasn't selected. + /// User-owned Hermes config recorded under `[agents.hermes].hooks_path`. Set by `run` so the + /// wizard preview and transparent launcher reference the same host configuration. pub hermes_hooks_path: Option, } @@ -245,6 +243,23 @@ pub(super) fn merge_agents_entry(dst: &mut DocumentMut, src: &DocumentMut, agent /// In both cases this targets the *project* layer; global and system layers are left to direct /// editing because they typically aren't owned by the wizard. pub(crate) fn reset(agent_hint: Option) -> Result<(), CliError> { + reset_project_config(agent_hint)?; + if matches!(agent_hint, Some(CodingAgent::Hermes)) { + let home = home_dir().ok_or_else(|| { + CliError::Config("cannot determine home directory (set $HOME or $USERPROFILE)".into()) + })?; + let removed = crate::hermes::uninstall_persistent(&crate::hermes::user_config_path(&home))?; + for path in removed { + println!( + " ✓ Removed Relay-owned Hermes state from {}", + path.display() + ); + } + } + Ok(()) +} + +fn reset_project_config(agent_hint: Option) -> Result<(), CliError> { let cwd = std::env::current_dir()?; let path = cwd.join(".nemo-relay").join("config.toml"); if !path.exists() { @@ -293,59 +308,26 @@ pub(crate) fn reset(agent_hint: Option) -> Result<(), CliError> { Ok(()) } -/// Returns the Hermes hooks file path that should be recorded for the selected setup scope. -pub(crate) fn hermes_hooks_path_for_scope( +/// Returns Hermes's user-owned configuration path when Hermes is selected. Relay's project/global +/// scope does not change where the Hermes host itself reads MCP servers and shell hooks. +pub(crate) fn hermes_config_path_for_agents( agents: &[CodingAgent], - scope: ConfigScope, - cwd: &Path, home: &Path, ) -> Option { if !agents.contains(&CodingAgent::Hermes) { return None; } - match scope { - ConfigScope::Project | ConfigScope::Both => Some(cwd.join(".hermes").join("config.yaml")), - ConfigScope::Global => Some(home.join(".hermes").join("config.yaml")), - } -} - -/// Writes/merges `.hermes/config.yaml` hook config for every scope-applicable location so hermes -/// fires `nemo-relay hook-forward hermes` on every hook event after setup. Idempotent: existing -/// hook entries are preserved and our generated groups are appended only when missing. -/// -/// Returns the list of paths actually written so callers can surface them to the user. -pub(crate) fn install_hermes_hooks( - scope: ConfigScope, - cwd: &Path, - home: &Path, -) -> Result, CliError> { - let generated = hermes_hooks(&hook_forward_command("nemo-relay", CodingAgent::Hermes)); - let mut written = Vec::new(); - for path in hermes_hook_targets(scope, cwd, home) { - let existing = match std::fs::read_to_string(&path) { - Ok(raw) => raw, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(error) => return Err(CliError::Io(error)), - }; - let merged = merge_hermes_config(&existing, generated.clone())?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&path, merged)?; - written.push(path); - } - Ok(written) + Some(crate::hermes::user_config_path(home)) } -pub(super) fn hermes_hook_targets(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec { - let mut targets = Vec::new(); - if matches!(scope, ConfigScope::Project | ConfigScope::Both) { - targets.push(cwd.join(".hermes").join("config.yaml")); - } - if matches!(scope, ConfigScope::Global | ConfigScope::Both) { - targets.push(home.join(".hermes").join("config.yaml")); - } - targets +/// Installs Hermes's lifecycle-bound MCP client and exact trusted Relay hooks transactionally. +pub(crate) fn install_hermes_integration(home: &Path) -> Result, CliError> { + let relay = std::env::current_exe().map_err(|error| { + CliError::Install(format!( + "failed to resolve the nemo-relay executable: {error}" + )) + })?; + crate::hermes::install_persistent(&crate::hermes::user_config_path(home), &relay) } /// Pre-filled wizard defaults read from an existing `config.toml`. When the file is missing or diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs index ea26f48ed..b14b3e485 100644 --- a/crates/cli/src/sidecar.rs +++ b/crates/cli/src/sidecar.rs @@ -303,7 +303,7 @@ fn incompatible_relay_error(agent: CodingAgent, url: &str) -> String { let remediation = match agent { CodingAgent::Codex => "run `nemo-relay install codex --force`", CodingAgent::ClaudeCode => "run `nemo-relay install claude-code --force`", - CodingAgent::Hermes => "restart the configured Relay gateway", + CodingAgent::Hermes => "rerun `nemo-relay config hermes`", }; format!( "{url} is occupied by NeMo Relay with a different version or persistent configuration; stop it, wait for its idle shutdown, or {remediation} before retrying" diff --git a/crates/cli/src/sidecar/state.rs b/crates/cli/src/sidecar/state.rs index 4caecdc45..9ff2f4a99 100644 --- a/crates/cli/src/sidecar/state.rs +++ b/crates/cli/src/sidecar/state.rs @@ -223,6 +223,7 @@ pub(crate) fn publish_owner_from_env(address: SocketAddr) -> Result<(), String> let agent = match agent.as_deref() { Some("codex") => CodingAgent::Codex, Some("claude" | "claude-code") => CodingAgent::ClaudeCode, + Some("hermes") => CodingAgent::Hermes, Some(other) => return Err(format!("unsupported bootstrap agent {other}")), None => { return Err(format!( diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index f0b60e9f1..4f5ef8850 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -249,6 +249,125 @@ fn cli_mcp_does_not_launch_gateway_when_stdio_closes_before_request() { assert!(find_runtime_file(temp.path(), "codex-sidecar.log").is_none()); } +#[cfg(unix)] +#[test] +fn cli_internal_hermes_install_writes_mcp_hooks_trust_and_doctor_ready_state() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let hermes_home = temp.path().join("hermes"); + let xdg = temp.path().join("xdg"); + let runtime = temp.path().join("runtime"); + let bin = temp.path().join("bin"); + for directory in [&home, &hermes_home, &xdg, &runtime, &bin] { + std::fs::create_dir_all(directory).unwrap(); + } + let hermes = bin.join("hermes"); + std::fs::write(&hermes, "#!/bin/sh\necho 'Hermes 1.0.0'\n").unwrap(); + std::fs::set_permissions(&hermes, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = std::env::join_paths(std::iter::once(bin.clone()).chain(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + ))) + .unwrap(); + + let install = Command::new(gateway_bin()) + .args(["plugin-shim", "install", "hermes"]) + .env("HOME", &home) + .env("HERMES_HOME", &hermes_home) + .env("XDG_CONFIG_HOME", &xdg) + .env("XDG_RUNTIME_DIR", &runtime) + .env("PATH", &path) + .env("OPENAI_API_KEY", "not-written-to-config") + .output() + .unwrap(); + assert!( + install.status.success(), + "{}", + String::from_utf8_lossy(&install.stderr) + ); + + let config_path = hermes_home.join("config.yaml"); + let config: serde_json::Value = + serde_yaml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap(); + let server = &config["mcp_servers"]["nemo-relay"]; + assert_eq!(server["command"], gateway_bin()); + assert_eq!( + server["args"], + serde_json::json!(["mcp", "--agent", "hermes"]) + ); + assert_eq!(server["env"]["NEMO_RELAY_GATEWAY_BIND"], "127.0.0.1:47632"); + assert_eq!(server["env"]["OPENAI_API_KEY"], "${OPENAI_API_KEY}"); + assert!( + !std::fs::read_to_string(&config_path) + .unwrap() + .contains("not-written-to-config") + ); + let command = config["hooks"]["on_session_start"][0]["command"] + .as_str() + .unwrap(); + assert!(command.contains("plugin-shim hook hermes")); + let approvals: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(hermes_home.join("shell-hooks-allowlist.json")).unwrap(), + ) + .unwrap(); + let approvals = approvals["approvals"].as_array().unwrap(); + assert_eq!(approvals.len(), 13); + assert!(approvals.iter().all(|entry| entry["command"] == command)); + + let relay_config_dir = xdg.join("nemo-relay"); + std::fs::create_dir_all(&relay_config_dir).unwrap(); + std::fs::write( + relay_config_dir.join("config.toml"), + format!( + "[agents.hermes]\ncommand = {:?}\nhooks_path = {:?}\n", + hermes.display().to_string(), + config_path.display().to_string() + ), + ) + .unwrap(); + let doctor = Command::new(gateway_bin()) + .args(["doctor", "hermes", "--json"]) + .env("HOME", &home) + .env("HERMES_HOME", &hermes_home) + .env("XDG_CONFIG_HOME", &xdg) + .env("XDG_RUNTIME_DIR", &runtime) + .env("PATH", &path) + .env("OPENAI_API_KEY", "runtime-only") + .output() + .unwrap(); + assert!( + doctor.status.success(), + "{}", + String::from_utf8_lossy(&doctor.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&doctor.stdout).unwrap(); + assert_eq!(report["agents"][0]["name"], "hermes"); + assert_eq!(report["agents"][0]["status"], "pass"); + assert!( + report["agents"][0]["annotation"] + .as_str() + .unwrap() + .contains("MCP lifecycle") + ); + + let uninstall = Command::new(gateway_bin()) + .args(["plugin-shim", "uninstall", "hermes"]) + .env("HOME", &home) + .env("HERMES_HOME", &hermes_home) + .env("XDG_CONFIG_HOME", &xdg) + .output() + .unwrap(); + assert!( + uninstall.status.success(), + "{}", + String::from_utf8_lossy(&uninstall.stderr) + ); + assert!(!config_path.exists()); + assert!(!hermes_home.join("shell-hooks-allowlist.json").exists()); + assert!(!hermes_home.join(".nemo-relay-generation").exists()); +} + fn start_mcp_client(temp: &std::path::Path, bind: SocketAddr) -> (Child, ChildStdin) { start_mcp_client_for_agent(temp, bind, "codex") } @@ -309,7 +428,7 @@ fn start_mcp_client_with_idle_timeout( #[test] fn cli_hooks_and_mcp_share_the_same_persistent_identity_for_each_host() { - for agent in ["codex", "claude"] { + for agent in ["codex", "claude", "hermes"] { let temp = tempfile::tempdir().unwrap(); let probe = TcpListener::bind("127.0.0.1:0").unwrap(); let address = probe.local_addr().unwrap(); @@ -779,7 +898,14 @@ fn relay_health(address: SocketAddr) -> serde_json::Value { #[test] fn cli_mcp_clients_share_gateway_until_final_idle_shutdown() { - for (first_agent, second_agent) in [("codex", "claude"), ("claude", "codex")] { + for (first_agent, second_agent) in [ + ("codex", "claude"), + ("claude", "codex"), + ("codex", "hermes"), + ("hermes", "codex"), + ("claude", "hermes"), + ("hermes", "claude"), + ] { let temp = tempfile::tempdir().unwrap(); let (mut first, first_stdin) = start_mcp_client_for_agent(temp.path(), "127.0.0.1:0".parse().unwrap(), first_agent); diff --git a/crates/cli/tests/coverage/gateway_tests.rs b/crates/cli/tests/coverage/gateway_tests.rs index c81871457..1c247e441 100644 --- a/crates/cli/tests/coverage/gateway_tests.rs +++ b/crates/cli/tests/coverage/gateway_tests.rs @@ -852,6 +852,7 @@ async fn streaming_gateway_call_guard_finishes_when_body_is_dropped() { prep.session_id, prep.owner_subagent_id, Arc::new(Mutex::new(None)), + prep.session_finish, ); drop(body); @@ -928,6 +929,7 @@ async fn streaming_body_records_final_response_for_turn_output() { session_id, owner_subagent_id, Arc::new(Mutex::new(Some(final_response.clone()))), + prep.session_finish, ); let _ = body.collect().await.unwrap(); diff --git a/crates/cli/tests/coverage/hermes_tests.rs b/crates/cli/tests/coverage/hermes_tests.rs new file mode 100644 index 000000000..c4760a846 --- /dev/null +++ b/crates/cli/tests/coverage/hermes_tests.rs @@ -0,0 +1,554 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::cell::Cell; +use std::path::Path; +use std::time::{Duration, UNIX_EPOCH}; + +use serde_json::{Value, json}; + +use super::*; + +fn relay_binary(root: &Path) -> PathBuf { + let path = root.join("NeMo Relay's bin").join("nemo-relay"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"relay").unwrap(); + path +} + +fn paths(root: &Path) -> PersistentPaths { + PersistentPaths::for_config(root.join("config.yaml")).unwrap() +} + +fn yaml(path: &Path) -> Value { + serde_yaml::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() +} + +fn json_file(path: &Path) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() +} + +#[test] +fn user_config_path_uses_hermes_home_or_platform_home() { + let default_home = Path::new("/users/relay"); + assert_eq!( + user_config_path_with_override(default_home, None), + default_home.join(".hermes/config.yaml") + ); + assert_eq!( + user_config_path_with_override(default_home, Some("/profiles/hermes".into())), + Path::new("/profiles/hermes/config.yaml") + ); + assert_eq!( + user_config_path_with_override(default_home, Some("".into())), + default_home.join(".hermes/config.yaml") + ); +} + +#[test] +fn install_lock_serializes_concurrent_hermes_config_updates() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("config.yaml"); + let _first = acquire_install_lock(&config, Duration::from_millis(10)).unwrap(); + + let error = acquire_install_lock(&config, Duration::ZERO).unwrap_err(); + + assert!( + error.contains("another Hermes integration update"), + "{error}" + ); +} + +#[test] +fn install_uses_the_native_hermes_allowlist_lock() { + let temp = tempfile::tempdir().unwrap(); + let allowlist = temp.path().join("shell-hooks-allowlist.json"); + let _first = acquire_allowlist_lock(&allowlist, Duration::from_millis(10)).unwrap(); + + let error = acquire_allowlist_lock(&allowlist, Duration::ZERO).unwrap_err(); + + assert!(error.contains("shell-hook approval update"), "{error}"); + assert!(temp.path().join("shell-hooks-allowlist.json.lock").exists()); +} + +#[test] +fn hook_command_round_trips_paths_and_recognizes_owned_legacy_spellings() { + let relay = Path::new("/tmp/NeMo $Relay`test'/bin/nemo-relay"); + assert_eq!( + persistent_hook_command_for_platform(relay, false), + "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' plugin-shim hook hermes" + ); + assert_eq!( + persistent_hook_command_for_platform( + Path::new(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"), + true, + ), + r#""C:\Program Files\NeMo 100%%\bin\nemo-relay.exe" plugin-shim hook hermes"# + ); + for command in [ + "nemo-relay hook-forward hermes", + "/old/path/nemo-relay plugin-shim hook hermes", + "'/old/plugin-shim hook hermes/nemo-relay' plugin-shim hook hermes", + "'/old install/nemo-relay' plugin-shim hook hermes --gateway-url http://127.0.0.1:47632", + r#""C:\Program Files\NeMo 100%%\nemo-relay.exe" plugin-shim hook hermes"#, + ] { + assert!( + is_managed_hook_command(command), + "not recognized: {command}" + ); + } + for command in [ + "relay-helper hook-forward hermes", + "echo nemo-relay hook-forward hermes", + "nemo-relay plugin-shim hook codex", + "nemo-relay-safe plugin-shim hook hermes", + ] { + assert!(!is_managed_hook_command(command), "overmatched: {command}"); + } +} + +#[test] +fn forwarded_environment_is_minimal_and_includes_explicit_config_references() { + let environment = vec![ + "AWS_REGION".into(), + "NEMO_RELAY_CUSTOM".into(), + "NEMO_RELAY_WORKER_TOKEN".into(), + "UNRELATED_SECRET".into(), + ]; + let config = json!({ + "header_env": {"Authorization": "CUSTOM_EXPORT_TOKEN"}, + "secret_access_key_var": "AWS_PRIVATE_SECRET", + "session_token_var": "NEMO_RELAY_WORKER_TOKEN" + }); + let names = forwarded_environment_names(&environment, Some(&config)); + + assert!(names.contains(&"ANTHROPIC_API_KEY".into())); + assert!(names.contains(&"OPENAI_API_KEY".into())); + assert!(names.contains(&"AWS_REGION".into())); + assert!(names.contains(&"NEMO_RELAY_CUSTOM".into())); + assert!(names.contains(&"CUSTOM_EXPORT_TOKEN".into())); + assert!(names.contains(&"AWS_PRIVATE_SECRET".into())); + assert!(!names.contains(&"NEMO_RELAY_WORKER_TOKEN".into())); + assert!(!names.contains(&"UNRELATED_SECRET".into())); + assert!(!names.contains(&"OTEL_EXPORTER_OTLP_ENDPOINT".into())); +} + +#[test] +fn persistent_config_migrates_owned_state_and_preserves_unrelated_config() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let generation = temp.path().join(GENERATION_FILE_NAME); + let command = persistent_hook_command(&relay); + let existing = r#" +model: keep-me +mcp_servers: + filesystem: + command: fs-mcp + nemo-relay: + command: /old/bin/nemo-relay + args: [mcp, --agent, hermes] +hooks: + on_session_start: + - command: custom-hook + timeout: 9 + - command: nemo-relay hook-forward hermes + timeout: 30 + legacy_event: + - command: /old/bin/nemo-relay plugin-shim hook hermes + custom_event: + - command: keep-custom +"#; + let merged = persistent_config( + Some(existing), + &relay, + &command, + &generation, + &["AWS_REGION".into()], + ) + .unwrap(); + + assert_eq!(merged["model"], json!("keep-me")); + assert_eq!( + merged["mcp_servers"]["filesystem"]["command"], + json!("fs-mcp") + ); + assert_eq!( + merged["mcp_servers"][MCP_SERVER_NAME], + expected_mcp_server(&relay, &generation, &["AWS_REGION".into()]) + ); + assert_eq!( + merged["mcp_servers"][MCP_SERVER_NAME]["env"]["AWS_REGION"], + json!("${AWS_REGION}") + ); + assert_eq!( + merged["hooks"]["on_session_start"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert_eq!( + merged["hooks"]["on_session_start"][0]["command"], + json!("custom-hook") + ); + assert_eq!( + merged["hooks"]["on_session_start"][1]["command"], + json!(command) + ); + assert!(merged["hooks"].get("legacy_event").is_none()); + assert_eq!( + merged["hooks"]["custom_event"][0]["command"], + json!("keep-custom") + ); + for event in HERMES_HOOK_EVENTS { + let groups = merged["hooks"][event].as_array().unwrap(); + assert_eq!( + groups + .iter() + .filter(|group| group["command"] == json!(command)) + .count(), + 1, + "event {event}" + ); + } +} + +#[test] +fn trusted_hooks_migrates_only_relay_approvals_and_records_every_event() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let command = persistent_hook_command(&relay); + let existing = json!({ + "schema": 7, + "approvals": [ + {"event": "custom", "command": "custom-hook", "approved_at": "keep"}, + {"event": "on_session_start", "command": "nemo-relay hook-forward hermes"}, + {"event": "on_session_end", "command": "/old/nemo-relay plugin-shim hook hermes"} + ] + }); + let now = UNIX_EPOCH + Duration::from_secs(1_700_000_000); + let merged = trusted_hooks( + Some(&serde_json::to_string(&existing).unwrap()), + &command, + &relay, + now, + ) + .unwrap(); + let approvals = merged["approvals"].as_array().unwrap(); + + assert_eq!(merged["schema"], json!(7)); + assert!( + approvals + .iter() + .any(|entry| entry["command"] == json!("custom-hook")) + ); + assert_eq!(approvals.len(), HERMES_HOOK_EVENTS.len() + 1); + for event in HERMES_HOOK_EVENTS { + let entries = approvals + .iter() + .filter(|entry| entry["event"] == json!(event) && entry["command"] == json!(command)) + .collect::>(); + assert_eq!(entries.len(), 1, "event {event}"); + assert_eq!( + entries[0]["approved_at"], + json!("2023-11-14T22:13:20.000000Z") + ); + assert!(entries[0].get("script_mtime_at_approval").is_some()); + } +} + +#[test] +fn install_is_verified_idempotent_and_rotates_the_generation() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + let environment = vec!["OTEL_SERVICE_NAME".into()]; + let now = UNIX_EPOCH + Duration::from_secs(1_700_000_000); + + let written = + install_persistent_with(paths.clone(), &relay, &environment, None, now, atomic_write) + .unwrap(); + assert_eq!(written, paths.all()); + let first_generation = std::fs::read_to_string(&paths.generation).unwrap(); + + install_persistent_with(paths.clone(), &relay, &environment, None, now, atomic_write).unwrap(); + let second_generation = std::fs::read_to_string(&paths.generation).unwrap(); + assert_ne!(first_generation, second_generation); + + let config = yaml(&paths.config); + assert_eq!( + config["hooks"]["on_session_start"] + .as_array() + .unwrap() + .iter() + .filter(|group| is_managed_hook_command(group["command"].as_str().unwrap())) + .count(), + 1 + ); + assert_eq!( + config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_FILE_ENV], + json!(paths.generation.display().to_string()) + ); + assert_eq!( + config["mcp_servers"][MCP_SERVER_NAME]["env"]["OTEL_SERVICE_NAME"], + json!("${OTEL_SERVICE_NAME}") + ); + assert_eq!( + json_file(&paths.allowlist)["approvals"] + .as_array() + .unwrap() + .len(), + HERMES_HOOK_EVENTS.len() + ); +} + +#[test] +fn install_rolls_back_config_allowlist_and_generation_after_write_failure() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + let originals = [ + (&paths.config, b"model: original\n".as_slice()), + ( + &paths.allowlist, + b"{\"approvals\":[{\"event\":\"x\",\"command\":\"custom\"}]}\n".as_slice(), + ), + (&paths.generation, b"original-generation\n".as_slice()), + ]; + for (path, bytes) in originals { + std::fs::write(path, bytes).unwrap(); + } + let before = paths.all().map(|path| std::fs::read(path).unwrap()); + let writes = Cell::new(0); + + let error = install_persistent_with( + paths.clone(), + &relay, + &[], + None, + UNIX_EPOCH, + |path, bytes| { + let write = writes.get() + 1; + writes.set(write); + if write == 3 { + return Err("injected config write failure".into()); + } + atomic_write(path, bytes) + }, + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("injected config write failure"), "{error}"); + for (index, path) in paths.all().iter().enumerate() { + assert_eq!( + std::fs::read(path).unwrap(), + before[index], + "{}", + path.display() + ); + } +} + +#[test] +fn install_rolls_back_after_post_write_verification_failure() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + std::fs::write(&paths.config, "model: original\n").unwrap(); + std::fs::write(&paths.allowlist, "{\"approvals\":[]}\n").unwrap(); + std::fs::write(&paths.generation, "old\n").unwrap(); + let before = paths.all().map(|path| std::fs::read(path).unwrap()); + let corrupted = Cell::new(false); + + let error = install_persistent_with( + paths.clone(), + &relay, + &[], + None, + UNIX_EPOCH, + |path, bytes| { + if path == paths.config && !corrupted.replace(true) { + return atomic_write(path, b"hooks: invalid-shape\n"); + } + atomic_write(path, bytes) + }, + ) + .unwrap_err() + .to_string(); + + assert!( + error.contains("Hermes MCP server did not persist exactly"), + "{error}" + ); + for (index, path) in paths.all().iter().enumerate() { + assert_eq!( + std::fs::read(path).unwrap(), + before[index], + "{}", + path.display() + ); + } +} + +#[test] +fn uninstall_removes_only_relay_owned_hermes_state() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + std::fs::write( + &paths.config, + "model: keep\nmcp_servers:\n filesystem:\n command: fs-mcp\nhooks:\n custom_event:\n - command: custom-hook\n", + ) + .unwrap(); + std::fs::write( + &paths.allowlist, + "{\"owner\":\"user\",\"approvals\":[{\"event\":\"custom_event\",\"command\":\"custom-hook\"}]}\n", + ) + .unwrap(); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + + let removed = uninstall_persistent_with(paths.clone(), atomic_write).unwrap(); + + assert_eq!(removed, paths.all()); + assert!(!paths.generation.exists()); + let config = yaml(&paths.config); + assert_eq!(config["model"], json!("keep")); + assert_eq!( + config["mcp_servers"]["filesystem"]["command"], + json!("fs-mcp") + ); + assert!(config["mcp_servers"].get(MCP_SERVER_NAME).is_none()); + assert_eq!( + config["hooks"]["custom_event"][0]["command"], + json!("custom-hook") + ); + let allowlist = json_file(&paths.allowlist); + assert_eq!(allowlist["owner"], json!("user")); + assert_eq!(allowlist["approvals"].as_array().unwrap().len(), 1); + assert_eq!(allowlist["approvals"][0]["command"], json!("custom-hook")); +} + +#[test] +fn uninstall_rolls_back_every_file_when_commit_fails() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + std::fs::write(&paths.config, "model: keep\n").unwrap(); + std::fs::write(&paths.allowlist, "{\"owner\":\"keep\"}\n").unwrap(); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + let before = paths.all().map(|path| std::fs::read(path).unwrap()); + let writes = Cell::new(0); + + let error = uninstall_persistent_with(paths.clone(), |path, bytes| { + let write = writes.get() + 1; + writes.set(write); + if write == 2 { + return Err("injected uninstall config failure".into()); + } + atomic_write(path, bytes) + }) + .unwrap_err() + .to_string(); + + assert!( + error.contains("injected uninstall config failure"), + "{error}" + ); + for (index, path) in paths.all().iter().enumerate() { + assert_eq!( + std::fs::read(path).unwrap(), + before[index], + "{}", + path.display() + ); + } +} + +#[test] +fn uninstall_noops_without_creating_a_hermes_home() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("missing-hermes-home"); + let config = home.join("config.yaml"); + + assert!(uninstall_persistent(&config).unwrap().is_empty()); + assert!(!home.exists()); +} + +#[test] +fn transparent_config_suppresses_only_the_managed_mcp_and_uses_one_relay_hook() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let command = persistent_hook_command(&relay); + let existing = format!( + r#" +mcp_servers: + nemo-relay: + command: {relay} + args: [mcp, --agent, hermes] + filesystem: + command: fs-mcp +hooks: + on_session_start: + - command: nemo-relay hook-forward hermes + - command: custom-hook +"#, + relay = relay.display() + ); + let patched: Value = + serde_yaml::from_str(&transparent_config(&existing, &relay).unwrap()).unwrap(); + + assert!(patched["mcp_servers"].get(MCP_SERVER_NAME).is_none()); + assert_eq!( + patched["mcp_servers"]["filesystem"]["command"], + json!("fs-mcp") + ); + for event in HERMES_HOOK_EVENTS { + let groups = patched["hooks"][event].as_array().unwrap(); + assert_eq!( + groups + .iter() + .filter_map(|group| group.get("command").and_then(Value::as_str)) + .filter(|candidate| is_managed_hook_command(candidate)) + .count(), + 1, + "event {event}" + ); + assert!( + groups + .iter() + .any(|group| group["command"] == json!(command)) + ); + } + assert!( + patched["hooks"]["on_session_start"] + .as_array() + .unwrap() + .iter() + .any(|group| group["command"] == json!("custom-hook")) + ); +} + +#[test] +fn malformed_user_files_fail_before_any_state_is_replaced() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + std::fs::write(&paths.config, "hooks: [not-an-object]\n").unwrap(); + std::fs::write(&paths.allowlist, "{\"approvals\":[]}").unwrap(); + std::fs::write(&paths.generation, "old\n").unwrap(); + let before = paths.all().map(|path| std::fs::read(path).unwrap()); + + assert!( + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write,) + .is_err() + ); + for (index, path) in paths.all().iter().enumerate() { + assert_eq!(std::fs::read(path).unwrap(), before[index]); + } +} diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index 1615642cd..8a3a334e0 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -615,7 +615,7 @@ fn prepares_hermes_hook_environment() { Some(&hooks_path) ); let hooks = std::fs::read_to_string(&hooks_path).unwrap(); - assert!(hooks.contains("hook-forward hermes")); + assert!(hooks.contains("plugin-shim hook hermes")); assert!(prepared.notes[0].contains("temporarily merged")); prepared.restore().unwrap(); @@ -700,7 +700,15 @@ fn hermes_patch_restore_restores_original_file() { std::env::set_current_dir(temp.path()).unwrap(); let hooks_path = temp.path().join("hermes-home/config.yaml"); std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); - let original = "hooks:\n PreToolUse: []\n"; + let original = r#"mcp_servers: + nemo-relay: + command: nemo-relay + args: [mcp, --agent, hermes] + filesystem: + command: fs-mcp +hooks: + PreToolUse: [] +"#; std::fs::write(&hooks_path, original).unwrap(); let resolved = ResolvedConfig { gateway: GatewayConfig::default(), @@ -723,10 +731,13 @@ fn hermes_patch_restore_restores_original_file() { ) .unwrap(); - assert!( - std::fs::read_to_string(&hooks_path) - .unwrap() - .contains("hook-forward hermes") + let patched = std::fs::read_to_string(&hooks_path).unwrap(); + assert!(patched.contains("plugin-shim hook hermes")); + let patched_yaml: serde_json::Value = serde_yaml::from_str(&patched).unwrap(); + assert!(patched_yaml["mcp_servers"].get("nemo-relay").is_none()); + assert_eq!( + patched_yaml["mcp_servers"]["filesystem"]["command"], + json!("fs-mcp") ); prepared.restore().unwrap(); assert_eq!(std::fs::read_to_string(&hooks_path).unwrap(), original); @@ -1075,7 +1086,7 @@ async fn execute_live_run_restores_hermes_hooks_when_health_check_fails() { assert!( std::fs::read_to_string(&hooks_path) .unwrap() - .contains("hook-forward hermes") + .contains("plugin-shim hook hermes") ); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/crates/cli/tests/coverage/main_tests.rs b/crates/cli/tests/coverage/main_tests.rs index f30dede4c..1b545f3f8 100644 --- a/crates/cli/tests/coverage/main_tests.rs +++ b/crates/cli/tests/coverage/main_tests.rs @@ -102,7 +102,11 @@ fn cli_parses_native_mcp_subcommand_and_bind_override() { Some(Command::Mcp(command)) if command.agent == McpAgent::ClaudeCode )); - assert!(Cli::try_parse_from(["nemo-relay", "mcp", "--agent", "hermes"]).is_err()); + let cli = Cli::try_parse_from(["nemo-relay", "mcp", "--agent", "hermes"]).unwrap(); + assert!(matches!( + cli.command, + Some(Command::Mcp(command)) if command.agent == McpAgent::Hermes + )); } #[test] @@ -231,19 +235,19 @@ fn safe_dispatch_plugin_json_errors_return_exit_codes() { #[tokio::test] async fn run_command_dispatches_safe_plugin_and_install_paths() { - let cli = Cli::try_parse_from(["nemo-relay", "plugin-shim", "install", "hermes"]).unwrap(); + let cli = Cli::try_parse_from(["nemo-relay", "plugin-shim", "install", "claude"]).unwrap(); let error = run_command(cli.command.unwrap(), &cli.server) .await .unwrap_err() .to_string(); - assert!(error.contains("plugin install supports codex")); + assert!(error.contains("plugin install supports codex and hermes")); - let cli = Cli::try_parse_from(["nemo-relay", "plugin-shim", "uninstall", "hermes"]).unwrap(); + let cli = Cli::try_parse_from(["nemo-relay", "plugin-shim", "uninstall", "claude"]).unwrap(); let error = run_command(cli.command.unwrap(), &cli.server) .await .unwrap_err() .to_string(); - assert!(error.contains("plugin uninstall supports codex")); + assert!(error.contains("plugin uninstall supports codex and hermes")); let dir = tempfile::tempdir().unwrap(); let install_dir = dir.path().join("plugin-install"); diff --git a/crates/cli/tests/coverage/plugin_shim_tests.rs b/crates/cli/tests/coverage/plugin_shim_tests.rs index 70021246e..964f710db 100644 --- a/crates/cli/tests/coverage/plugin_shim_tests.rs +++ b/crates/cli/tests/coverage/plugin_shim_tests.rs @@ -2978,6 +2978,32 @@ fn hook_forward_posts_to_local_sidecar_and_healthz_verifies_relay_identity() { health_thread.join().unwrap(); } +#[test] +fn hermes_hook_forward_posts_the_canonical_payload_to_the_hermes_endpoint() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + let raw = String::from_utf8_lossy(&request); + assert!(raw.starts_with("POST /hooks/hermes HTTP/1.1")); + assert!(raw.ends_with(r#"{"hook_event_name":"on_session_start"}"#)); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}") + .unwrap(); + }); + + let body = post_hook( + CodingAgent::Hermes, + &format!("http://127.0.0.1:{port}"), + br#"{"hook_event_name":"on_session_start"}"#, + ) + .unwrap(); + + assert_eq!(body, b"{}"); + server.join().unwrap(); +} + #[test] fn hook_forward_rejects_an_oversized_http_response() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -3766,6 +3792,10 @@ fn shared_url_env_and_response_helpers_cover_error_branches() { gateway_url(CodingAgent::ClaudeCode, None), "http://127.0.0.1:47641" ); + assert_eq!( + gateway_url(CodingAgent::Hermes, None), + "http://127.0.0.1:47641" + ); assert_eq!( gateway_url(CodingAgent::Codex, Some("http://127.0.0.1:9")), "http://127.0.0.1:9" @@ -3863,6 +3893,7 @@ fn shared_defaults_cover_runtime_username_and_empty_segments() { let _fail_closed = EnvVarGuard::remove("NEMO_RELAY_FAIL_CLOSED"); assert_eq!(gateway_url(CodingAgent::Codex, None), DEFAULT_URL); + assert_eq!(gateway_url(CodingAgent::Hermes, None), DEFAULT_URL); assert_eq!(plugin_idle_timeout().unwrap(), Duration::from_secs(300)); assert_eq!( plugin_heartbeat_interval().unwrap(), @@ -4143,12 +4174,6 @@ fn plugin_shim_entrypoints_reject_unsupported_agents_and_report_json() { .unwrap_err() .contains("supports claude") ); - assert!( - post_hook(CodingAgent::Hermes, DEFAULT_URL, b"{}") - .unwrap_err() - .to_string() - .contains("supports claude and codex") - ); } #[test] diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index 00b8bfeb7..0a69d3fad 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -756,6 +756,37 @@ async fn serve_listener_exits_after_codex_stop_without_session_end() { result.unwrap(); } +#[tokio::test] +async fn serve_listener_exits_after_hermes_turn_without_session_finalize() { + let _guard = PLUGIN_CONFIG_TEST_LOCK.lock().await; + let _env = EnvVarGuard::set("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "1"); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let url = format!("http://{address}"); + let handle = tokio::spawn(async move { serve_listener(listener, test_config(), None).await }); + let client = test_http_client(); + + for hook_event_name in ["on_session_start", "on_session_end"] { + let response = client + .post(format!("{url}/hooks/hermes")) + .json(&json!({ + "session_id": "plugin-idle-hermes-session", + "hook_event_name": hook_event_name + })) + .send() + .await + .unwrap(); + assert!(response.status().is_success()); + } + + let result = tokio::time::timeout(std::time::Duration::from_secs(3), handle) + .await + .expect("plugin idle timeout should stop after the Hermes turn ends") + .unwrap(); + result.unwrap(); +} + #[tokio::test] async fn serve_listener_activates_plugin_config_and_clears_on_shutdown() { let _guard = PLUGIN_CONFIG_TEST_LOCK.lock().await; diff --git a/crates/cli/tests/coverage/session_tests.rs b/crates/cli/tests/coverage/session_tests.rs index 0f043253d..34d960732 100644 --- a/crates/cli/tests/coverage/session_tests.rs +++ b/crates/cli/tests/coverage/session_tests.rs @@ -3952,7 +3952,7 @@ async fn claude_startup_probe_does_not_open_null_input_turn() { json!("claude_startup_probe") ); manager - .finish_gateway_call(&prep.session_id, prep.prune_empty_session_on_finish) + .finish_gateway_call(&prep.session_id, prep.session_finish) .await; manager @@ -4001,11 +4001,11 @@ async fn claude_startup_probe_only_session_is_pruned_after_finish() { .unwrap(); assert!(prep.bypass_managed_pipeline); - assert!(prep.prune_empty_session_on_finish); + assert_eq!(prep.session_finish, GatewaySessionFinish::PruneIfEmpty); assert!(manager.inner.lock().await.contains_key("probe-only")); manager - .finish_gateway_call(&prep.session_id, prep.prune_empty_session_on_finish) + .finish_gateway_call(&prep.session_id, prep.session_finish) .await; assert!(!manager.inner.lock().await.contains_key("probe-only")); @@ -4093,7 +4093,7 @@ async fn claude_direct_gateway_request_seeds_turn_input_before_prompt_hook() { .unwrap(); assert!(!prep.bypass_managed_pipeline); manager - .finish_gateway_call(&prep.session_id, prep.prune_empty_session_on_finish) + .finish_gateway_call(&prep.session_id, prep.session_finish) .await; manager @@ -4371,6 +4371,76 @@ async fn llm_lifecycle_uses_single_active_hook_session_when_header_is_missing() assert!(!sessions.contains_key("gateway-gateway")); } +#[tokio::test] +async fn unidentified_concurrent_gateway_calls_use_isolated_ephemeral_sessions() { + let manager = SessionManager::new(session_test_config()); + manager + .apply_events( + &HeaderMap::new(), + ["hermes-a", "hermes-b"] + .into_iter() + .map(|session_id| { + NormalizedEvent::AgentStarted(SessionEvent { + session_id: session_id.into(), + agent_kind: AgentKind::Hermes, + event_name: "on_session_start".into(), + payload: json!({}), + metadata: json!({}), + }) + }) + .collect(), + ) + .await + .unwrap(); + + let first = manager + .prepare_gateway_call( + &HeaderMap::new(), + LlmGatewayStart { + session_id: None, + ..llm_start() + }, + ) + .await + .unwrap(); + let second = manager + .prepare_gateway_call( + &HeaderMap::new(), + LlmGatewayStart { + session_id: None, + ..llm_start() + }, + ) + .await + .unwrap(); + + assert_eq!(first.session_finish, GatewaySessionFinish::Close); + assert_eq!(second.session_finish, GatewaySessionFinish::Close); + assert_ne!(first.session_id, second.session_id); + assert!(first.session_id.starts_with("gateway-isolated-")); + assert!(second.session_id.starts_with("gateway-isolated-")); + + manager + .finish_gateway_call(&first.session_id, first.session_finish) + .await; + { + let sessions = manager.inner.lock().await; + assert!(!sessions.contains_key(&first.session_id)); + assert!(sessions.contains_key(&second.session_id)); + assert!(sessions.contains_key("hermes-a")); + assert!(sessions.contains_key("hermes-b")); + } + + manager + .finish_gateway_call(&second.session_id, second.session_finish) + .await; + assert!(!manager.has_open_sessions().await); + let sessions = manager.inner.lock().await; + assert!(!sessions.contains_key(&second.session_id)); + assert!(sessions.contains_key("hermes-a")); + assert!(sessions.contains_key("hermes-b")); +} + #[tokio::test] async fn single_pending_llm_hint_claims_next_gateway_llm() { let config = GatewayConfig { @@ -5815,7 +5885,9 @@ async fn idle_timeout_waits_for_active_gateway_llm_call() { .contains_key("active-gateway-call") ); - manager.finish_gateway_call(&prep.session_id, false).await; + manager + .finish_gateway_call(&prep.session_id, GatewaySessionFinish::Retain) + .await; let closed = manager .close_idle_sessions_at( Instant::now() + AGENT_IDLE_TIMEOUT + Duration::from_secs(1), diff --git a/crates/cli/tests/coverage/setup_tests.rs b/crates/cli/tests/coverage/setup_tests.rs index aed26f987..783ca14db 100644 --- a/crates/cli/tests/coverage/setup_tests.rs +++ b/crates/cli/tests/coverage/setup_tests.rs @@ -320,34 +320,15 @@ fn config_scope_labels_are_user_facing_and_stable() { } #[test] -fn hermes_hook_paths_follow_selected_scope() { - let cwd = PathBuf::from("/workspace"); +fn hermes_host_config_is_user_owned_independent_of_relay_scope() { let home = PathBuf::from("/home/user"); let agents = [CodingAgent::Hermes]; assert_eq!( - hermes_hooks_path_for_scope(&agents, ConfigScope::Project, &cwd, &home), - Some(PathBuf::from("/workspace/.hermes/config.yaml")) - ); - assert_eq!( - hermes_hooks_path_for_scope(&agents, ConfigScope::Both, &cwd, &home), - Some(PathBuf::from("/workspace/.hermes/config.yaml")) - ); - assert_eq!( - hermes_hooks_path_for_scope(&agents, ConfigScope::Global, &cwd, &home), + hermes_config_path_for_agents(&agents, &home), Some(PathBuf::from("/home/user/.hermes/config.yaml")) ); - assert_eq!( - hermes_hooks_path_for_scope(&[], ConfigScope::Project, &cwd, &home), - None - ); - assert_eq!( - hermes_hook_targets(ConfigScope::Both, &cwd, &home), - vec![ - PathBuf::from("/workspace/.hermes/config.yaml"), - PathBuf::from("/home/user/.hermes/config.yaml") - ] - ); + assert_eq!(hermes_config_path_for_agents(&[], &home), None); } #[test] @@ -418,33 +399,6 @@ fn read_existing_defaults_prefers_workspace_and_reports_scope_variants() { assert_eq!(defaults.agents, vec![CodingAgent::ClaudeCode]); } -#[test] -fn install_hermes_hooks_writes_yaml_and_merges_existing() { - let cwd = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - // Seed an existing hermes config so we can verify the merge preserves user state. - let project_hermes = cwd.path().join(".hermes"); - std::fs::create_dir_all(&project_hermes).unwrap(); - std::fs::write( - project_hermes.join("config.yaml"), - "model:\n provider: auto\n", - ) - .unwrap(); - - let written = install_hermes_hooks(ConfigScope::Both, cwd.path(), home.path()).unwrap(); - - assert_eq!(written.len(), 2); - let project_yaml = std::fs::read_to_string(cwd.path().join(".hermes/config.yaml")).unwrap(); - assert!(project_yaml.contains("nemo-relay hook-forward hermes")); - assert!(project_yaml.contains("api_request_error")); - assert!( - project_yaml.contains("provider: auto"), - "existing model block must survive merge" - ); - let home_yaml = std::fs::read_to_string(home.path().join(".hermes/config.yaml")).unwrap(); - assert!(home_yaml.contains("nemo-relay hook-forward hermes")); -} - #[test] fn write_or_merge_recovers_from_non_table_agents_value() { let temp = tempfile::tempdir().unwrap(); @@ -556,6 +510,12 @@ fn reset_noops_when_project_config_is_missing() { fn reset_reports_missing_or_malformed_agent_blocks_without_rewriting() { let temp = tempfile::tempdir().unwrap(); let _cwd = CwdScope::enter(temp.path()); + let hermes_home = temp.path().join("hermes-home"); + let _env = EnvScope::set(&[ + ("HOME", Some(temp.path().as_os_str())), + ("USERPROFILE", None), + ("HERMES_HOME", Some(hermes_home.as_os_str())), + ]); let config_dir = temp.path().join(".nemo-relay"); std::fs::create_dir_all(&config_dir).unwrap(); let path = config_dir.join("config.toml"); diff --git a/crates/cli/tests/coverage/sidecar_state_tests.rs b/crates/cli/tests/coverage/sidecar_state_tests.rs index 65b232049..b5e099b7e 100644 --- a/crates/cli/tests/coverage/sidecar_state_tests.rs +++ b/crates/cli/tests/coverage/sidecar_state_tests.rs @@ -200,6 +200,29 @@ fn managed_owner_supports_claude_code_identity() { } } +#[test] +fn managed_owner_supports_hermes_identity() { + let dir = tempdir().unwrap(); + let state = dir.path().join("state"); + let environment = Environment::isolated(); + configure_managed_bootstrap(&environment, &state); + environment.set(BOOTSTRAP_AGENT_ENV, "hermes"); + let address = "127.0.0.1:47635".parse().unwrap(); + + publish_owner_from_env(address).unwrap(); + + let url = format!("http://{address}"); + validate_owner( + &owner_path(&state, CodingAgent::Hermes, &url), + &pid_path(&state, CodingAgent::Hermes, &url), + std::process::id(), + &url, + "shutdown-token", + Some("fingerprint"), + ) + .unwrap(); +} + #[test] fn failed_owner_publish_removes_the_partial_pid_record() { let dir = tempdir().unwrap(); diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 45bd1b363..a6e1c679f 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -230,17 +230,17 @@ configuration and verification steps. ### Hermes Agent -Install the latest NeMo Relay Python package in the environment that runs -Hermes, then enable the bundled Hermes plugin: +After installing the NeMo Relay CLI, configure the Hermes user integration: ```bash -pip install nemo-relay -hermes plugins enable observability/nemo_relay +nemo-relay config hermes +nemo-relay doctor hermes ``` -The plugin ID is `observability/nemo_relay`. See the -[Hermes NeMo Relay plugin guide](https://github.com/NousResearch/hermes-agent/tree/main/plugins/observability/nemo_relay) -for configuration and verification steps. +Relay adds a native stdio MCP entry and the exact trusted lifecycle hooks that +share one deferred user-level gateway. Refer to the +[Hermes Agent guide](/nemo-relay-cli/hermes) for provider routing, lifecycle, +and removal steps. ### Python Framework Integrations diff --git a/docs/nemo-relay-cli/about.mdx b/docs/nemo-relay-cli/about.mdx index 5412d3e15..d82985bb1 100644 --- a/docs/nemo-relay-cli/about.mdx +++ b/docs/nemo-relay-cli/about.mdx @@ -50,7 +50,7 @@ controls. | --- | --- | --- | --- | --- | | Claude Code | Yes | Yes | Partial | Pre-tool hook responses are supported. LLM optimization uses gateway-routed traffic; full coverage depends on loaded Claude Code hooks. | | Codex | Yes | Yes | Partial | Persistent install verifies all 10 hooks in the supported schema. Each `Stop` finalizes a turn snapshot because the plugin schema does not expose `SessionEnd`. | -| Hermes Agent | Yes | Yes | Partial | Hook forwarding, pre-tool guardrails, and Hermes API-request telemetry are supported. Optimization depends on Hermes shell and API-request hook coverage. | +| Hermes Agent | Yes | Yes | Partial | User config installs the shared native MCP gateway lifecycle plus exact trusted hooks. Optimization depends on Hermes shell and API-request hook coverage. | ## Guides @@ -65,9 +65,8 @@ Use these guide links to move from CLI setup into agent-specific instructions. application modes. - [Codex](/nemo-relay-cli/codex) covers transparent Codex CLI runs, local GUI/app caveats, model provider routing, and remote-task limits. -- [Hermes Agent](/nemo-relay-cli/hermes) covers Hermes shell hook installation, - dynamic gateway URL handling, session-finalize behavior, and hook consent - caveats. +- [Hermes Agent](/nemo-relay-cli/hermes) covers Hermes MCP lifecycle setup, + exact hook trust, provider routing, and session-finalize behavior. Start with [Basic Usage](/nemo-relay-cli/basic-usage), then use the guide for the coding agent that you want to observe. diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index 828d74bb9..041a7f2af 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -82,14 +82,15 @@ command = "my-codex-wrapper" nemo-relay run --agent codex ``` -Hermes is different from the other transparent modes: `run --agent hermes` -starts the gateway and exports the dynamic `NEMO_RELAY_GATEWAY_URL`, but Hermes -shell hooks still need to be installed or otherwise approved in Hermes config. +For Hermes, setup installs the persistent user-level MCP and trusted shell-hook +configuration. Transparent `run --agent hermes` temporarily suppresses that +fixed MCP entry, exports the dynamic `NEMO_RELAY_GATEWAY_URL`, and restores the +original Hermes config after the run. Use `--dry-run --print` to inspect the generated hook config, gateway environment, gateway URL, and final command without launching the agent. -## Persistent Host Plugins +## Persistent Host Integrations Use persistent plugin installation when Claude Code or Codex should load NeMo Relay through the host's plugin system instead of through a wrapper command: @@ -100,6 +101,18 @@ nemo-relay install codex nemo-relay install all ``` +Hermes exposes the same stdio MCP lifecycle but owns it through user config +instead of a marketplace plugin: + +```bash +nemo-relay config hermes +``` + +Hermes, Claude Code, and Codex MCP clients can share the native gateway on +`127.0.0.1:47632`. Hermes and Claude Code start MCP asynchronously, so their +canonical hook command also closes the cold-start race and retries the original +payload once. + Plugin installation writes a local marketplace, installs the generated `nemo-relay-plugin`, and configures the host-specific hook and provider routing needed for full observability. It still relies on the same local `nemo-relay` @@ -316,15 +329,15 @@ map to NeMo Relay LLM start/end events when present. Hermes `pre_llm_call` and ## Hook Forwarding -Hooks generated by the wrapper (Claude/Codex ephemeral, Hermes via -setup) invoke `nemo-relay hook-forward ` from stdin. Inside the wrapper -the gateway URL comes from `NEMO_RELAY_GATEWAY_URL` injected on every run; -outside the wrapper (Hermes standalone, IDE-launched Claude/Codex) the hook -command falls back to its embedded `--gateway-url`. +Transparent Claude Code and Codex hooks invoke +`nemo-relay hook-forward ` with the canonical payload on standard input. +The wrapper injects `NEMO_RELAY_GATEWAY_URL` for its ephemeral gateway. -Hooks installed by Claude Code and Codex plugins use the plugin shim path -managed by `nemo-relay install`. That path starts or reuses the local plugin -sidecar before forwarding the hook payload. +Persistent Claude Code, Codex, and Hermes hooks use +`nemo-relay plugin-shim hook `. That path starts or reuses the shared +native sidecar before forwarding the unchanged payload. Hermes setup stores the +canonical absolute command and trusts its exact event pairs; it does not enable +global hook auto-acceptance. `hook-forward` reads the canonical hook payload from standard input, sends it to the matching endpoint, and prints the endpoint response. It fails open by diff --git a/docs/nemo-relay-cli/claude-code.mdx b/docs/nemo-relay-cli/claude-code.mdx index 5e0bd5e01..40908d735 100644 --- a/docs/nemo-relay-cli/claude-code.mdx +++ b/docs/nemo-relay-cli/claude-code.mdx @@ -58,9 +58,9 @@ The plugin starts `nemo-relay mcp`, a lightweight Rust lifecycle client that starts or reuses the shared gateway on `127.0.0.1:47632`. The client verifies the gateway identity and effective persistent configuration, heartbeats it while MCP stdio remains open, and performs one coordinated restart if the -gateway becomes unhealthy. Claude Code and Codex MCP clients share a compatible -gateway, and the gateway exits after the final client's idle timeout. The MCP -server advertises no tools. +gateway becomes unhealthy. Claude Code, Codex, and Hermes MCP clients share a +compatible gateway, and the gateway exits after the final client's idle +timeout. The MCP server advertises no tools. Claude Code starts plugin MCP servers asynchronously, so `SessionStart` can run before the MCP connection is ready. The installed command hook handles this diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index b251a1035..1450ee23a 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -100,8 +100,8 @@ managed Python environment, is limited to 100,000 filesystem entries and 512 MiB in total, with a maximum directory traversal depth of 128. When startup reports an activation snapshot budget error, remove unrelated files from the manifest or load-target directory, flatten deeply nested directories, or reduce -the managed Python environment before retrying. Concurrent Codex and Claude -Code plugin processes can share the sidecar. Each MCP client sends a heartbeat +the managed Python environment before retrying. Concurrent Codex, Claude Code, +and Hermes processes can share the sidecar. Each MCP client sends a heartbeat while its stdio connection is open, and the sidecar exits after 300 seconds without activity by default. No wrapper, launch agent, system user service, scheduled task, login item, or diff --git a/docs/nemo-relay-cli/hermes.mdx b/docs/nemo-relay-cli/hermes.mdx index 28e81fb4f..686924896 100644 --- a/docs/nemo-relay-cli/hermes.mdx +++ b/docs/nemo-relay-cli/hermes.mdx @@ -1,207 +1,188 @@ --- title: "Hermes Agent" -description: "" +description: "Configure Hermes to start and share the native NeMo Relay gateway through MCP." position: 7 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} -The NeMo Relay CLI wrapper and gateway allows you to observe local Hermes Agent sessions. The guide below shows you how to observe these sessions. +NeMo Relay observes Hermes sessions through Hermes's native stdio MCP and shell +hook configuration: -This path is different from the upstream Hermes -`observability/nemo_relay` plugin. The CLI wrapper observes Hermes through -Hermes shell hooks. It can record LLM request/response payloads from sanitized -Hermes API hook payloads when the Hermes build provides them, or from provider -traffic routed through the NeMo Relay gateway. The upstream Hermes plugin runs -inside Hermes and is configured from Hermes itself. +```text +Hermes -> Relay MCP -> Relay Gateway -> Model provider + | + +-> shared lifecycle lease on 127.0.0.1:47632 +``` -Hermes shell hooks provide session, subagent, tool, and LLM lifecycle signals. -For hook-based LLM lifecycle telemetry, `pre_api_request`, `post_api_request`, -and `api_request_error` are the authoritative Hermes hooks. The legacy -`pre_llm_call` and `post_llm_call` hooks still exist, but only as private -hint-style signals for correlation. When Hermes hook payloads include sanitized -request and response bodies, NeMo Relay records them from the hooks. Gateway -routing is still the direct provider-traffic path, and it remains the best -fallback when a Hermes build emits summary-only hook payloads. +The MCP process manages gateway lifetime. Shell hooks preserve Hermes's native +session, subagent, tool, and model lifecycle payloads. You do not need a +separate service manager, wrapper process, Node.js runtime, or Python bootstrap +component. -## Choose the Right Hermes Path +## Configure Persistent Capture -Use the `nemo-relay hermes` wrapper when you want NeMo Relay to manage the -local gateway lifetime for a Hermes process and collect hook plus -gateway-routed LLM observability. +Run the scoped setup flow: -Use the upstream Hermes `observability/nemo_relay` plugin when you want Hermes -itself to load the bundled plugin and emit NeMo Relay observability through -Hermes plugin configuration. Observe-only plugin builds keep Hermes in control -of LLM and tool execution. +```bash +nemo-relay config hermes +``` -Use adaptive execution only with a Hermes build that includes the adaptive -middleware contract and a NeMo Relay runtime that exposes managed -`llm.execute(...)` and `tools.execute(...)` boundaries. Verify the Hermes -release tag before depending on adaptive execution in a released Hermes -environment. +Relay preserves unrelated Hermes settings and updates the Relay-owned portions +of the user configuration. Hermes reads this configuration from +`$HERMES_HOME/config.yaml`, or `~/.hermes/config.yaml` when `HERMES_HOME` is +unset. This location is user-owned even when you choose project-scoped Relay +configuration. -## Transparent Run +Setup performs the following changes as one verified transaction: -Use the wrapper when you want the gateway lifetime managed for a local Hermes -process: +- Adds `mcp_servers.nemo-relay` with the canonical absolute `nemo-relay` + executable and `mcp --agent hermes` arguments. +- Sets the shared gateway bind to `127.0.0.1:47632` and adds a private MCP + generation fence. +- Installs one canonical Relay handler for every supported Hermes lifecycle + event. +- Approves only those exact `(event, command)` pairs in + `shell-hooks-allowlist.json`. +- Removes legacy Relay hook commands and approvals while preserving unrelated + hooks, MCP servers, approvals, and Hermes settings. -```bash -nemo-relay hermes -``` +If any write or post-write verification fails, Relay restores the original +Hermes config, allowlist, and generation file. Concurrent setup operations are +serialized so they cannot overwrite one another. -Pass Hermes arguments after `--`: +Run doctor to verify the MCP entry, all hook definitions, every trust record, +the generation fence, the Relay executable, and forwarded environment names: ```bash -nemo-relay hermes -- chat --provider custom +nemo-relay doctor hermes ``` -After NeMo Relay config exists, this shortcut is equivalent to -`nemo-relay run --agent hermes`. The wrapper starts a gateway on a dynamic -`127.0.0.1` port and exports `NEMO_RELAY_GATEWAY_URL` for the launched -process. After initial NeMo Relay setup exists, Hermes hook configuration is -temporary in this mode: the launcher merges the NeMo Relay hook-forward -commands into the configured Hermes hook file for the run and restores the -original file afterward. The wrapper also sets `HERMES_ACCEPT_HOOKS=1` so -Hermes can use the injected hook commands without extra manual approval -prompts. - -If no NeMo Relay config exists yet, `nemo-relay hermes` triggers the setup flow -first and then launches Hermes through the same wrapped pipeline. +If you add a Relay, OpenTelemetry, AWS, proxy, certificate, or +config-referenced credential variable later, rerun `nemo-relay config hermes` +to add its name to the Hermes MCP environment. Generated config stores +`${VARIABLE_NAME}` references, never secret values. -Inspect what would be launched without starting Hermes: +## Gateway Lifecycle -```bash -nemo-relay run \ - --dry-run \ - --print \ - -- hermes -``` +Hermes launches `nemo-relay mcp --agent hermes` as a long-lived stdio MCP +server. The MCP process does not expose tools. It waits for a valid MCP +`initialize` request before starting or acquiring the gateway, then completes +initialization only after Relay identity, version, bootstrap protocol, and +effective persistent configuration are verified. -## Shared Config +Hermes starts MCP discovery in the background, so an early lifecycle hook can +arrive before MCP initialization completes. The installed hook command handles +that race: it attempts delivery, starts or reuses the same gateway under the +same startup lock after a connection failure, and retries the original payload +once. The payload is not reconstructed or replaced. -Create `.nemo-relay/config.toml` for project defaults or -`~/.config/nemo-relay/config.toml` for user defaults: +Concurrent Hermes, Claude Code, and Codex MCP clients can share one compatible +gateway. Every open MCP client heartbeats the gateway. If the gateway becomes +unhealthy, a client performs one coordinated restart; a second failure exits +that MCP process with an error. After the last client closes, the gateway exits +after 300 idle seconds by default. Set +`NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS` before setup and launch to choose another +positive timeout. -```toml -[agents.hermes] -command = "hermes" -``` +Persistent MCP mode reads only system and user Relay configuration. It does not +load a project's `.nemo-relay` layer. Use transparent run mode for +project-specific Relay configuration. -Then configure observability with `nemo-relay plugins edit --project` or -`.nemo-relay/plugins.toml`: +## Route Model Traffic -```toml -version = 1 +Hermes API hooks can provide sanitized model request and response data. Route +provider traffic through Relay when you also need direct gateway observation +of the provider exchange. -[[components]] -kind = "observability" -enabled = true +For an OpenAI-compatible provider, point Hermes at the fixed Relay address: -[components.config.atif] -enabled = true -output_directory = ".nemo-relay/atif" +```bash +export OPENAI_BASE_URL=http://127.0.0.1:47632/v1 +``` -[components.config.atof] -enabled = true -output_directory = ".nemo-relay/atof" +Configure Relay's upstream separately so it does not route back to itself: -[components.config.openinference] -enabled = true -endpoint = "http://127.0.0.1:4318/v1/traces" +```toml +[upstream] +openai_base_url = "https://api.openai.com/v1" ``` -Run `nemo-relay run --agent hermes` to use the configured command and plugin -config. User config takes priority over project and system config. +Use the corresponding Anthropic-compatible base URL when Hermes uses the +Messages API. Relay does not rewrite Hermes provider settings during setup +because those settings are provider-specific and user-owned. -## Hermes Hook Setup +## Lifecycle Hook Coverage -Unlike the other agents, Hermes reads hooks from `.hermes/config.yaml`. The -setup wizard writes that file for you when you select hermes — running -`nemo-relay config` (or `nemo-relay config hermes` to scope to one agent) merges -NeMo Relay hook commands into the YAML, preserving any existing config, and -records the path under `[agents.hermes].hooks_path` in `.nemo-relay/config.toml`. +Relay installs these Hermes hooks: -The generated Hermes hooks cover `on_session_start`, `on_session_end`, -`on_session_finalize`, `on_session_reset`, `pre_llm_call`, `post_llm_call`, -`pre_api_request`, `post_api_request`, `api_request_error`, `pre_tool_call`, -`post_tool_call`, `subagent_start`, and `subagent_stop`. +- Session: `on_session_start`, `on_session_end`, `on_session_finalize`, and + `on_session_reset`. +- Model: `pre_api_request`, `post_api_request`, `api_request_error`, + `pre_llm_call`, and `post_llm_call`. +- Tool: `pre_tool_call` and `post_tool_call`. +- Subagent: `subagent_start` and `subagent_stop`. -The API hooks are the main Hermes LLM lifecycle path for NeMo Relay. The legacy -LLM hooks remain installed because they can still provide useful private hints, -but they are not treated as equal peers to the API hooks in the observability -contract. +The API-request hooks are authoritative for hook-backed model lifecycle. +`pre_llm_call` and `post_llm_call` remain private correlation hints. When model +traffic also passes through the gateway, Relay correlates the hook and provider +signals instead of creating a second model call. -Hermes hook forwarding prefers `NEMO_RELAY_GATEWAY_URL` when set (this is what -`nemo-relay hermes` injects on every run). When launched outside the wrapper — -e.g., bare `hermes` against a long-running gateway — the hook command falls -back to `--gateway-url http://127.0.0.1:4040`. +## Transparent Run -For standalone gateway mode, start the daemon manually: +Use the wrapper for project-specific Relay configuration or an isolated +per-process gateway: ```bash -nemo-relay --bind 127.0.0.1:4040 +nemo-relay hermes +nemo-relay hermes -- chat --provider custom ``` -Then point Hermes provider traffic at `http://127.0.0.1:4040` for any provider -mode that exposes a local OpenAI-compatible or Anthropic-compatible base URL. -This is the practical routed-provider validation path today. +This mode starts a gateway on an OS-assigned loopback port and exports +`NEMO_RELAY_GATEWAY_URL` to Hermes. Relay temporarily removes its fixed MCP +entry from the Hermes config so Hermes does not start a redundant sidecar. The +same canonical hooks target the dynamic gateway. The original Hermes config is +restored after the process exits, including failure paths. -Important distinction: - -- Wrapped execution is the authoritative path for Hermes hook-path validation. -- Wrapped execution does not automatically rewrite Hermes provider `base_url`, - `custom_providers`, or `api_mode`. -- Routed `/v1/messages`, `/v1/chat/completions`, or `/v1/responses` validation - therefore requires explicit Hermes provider configuration in addition to the - wrapped or standalone gateway. - -## Smoke Test - -Use this smoke test to verify the Hermes hook-forward path without launching -Hermes. It sends one synthetic Hermes hook payload through the same -`nemo-relay hook-forward hermes` command that generated hooks use. - -Start a standalone gateway in one terminal: +Inspect the launch without changing files or starting Hermes: ```bash -nemo-relay --bind 127.0.0.1:4040 +nemo-relay run --agent hermes --dry-run --print -- hermes ``` -Then check Hermes hook forwarding from another terminal: +## Remove the Integration + +Remove the Relay agent block and the Relay-owned Hermes MCP, hooks, trust, and +generation state with: ```bash -curl -f http://127.0.0.1:4040/healthz -printf '{"session_id":"smoke-hermes","hook_event_name":"on_session_start"}' \ - | NEMO_RELAY_GATEWAY_URL=http://127.0.0.1:4040 nemo-relay hook-forward hermes --fail-closed +nemo-relay config --reset hermes ``` -The response should be `{}`. If Hermes prompts for hook consent, approve the -NeMo Relay hook command interactively or through Hermes configuration before -relying on unattended capture. +Unrelated Hermes configuration remains unchanged. -## Verify Export +## Validate End-to-End Capture -End a Hermes turn or finalize the session and confirm the configured exporters -received data: +When a compatible Hermes CLI is installed, run the opt-in cold-start test: ```bash -ls .nemo-relay/atof -ls .nemo-relay/atif +just test-hermes-mcp-e2e ``` -The gateway writes or updates an ATIF snapshot when it receives -`on_session_end`, `on_session_finalize`, or `on_session_reset`. -`on_session_end` is a per-turn snapshot boundary: it does not close the NeMo -Relay session and does not emit a visible trajectory mark. `on_session_finalize` -and `on_session_reset` close the session. When ATOF export is enabled, the raw -event stream is written continuously as lifecycle events arrive. +The test uses isolated home directories and a local model provider. It runs 10 +cold one-shot sessions plus two concurrent sessions and verifies provider +authorization, shared gateway lifetime, exactly-once model and turn scopes, +session isolation, balanced ATOF output, hook trust, and final port release. +It is intentionally outside mandatory Rust CI. + +## Troubleshoot -## Troubleshoot LLM Lifecycle +If Hermes reports that the MCP server failed to start, run +`nemo-relay doctor hermes` and inspect the sidecar log path named in the error. +A foreign listener on `127.0.0.1:47632` is rejected rather than adopted. -If hook events appear but LLM spans are missing, Hermes model traffic is not -routed through the gateway. If LLM spans exist but attach to the top-level agent -instead of a subagent, include shared identifiers in Hermes hook payloads and -gateway requests, such as `conversation_id`, `generation_id`, `request_id`, or -`x-nemo-relay-subagent-id`. +If lifecycle events appear but direct model spans do not, check whether the +Hermes provider base URL points at Relay. If model spans attach to the parent +instead of a subagent, preserve Hermes correlation identifiers such as +`task_id`, `turn_id`, `api_request_id`, and `tool_call_id` in the hook payloads. diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index 5f2118238..ff240aa9e 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -11,6 +11,11 @@ Install a host plugin when you want Claude Code or Codex to load NeMo Relay through its normal plugin system instead of through a `nemo-relay` wrapper command. +Hermes uses the same native MCP lifecycle without a marketplace plugin. Run +`nemo-relay config hermes` to merge the Relay MCP server, canonical hooks, and +exact hook trust into Hermes's user configuration. Refer to the +[Hermes Agent guide](/nemo-relay-cli/hermes). + The installed plugin emits agent, subagent, tool, prompt, compaction, and stop lifecycle signals. Model-provider routing sends LLM traffic through the local NeMo Relay gateway. Hooks alone cannot capture complete LLM request and @@ -103,22 +108,23 @@ Upgrade removes legacy Relay groups from Start a new Codex CLI process after installation. Restart the Codex desktop app if it was already running so it reloads the provider and hook configuration. -Claude Code and Codex plugin modes use the same `nemo-relay mcp` lifecycle -client. It starts or reuses a detached native sidecar on `127.0.0.1:47632`, +Claude Code, Codex, and configured Hermes processes use the same +`nemo-relay mcp` lifecycle client. It starts or reuses a detached native +sidecar on `127.0.0.1:47632`, verifies Relay identity, version, protocol readiness, and effective user-level configuration, and heartbeats it every 30 seconds. Concurrent processes from -either host share the gateway. After the final MCP client closes, the sidecar +any host share the gateway. After the final MCP client closes, the sidecar exits after 300 idle seconds by default. If the gateway exits while MCP stdio is open, the client performs one coordinated restart and fails if recovery does not succeed. A foreign process on the configured port is rejected with an actionable conflict instead of being accepted because it returned HTTP 200. Codex marks the MCP server as required, so the captured turn waits for verified -gateway readiness. Claude Code starts plugin MCP servers asynchronously; its -`SessionStart` hook can run first. The Claude hook shim closes that race by +gateway readiness. Claude Code and Hermes start MCP servers asynchronously, so +their first lifecycle hook can run first. The hook shim closes that race by starting or reusing the same gateway under the same startup lock and retrying -the original canonical payload once. The MCP server advertises no tools in -either host. +the original canonical payload once. The MCP server advertises no tools in any +host. On Windows, Relay requests Job Object breakaway only when the host job permits it. If the host applies a restrictive job but permits nested jobs, Relay keeps @@ -142,7 +148,10 @@ forwarding allowlist, so its generated MCP manifest stores variable names—not values—for provider credentials, Relay runtime, OpenTelemetry, AWS, proxies, certificates, approved prefixes, and credential variables referenced by user observability configuration. Claude Code supplies its normal environment to -plugin MCP processes. +plugin MCP processes. Hermes config stores `${VARIABLE_NAME}` references for +the provider credentials, currently defined approved variables, and explicit +credential names referenced by user observability configuration. Rerun +`nemo-relay config hermes` after adding one of those variables. ## Diagnose diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index c627cdac6..08758080f 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -34,9 +34,9 @@ environment variables, or shared TOML config. host share one Rust gateway, subject to the Windows Job Object lifetime caveat below, with no wrapper, login item, launchd agent, systemd user service, scheduled task, or persistent supervisor. -- Hermes does not require a static bundle in this directory. The setup wizard - (`nemo-relay config`) merges hook commands into `.hermes/config.yaml` when - hermes is selected. +- Hermes does not require a static marketplace bundle. `nemo-relay config + hermes` transactionally merges a native MCP lifecycle client, canonical + hooks, and exact per-event trust into the user-owned Hermes config. ## Transparent Setup @@ -76,17 +76,19 @@ CLI must already be installed and discoverable on `$PATH` or `%PATH%`; no separate npm installer, release bundle download, or plugin-local Relay binary is required. -Each plugin MCP entry starts `nemo-relay mcp`, a lightweight client that starts -or reuses a native `nemo-relay --bind 127.0.0.1:47632` sidecar. Relay +Each plugin MCP entry—and the equivalent Hermes `mcp_servers` entry—starts +`nemo-relay mcp`, a lightweight client that starts or reuses a native +`nemo-relay --bind 127.0.0.1:47632` sidecar. Relay detaches the sidecar when host policy permits. A restrictive Windows Job Object keeps the sidecar scoped to that host job instead of failing bootstrap. MCP initialization waits for Relay identity, version, and bootstrap-protocol -readiness. Concurrent Codex and Claude Code processes share the gateway and -heartbeat it while their MCP stdio connections remain open; the gateway exits -after the final client's idle timeout. Codex requires MCP initialization before -the captured turn. Claude Code starts plugin MCP servers asynchronously, so its -command hook also starts or reuses the same gateway and retries the original -payload when it wins the startup race. The MCP client advertises no tools. +readiness. Concurrent Codex, Claude Code, and Hermes processes share the +gateway and heartbeat it while their MCP stdio connections remain open; the +gateway exits after the final client's idle timeout. Codex requires MCP +initialization before the captured turn. Claude Code and Hermes start MCP +servers asynchronously, so their command hooks also start or reuse the same +gateway and retry the original payload when they win the startup race. The MCP +client advertises no tools. Persistent plugin mode loads system and user Relay configuration only and starts the sidecar from the user configuration directory. Relative exporter paths are @@ -136,10 +138,16 @@ That path reads `.claude-plugin/marketplace.json` from the repository. Source plugin hooks invoke `nemo-relay plugin-shim hook claude` directly. Use `nemo-relay install claude-code` for the complete provider-routing setup. -Hermes transparent runs export the dynamic `NEMO_RELAY_GATEWAY_URL`, but Hermes -hooks must already be present in `.hermes/config.yaml` before they can call the -gateway. The setup wizard (`nemo-relay config`) writes that file for you when -you select hermes. +Hermes persistent setup is user-level: + +```bash +nemo-relay config hermes +``` + +It writes the MCP server and trusted hooks to `$HERMES_HOME/config.yaml` or +`~/.hermes/config.yaml`. Transparent Hermes runs temporarily suppress that +fixed MCP entry, export the dynamic `NEMO_RELAY_GATEWAY_URL`, and restore the +original config afterward. Shared TOML config is loaded from `/etc/nemo-relay/config.toml`, then nearest project `.nemo-relay/config.toml`, then @@ -187,12 +195,15 @@ destination records, logs, or reports the failure. ## Hook Forwarding -The transparent wrapper hooks call `nemo-relay hook-forward ` with the -canonical hook payload on stdin. The wrapper injects `NEMO_RELAY_GATEWAY_URL` so -the same hook command reaches the ephemeral per-run gateway; hermes hooks fall -back to an embedded `--gateway-url` when running outside the wrapper. +Transparent Claude Code and Codex hooks call +`nemo-relay hook-forward ` with the canonical hook payload on standard +input. The wrapper injects `NEMO_RELAY_GATEWAY_URL` so the command reaches the +ephemeral per-run gateway. -Claude Code and Codex plugin hooks call `nemo-relay plugin-shim hook `. +Persistent Claude Code and Codex hooks, and Hermes hooks in both modes, call +`nemo-relay plugin-shim hook `. During a transparent Hermes run, the +same canonical command prefers the wrapper's dynamic gateway URL. Otherwise, +it starts or reuses the fixed shared gateway. For Codex, the installed plugin file is the sole persistent Relay hook source; installation does not add Relay groups to `~/.codex/hooks.json`. The shim forwards each canonical payload to the verified shared sidecar. @@ -252,9 +263,10 @@ targets are intentionally outside `test-rust` and mandatory CI: ```bash just test-claude-plugin-e2e just test-codex-plugin-e2e +just test-hermes-mcp-e2e ``` Each target uses an isolated home directory and local mock provider. The Claude -target runs 10 cold sessions plus two concurrent sessions and verifies MCP -connection, hook delivery, provider routing, session isolation, balanced ATOF -output, and final port release. +and Hermes targets each run 10 cold sessions plus two concurrent sessions and +verify MCP connection, hook delivery, provider routing, session isolation, +balanced ATOF output, and final port release. diff --git a/integrations/coding-agents/claude-code/README.md b/integrations/coding-agents/claude-code/README.md index f5b622eb0..02ccdf8ce 100644 --- a/integrations/coding-agents/claude-code/README.md +++ b/integrations/coding-agents/claude-code/README.md @@ -172,7 +172,8 @@ nemo-relay install claude-code installs `nemo-relay-plugin` at user scope, and enables Claude Code provider routing through NeMo Relay. Its plugin MCP process starts or reuses the shared native gateway on `127.0.0.1:47632` and heartbeats it while MCP stdio remains -open. Codex and Claude Code MCP clients can share that gateway. +open. Codex, Claude Code, and configured Hermes MCP clients can share that +gateway. Claude Code starts plugin MCP servers asynchronously. The `SessionStart` hook can therefore run first. The command hook handles that race by starting or diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index 38f9b522d..1102cd56c 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -71,9 +71,9 @@ files and a copied managed Python environment, is limited to 100,000 filesystem entries and 512 MiB in total, with a maximum directory traversal depth of 128. If startup reports an activation snapshot budget error, remove unrelated files from the manifest or load-target directory, flatten deeply nested directories, -or reduce the managed Python environment before retrying. Concurrent Codex and -Claude Code plugin processes can share the gateway and heartbeat it every 30 -seconds. The sidecar +or reduce the managed Python environment before retrying. Concurrent Codex, +Claude Code, and configured Hermes processes can share the gateway and +heartbeat it every 30 seconds. The sidecar remains available for 300 idle seconds after the final client closes. If it dies while MCP remains open, one coordinated restart is attempted. The MCP server advertises no tools. diff --git a/justfile b/justfile index 5a504b0df..2a351d600 100644 --- a/justfile +++ b/justfile @@ -1064,6 +1064,10 @@ test-codex-plugin-e2e: test-claude-plugin-e2e: ./scripts/test-claude-plugin-e2e.sh +# Opt-in: requires Hermes Agent with shell hooks and stdio MCP support; outside test-rust/CI. +test-hermes-mcp-e2e: + ./scripts/test-hermes-mcp-e2e.sh + # --set [output_dir=] [ci=true|false] test-rust: #!/usr/bin/env bash diff --git a/scripts/test-hermes-mcp-e2e.sh b/scripts/test-hermes-mcp-e2e.sh new file mode 100755 index 000000000..5811b64b6 --- /dev/null +++ b/scripts/test-hermes-mcp-e2e.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +keep_work="${NEMO_RELAY_E2E_KEEP_WORK:-0}" +cold_runs="${NEMO_RELAY_HERMES_E2E_COLD_RUNS:-10}" + +if ! command -v hermes >/dev/null 2>&1; then + echo "SKIP: hermes is not installed" + exit 0 +fi +if ! hermes mcp --help >/dev/null 2>&1 || ! hermes hooks --help >/dev/null 2>&1; then + echo "SKIP: installed Hermes does not provide stdio MCP and shell-hook configuration" + exit 0 +fi + +cargo build -p nemo-relay-cli --bin nemo-relay + +work="$(mktemp -d)" +provider_pid="" +background_pids=("") + +cleanup() { + for pid in "${background_pids[@]}"; do + [[ -n "$pid" ]] || continue + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + if [[ -n "$provider_pid" ]]; then + kill "$provider_pid" 2>/dev/null || true + wait "$provider_pid" 2>/dev/null || true + fi + for owner in "${XDG_CONFIG_HOME:-}/nemo-relay/bootstrap"/hermes-sidecar-*.owner.json; do + [[ -f "$owner" ]] || continue + python3 - "$owner" <<'PY' || true +import json +import sys +import urllib.request +from pathlib import Path + +owner = json.loads(Path(sys.argv[1]).read_text()) +request = urllib.request.Request( + f"{owner['url']}/bootstrap/shutdown", + headers={"x-nemo-relay-bootstrap-token": owner["shutdown_token"]}, + method="POST", +) +try: + with urllib.request.urlopen(request, timeout=2): + pass +except OSError: + pass +PY + done + if [[ "$keep_work" == "1" ]]; then + echo "Hermes MCP E2E work directory preserved at $work" >&2 + return + fi + rm -rf "$work" +} +trap cleanup EXIT + +while IFS='=' read -r name _; do + if [[ "$name" == NEMO_RELAY_* ]]; then + unset "$name" + fi +done < <(env) + +export HOME="$work/home" +export HERMES_HOME="$work/hermes" +export XDG_CONFIG_HOME="$work/xdg" +export XDG_DATA_HOME="$work/data" +export XDG_RUNTIME_DIR="$work/runtime" +export TMPDIR="$work/tmp" +export PATH="$repo_root/target/debug:$PATH" +export OPENAI_API_KEY="relay-hermes-e2e-key" +export OPENAI_BASE_URL="http://127.0.0.1:47632/v1" +# Hermes drains some shell hooks after the foreground CLI has exited. Keep a short grace period so +# one lifecycle cannot be split across two gateway generations; production retains the gateway for +# 300 seconds. +export NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS=5 +export DISABLE_AUTOUPDATER=1 + +mkdir -p \ + "$HOME" \ + "$HERMES_HOME" \ + "$XDG_CONFIG_HOME/nemo-relay" \ + "$XDG_DATA_HOME" \ + "$XDG_RUNTIME_DIR" \ + "$TMPDIR" \ + "$work/atof" \ + "$work/provider-barrier" \ + "$work/workspace" + +provider_ready="$work/provider-ready.json" +provider_log="$work/provider-requests.jsonl" +python3 "$repo_root/scripts/test-support/codex_mock_provider.py" \ + --ready-file "$provider_ready" \ + --log-file "$provider_log" \ + --barrier-dir "$work/provider-barrier" & +provider_pid=$! + +for _ in $(seq 1 100); do + [[ -s "$provider_ready" ]] && break + sleep 0.05 +done +[[ -s "$provider_ready" ]] +provider_address="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["address"])' "$provider_ready")" + +cat >"$XDG_CONFIG_HOME/nemo-relay/config.toml" <"$XDG_CONFIG_HOME/nemo-relay/plugins.toml" <"$work/doctor.json" + +python3 - "$HERMES_HOME" "$work/doctor.json" "$repo_root/target/debug/nemo-relay" <<'PY' +import json +import sys +from pathlib import Path + +home, doctor_path, relay = map(Path, sys.argv[1:]) +config = (home / "config.yaml").read_text() +assert "mcp_servers:" in config and "nemo-relay:" in config, config +assert str(relay.resolve()) in config, config +assert "- mcp" in config and "- --agent" in config and "- hermes" in config, config +assert "NEMO_RELAY_GATEWAY_BIND: 127.0.0.1:47632" in config, config +assert "OPENAI_API_KEY: ${OPENAI_API_KEY}" in config, config +generation = home / ".nemo-relay-generation" +assert f"NEMO_RELAY_MCP_GENERATION_FILE: {generation}" in config, config +assert generation == home / ".nemo-relay-generation", generation +assert generation.is_file(), generation + +allowlist = json.loads((home / "shell-hooks-allowlist.json").read_text()) +commands = { + entry["command"] + for entry in allowlist["approvals"] + if "plugin-shim hook hermes" in entry.get("command", "") +} +assert len(commands) == 1, commands +command = commands.pop() +approvals = [entry for entry in allowlist["approvals"] if entry.get("command") == command] +assert len(approvals) == 13, approvals +assert len({entry["event"] for entry in approvals}) == 13, approvals +assert config.count("plugin-shim hook hermes") == 13, config + +doctor = json.loads(doctor_path.read_text()) +hermes = next(agent for agent in doctor["agents"] if agent["name"] == "hermes") +assert hermes["status"] == "pass", hermes +assert "MCP lifecycle" in hermes["annotation"], hermes +PY + +wait_for_relay_port_release() { + python3 - <<'PY' +import socket +import time + +deadline = time.monotonic() + 30 +while time.monotonic() < deadline: + with socket.socket() as sock: + sock.settimeout(0.2) + if sock.connect_ex(("127.0.0.1", 47632)) != 0: + raise SystemExit(0) + time.sleep(0.1) +raise SystemExit("Relay port 47632 did not become free") +PY +} + +run_hermes() { + run_id="$1" + output="$work/hermes-$run_id.stdout" + stderr="$work/hermes-$run_id.stderr" + ( + cd "$work/workspace" + hermes -z "ping" --provider openai-api --model gpt-4o-mini + ) >"$output" 2>"$stderr" + python3 - "$output" "$stderr" <<'PY' +import sys +from pathlib import Path + +output, stderr = map(Path, sys.argv[1:]) +assert output.read_text().strip().lower() == "pong", (output.read_text(), stderr.read_text()) +PY +} + +wait_for_relay_port_release +for run_id in $(seq 1 "$cold_runs"); do + run_hermes "$run_id" + wait_for_relay_port_release +done + +touch "$work/provider-barrier/enabled" +run_hermes concurrent-a & +background_pids+=("$!") +run_hermes concurrent-b & +background_pids+=("$!") + +python3 - "$work/provider-barrier/arrivals" <<'PY' +import socket +import sys +import time +from pathlib import Path + +arrivals = Path(sys.argv[1]) +deadline = time.monotonic() + 30 +while time.monotonic() < deadline: + if arrivals.exists() and int(arrivals.read_text() or "0") >= 2: + with socket.socket() as sock: + sock.settimeout(0.2) + assert sock.connect_ex(("127.0.0.1", 47632)) == 0, "shared Relay gateway is not alive" + raise SystemExit(0) + time.sleep(0.05) +raise SystemExit("concurrent Hermes requests did not reach the provider barrier") +PY +touch "$work/provider-barrier/release" + +for pid in "${background_pids[@]}"; do + [[ -n "$pid" ]] || continue + wait "$pid" +done +background_pids=("") +wait_for_relay_port_release + +python3 - "$provider_log" "$work/atof/events.jsonl" "$cold_runs" <<'PY' +import collections +import json +import sys +from pathlib import Path +from urllib.parse import urlparse + +provider_log, atof_path = map(Path, sys.argv[1:3]) +cold_runs = int(sys.argv[3]) +expected_runs = cold_runs + 2 +requests = [json.loads(line) for line in provider_log.read_text().splitlines() if line.strip()] +completions = [ + row for row in requests if urlparse(row["path"]).path.endswith("/chat/completions") +] +assert len(completions) == expected_runs, completions +assert all(row["authorization"] == "Bearer relay-hermes-e2e-key" for row in completions), completions + +events = [json.loads(line) for line in atof_path.read_text().splitlines() if line.strip()] +assert events and all(event.get("atof_version") == "0.1" for event in events), events +scope_counts = collections.defaultdict(collections.Counter) +for event in events: + if event.get("kind") == "scope": + scope_counts[event["uuid"]][event["scope_category"]] += 1 +for scope_id, counts in scope_counts.items(): + assert counts == {"start": 1, "end": 1}, (scope_id, counts) + +turn_starts = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("scope_category") == "start" + and event.get("name") == "hermes-turn" +] +llm_starts = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("scope_category") == "start" + and event.get("name") == "openai.chat_completions" +] +assert len(turn_starts) == expected_runs, turn_starts +assert len(llm_starts) == expected_runs, llm_starts +session_ids = [event.get("metadata", {}).get("session_id") for event in turn_starts] +assert None not in session_ids and len(set(session_ids)) == expected_runs, session_ids +llm_parents = [event.get("parent_uuid") for event in llm_starts] +assert None not in llm_parents and len(set(llm_parents)) == expected_runs, llm_parents +PY + +echo "Hermes MCP E2E passed: $cold_runs cold runs and 2 concurrent runs" diff --git a/scripts/test-support/codex_mock_provider.py b/scripts/test-support/codex_mock_provider.py index 1d5f3c68b..84b1e67e7 100644 --- a/scripts/test-support/codex_mock_provider.py +++ b/scripts/test-support/codex_mock_provider.py @@ -175,6 +175,33 @@ def anthropic_events(request: dict[str, Any]) -> list[tuple[str, dict[str, Any]] ] +def chat_completion_chunks(request: dict[str, Any]) -> list[dict[str, Any]]: + completion_id = f"chatcmpl_{uuid.uuid4().hex}" + model = request.get("model", "gpt-4o-mini") + created = int(time.time()) + base = { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + } + return [ + { + **base, + "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}], + }, + { + **base, + "choices": [{"index": 0, "delta": {"content": "pong"}, "finish_reason": None}], + }, + { + **base, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + ] + + class Provider(ThreadingHTTPServer): def __init__(self, address: tuple[str, int], log_path: Path, barrier_dir: Path) -> None: super().__init__(address, Handler) @@ -225,7 +252,10 @@ def do_GET(self) -> None: # noqa: N802 body = json.dumps( { "object": "list", - "data": [{"id": "gpt-5-codex", "object": "model", "owned_by": "openai"}], + "data": [ + {"id": "gpt-5-codex", "object": "model", "owned_by": "openai"}, + {"id": "gpt-4o-mini", "object": "model", "owned_by": "openai"}, + ], } ).encode() self.send_response(200) @@ -241,6 +271,7 @@ def do_POST(self) -> None: # noqa: N802 path = urlparse(self.path).path response_stream = response_events(request) if path.endswith("/responses") else None anthropic_stream = anthropic_events(request) if path.endswith("/messages") else None + chat_stream = chat_completion_chunks(request) if path.endswith("/chat/completions") else None self.server.log_request_record( { "method": "POST", @@ -259,7 +290,7 @@ def do_POST(self) -> None: # noqa: N802 self.end_headers() self.wfile.write(body) return - if response_stream is None and anthropic_stream is None: + if response_stream is None and anthropic_stream is None and chat_stream is None: self.send_error(404) return self.server.wait_at_barrier_if_enabled() @@ -282,6 +313,29 @@ def do_POST(self) -> None: # noqa: N802 self.end_headers() self.wfile.write(body) return + if chat_stream is not None and not request.get("stream", False): + body = json.dumps( + { + "id": f"chatcmpl_{uuid.uuid4().hex}", + "object": "chat.completion", + "created": int(time.time()), + "model": request.get("model", "gpt-4o-mini"), + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "pong"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") @@ -291,6 +345,10 @@ def do_POST(self) -> None: # noqa: N802 for event in response_stream: self.wfile.write(f"data: {json.dumps(event)}\n\n".encode()) self.wfile.write(b"data: [DONE]\n\n") + elif chat_stream is not None: + for event in chat_stream: + self.wfile.write(f"data: {json.dumps(event)}\n\n".encode()) + self.wfile.write(b"data: [DONE]\n\n") else: for event_name, event in anthropic_stream or []: self.wfile.write(f"event: {event_name}\ndata: {json.dumps(event)}\n\n".encode()) From 7dc185b91b3ec7d7600393074b2b628214c2eae9 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sat, 11 Jul 2026 08:49:29 -0400 Subject: [PATCH 006/102] refactor(cli): unify coding-agent gateway bootstrap Signed-off-by: Will Killian --- crates/cli/src/coding_agent.rs | 189 +++++ crates/cli/src/config.rs | 133 ++- crates/cli/src/doctor.rs | 102 +-- crates/cli/src/hermes.rs | 9 +- crates/cli/src/hermes/config.rs | 7 +- crates/cli/src/installer.rs | 232 ++++-- crates/cli/src/launcher.rs | 47 +- crates/cli/src/main.rs | 6 +- crates/cli/src/mcp.rs | 17 +- crates/cli/src/mcp/gateway.rs | 6 +- crates/cli/src/mcp/protocol.rs | 20 +- crates/cli/src/mcp/session.rs | 17 +- .../{plugin_shim => plugin_host}/claude.rs | 126 ++- .../src/{plugin_shim => plugin_host}/codex.rs | 21 +- .../codex_app_server.rs | 0 crates/cli/src/plugin_host/mod.rs | 200 +++++ .../{plugin_shim => plugin_host}/shared.rs | 179 +--- crates/cli/src/plugin_install/host.rs | 95 +-- crates/cli/src/plugin_install/marketplace.rs | 95 ++- crates/cli/src/plugin_install/mod.rs | 383 ++++++--- .../cli/src/plugin_install/operation_lock.rs | 18 +- crates/cli/src/plugin_install/setup.rs | 136 +-- crates/cli/src/plugin_install/state.rs | 38 +- crates/cli/src/plugin_shim/command.rs | 80 -- crates/cli/src/plugin_shim/mod.rs | 434 ---------- crates/cli/src/setup/model.rs | 9 +- crates/cli/src/sidecar.rs | 2 +- crates/cli/src/sidecar/health.rs | 6 +- crates/cli/tests/cli_tests.rs | 155 ++-- .../cli/tests/coverage/coding_agent_tests.rs | 78 ++ crates/cli/tests/coverage/doctor_tests.rs | 29 +- crates/cli/tests/coverage/hermes_tests.rs | 4 +- crates/cli/tests/coverage/installer_tests.rs | 94 ++- crates/cli/tests/coverage/launcher_tests.rs | 8 +- crates/cli/tests/coverage/main_tests.rs | 22 +- crates/cli/tests/coverage/mcp_tests.rs | 27 +- ...gin_shim_tests.rs => plugin_host_tests.rs} | 786 +----------------- .../coverage/plugin_install_setup_tests.rs | 40 +- .../tests/coverage/plugin_install_tests.rs | 786 +++++++++++------- docs/about-nemo-relay/release-notes/index.mdx | 2 +- docs/getting-started/installation.mdx | 22 +- docs/getting-started/quick-start/index.mdx | 2 +- docs/nemo-relay-cli/about.mdx | 8 +- docs/nemo-relay-cli/basic-usage.mdx | 37 +- docs/nemo-relay-cli/claude-code.mdx | 27 +- docs/nemo-relay-cli/codex.mdx | 15 +- docs/nemo-relay-cli/hermes.mdx | 54 +- docs/nemo-relay-cli/plugin-installation.mdx | 174 ++-- integrations/coding-agents/README.md | 81 +- .../coding-agents/claude-code/.mcp.json | 3 +- .../coding-agents/claude-code/README.md | 38 +- .../claude-code/hooks/hooks.json | 28 +- integrations/coding-agents/codex/README.md | 6 +- .../coding-agents/codex/hooks/hooks.json | 20 +- justfile | 6 +- scripts/test-claude-plugin-e2e.sh | 17 +- scripts/test-codex-plugin-e2e.sh | 15 +- scripts/test-hermes-mcp-e2e.sh | 13 +- 58 files changed, 2377 insertions(+), 2827 deletions(-) create mode 100644 crates/cli/src/coding_agent.rs rename crates/cli/src/{plugin_shim => plugin_host}/claude.rs (56%) rename crates/cli/src/{plugin_shim => plugin_host}/codex.rs (98%) rename crates/cli/src/{plugin_shim => plugin_host}/codex_app_server.rs (100%) create mode 100644 crates/cli/src/plugin_host/mod.rs rename crates/cli/src/{plugin_shim => plugin_host}/shared.rs (52%) delete mode 100644 crates/cli/src/plugin_shim/command.rs delete mode 100644 crates/cli/src/plugin_shim/mod.rs create mode 100644 crates/cli/tests/coverage/coding_agent_tests.rs rename crates/cli/tests/coverage/{plugin_shim_tests.rs => plugin_host_tests.rs} (81%) diff --git a/crates/cli/src/coding_agent.rs b/crates/cli/src/coding_agent.rs new file mode 100644 index 000000000..d479af073 --- /dev/null +++ b/crates/cli/src/coding_agent.rs @@ -0,0 +1,189 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Canonical coding-agent identity and compatibility policy. + +use clap::ValueEnum; +use semver::Version; +use std::path::Path; + +/// Coding-agent hosts supported by the CLI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub(crate) enum CodingAgent { + /// `claude-code` remains an input alias for older Relay configuration. + #[value(name = "claude", alias = "claude-code")] + ClaudeCode, + Codex, + Hermes, +} + +#[derive(Debug, Clone, Copy)] +struct AgentDescriptor { + argument: &'static str, + label: &'static str, + executable: &'static str, + hook_path: &'static str, + version_product: &'static str, + minimum_version: (u64, u64, u64), + version_format: VersionFormat, +} + +#[derive(Debug, Clone, Copy)] +enum VersionFormat { + Codex, + ClaudeCode, + Hermes, +} + +const CLAUDE_CODE: AgentDescriptor = AgentDescriptor { + argument: "claude", + label: "Claude Code", + executable: "claude", + hook_path: "/hooks/claude-code", + version_product: "Claude Code", + minimum_version: (2, 1, 121), + version_format: VersionFormat::ClaudeCode, +}; + +const CODEX: AgentDescriptor = AgentDescriptor { + argument: "codex", + label: "Codex", + executable: "codex", + hook_path: "/hooks/codex", + version_product: "codex-cli", + minimum_version: (0, 143, 0), + version_format: VersionFormat::Codex, +}; + +const HERMES: AgentDescriptor = AgentDescriptor { + argument: "hermes", + label: "Hermes Agent", + executable: "hermes", + hook_path: "/hooks/hermes", + version_product: "Hermes Agent", + minimum_version: (0, 18, 2), + version_format: VersionFormat::Hermes, +}; + +impl CodingAgent { + pub(crate) const ALL: [Self; 3] = [Self::ClaudeCode, Self::Codex, Self::Hermes]; + + const fn descriptor(self) -> AgentDescriptor { + match self { + Self::ClaudeCode => CLAUDE_CODE, + Self::Codex => CODEX, + Self::Hermes => HERMES, + } + } + + /// Canonical CLI spelling used in generated commands and configuration. + pub(crate) const fn as_arg(self) -> &'static str { + self.descriptor().argument + } + + /// Human-readable product name used in diagnostics. + pub(crate) const fn label(self) -> &'static str { + self.descriptor().label + } + + /// Default executable name used for discovery and transparent launch. + pub(crate) const fn executable(self) -> &'static str { + self.descriptor().executable + } + + /// Stable gateway endpoint used by lifecycle hooks. + pub(crate) const fn hook_path(self) -> &'static str { + self.descriptor().hook_path + } + + pub(crate) fn minimum_version(self) -> Version { + let (major, minor, patch) = self.descriptor().minimum_version; + Version::new(major, minor, patch) + } + + pub(crate) fn version_requirement(self) -> String { + let descriptor = self.descriptor(); + format!( + "{} {} or newer", + descriptor.version_product, + self.minimum_version() + ) + } + + /// Parses and validates the first version line emitted by the host CLI. + pub(crate) fn validate_version_output(self, raw: &str) -> Result { + let first_line = raw.lines().next().unwrap_or_default().trim(); + let version = self.parse_version(first_line).ok_or_else(|| { + format!( + "could not parse `{} --version` output {:?}; NeMo Relay requires {}", + self.executable(), + raw.trim(), + self.version_requirement() + ) + })?; + if version < self.minimum_version() || !version.pre.is_empty() { + return Err(format!( + "{} {version} is unsupported; NeMo Relay requires {}", + self.descriptor().version_product, + self.version_requirement() + )); + } + Ok(version) + } + + /// Runs the canonical version probe for a concrete host executable. + pub(crate) fn validate_executable(self, executable: &Path) -> Result { + let output = std::process::Command::new(executable) + .arg("--version") + .stdin(std::process::Stdio::null()) + .output() + .map_err(|error| { + format!( + "failed to run `{} --version`: {error}; NeMo Relay requires {}", + executable.display(), + self.version_requirement() + ) + })?; + if !output.status.success() { + return Err(format!( + "`{} --version` failed with {}; NeMo Relay requires {}", + executable.display(), + output.status, + self.version_requirement() + )); + } + self.validate_version_output(&String::from_utf8_lossy(&output.stdout)) + } + + fn parse_version(self, raw: &str) -> Option { + let descriptor = self.descriptor(); + let token = match descriptor.version_format { + VersionFormat::Codex => raw.strip_prefix("codex-cli ")?, + VersionFormat::ClaudeCode => raw.split_whitespace().next()?, + VersionFormat::Hermes => raw + .strip_prefix("Hermes Agent v")? + .split_whitespace() + .next()?, + }; + Version::parse(token).ok() + } + + /// Infers a host from an executable basename. + pub(crate) fn infer(command: &str) -> Option { + let name = std::path::Path::new(command) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(command); + match name { + "claude" | "claude-code" => Some(Self::ClaudeCode), + "codex" => Some(Self::Codex), + "hermes" | "hermes-agent" => Some(Self::Hermes), + _ => None, + } + } +} + +#[cfg(test)] +#[path = "../tests/coverage/coding_agent_tests.rs"] +mod tests; diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 529d248f4..e75fa1f10 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -22,9 +22,9 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use strum::{Display, IntoStaticStr}; +pub(crate) use crate::coding_agent::CodingAgent; use crate::error::CliError; use crate::file_io::{LockAttempt, try_lock_exclusive}; -use crate::plugin_shim::PluginShimCommand; #[cfg(test)] use crate::plugins::lifecycle::active_dynamic_plugin_components; use crate::plugins::lifecycle::{ @@ -70,8 +70,8 @@ pub(crate) enum Command { injects a `nemo-relay-openai` provider override so codex points at the \ gateway; the gateway then forwards to `--openai-base-url` (defaults to \ api.openai.com) with `OPENAI_API_KEY` injected on the codex route (see \ - NMF-86 — codex's own auth.json JWT is stripped). Requires codex-cli >= \ - 0.143.0.", + NMF-86 — codex's own auth.json JWT is stripped). The supported host version \ + is validated before launch.", after_help = "Examples:\n \ nemo-relay codex\n \ nemo-relay codex -- exec \"fix the bug in foo.rs\"\n \ @@ -84,8 +84,8 @@ pub(crate) enum Command { configures Hermes's user-level `mcp_servers` and shell hooks so bare Hermes \ processes can share the native Relay gateway on 127.0.0.1:47632. This \ wrapper temporarily suppresses that fixed MCP entry and uses a dynamic \ - gateway for project-specific Relay configuration. Re-run \ - `nemo-relay config hermes` to refresh the persistent integration.", + gateway for project-specific Relay configuration. Run \ + `nemo-relay install hermes --force` to refresh the persistent integration.", after_help = "Examples:\n \ nemo-relay hermes\n \ nemo-relay hermes -- chat --provider custom" @@ -94,7 +94,8 @@ pub(crate) enum Command { /// Keep a shared Relay gateway ready for an MCP client. #[command( long_about = "Start or reuse a shared native NeMo Relay gateway for an MCP stdio \ - connection. The gateway binds 127.0.0.1:47632 by default and MCP \ + connection. The command acquires the gateway immediately, before reading \ + MCP protocol frames. The gateway binds 127.0.0.1:47632 by default and MCP \ initialization completes only after Relay identity and readiness are \ verified. Multiple MCP clients share the gateway; it remains available \ until its idle timeout after the final client closes. This command \ @@ -126,9 +127,6 @@ pub(crate) enum Command { /// Internal: subprocess used by installed hooks to forward events. Not typed by humans. #[command(hide = true)] HookForward(HookForwardCommand), - /// Internal: plugin-local hook and sidecar supervisor. Not typed by humans. - #[command(hide = true)] - PluginShim(PluginShimCommand), } /// Host identity for the lifecycle-bound MCP client. @@ -136,27 +134,7 @@ pub(crate) enum Command { pub(crate) struct McpCommand { /// Coding-agent host that launched this MCP client. #[arg(long, value_enum, default_value = "codex")] - pub(crate) agent: McpAgent, -} - -/// Hosts that can own the lifecycle-bound MCP client through plugin or user configuration. -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -#[value(rename_all = "kebab-case")] -pub(crate) enum McpAgent { - #[value(name = "claude", alias = "claude-code")] - ClaudeCode, - Codex, - Hermes, -} - -impl From for CodingAgent { - fn from(agent: McpAgent) -> Self { - match agent { - McpAgent::ClaudeCode => Self::ClaudeCode, - McpAgent::Codex => Self::Codex, - McpAgent::Hermes => Self::Hermes, - } - } + pub(crate) agent: CodingAgent, } /// Args for `nemo-relay doctor`. `--json` is on this command (rather than as a global flag) @@ -166,9 +144,9 @@ pub(crate) struct DoctorCommand { /// Limit readiness checks to one supported agent. #[arg(value_enum)] pub(crate) agent: Option, - /// Diagnose an installed coding-agent plugin instead of the normal relay config. + /// Diagnose an installed coding-agent integration instead of the normal Relay config. #[arg(long, value_enum)] - pub(crate) plugin: Option, + pub(crate) plugin: Option, /// Plugin install state directory. Defaults to the platform data directory. #[arg(long)] pub(crate) install_dir: Option, @@ -181,7 +159,7 @@ pub(crate) struct DoctorCommand { #[derive(Debug, Clone, Args)] pub(crate) struct InstallCommand { #[arg(value_enum)] - pub(crate) host: PluginHost, + pub(crate) host: IntegrationHost, #[arg(long)] pub(crate) install_dir: Option, #[arg(long)] @@ -195,7 +173,7 @@ pub(crate) struct InstallCommand { #[derive(Debug, Clone, Args)] pub(crate) struct UninstallCommand { #[arg(value_enum)] - pub(crate) host: PluginHost, + pub(crate) host: IntegrationHost, #[arg(long)] pub(crate) install_dir: Option, #[arg(long)] @@ -584,27 +562,50 @@ pub(crate) struct RunCommand { pub(crate) command: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -#[value(rename_all = "kebab-case")] -pub(crate) enum CodingAgent { - /// Canonical CLI spelling is `claude` (matches Anthropic's own binary name and the TOML - /// `[agents.claude]` key). `claude-code` is kept as an input alias for backward compat - /// with hooks installed before this rename. - #[value(name = "claude", alias = "claude-code")] - ClaudeCode, - Codex, - Hermes, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] #[value(rename_all = "kebab-case")] -pub(crate) enum PluginHost { +pub(crate) enum IntegrationHost { Codex, #[value(name = "claude-code", alias = "claude")] ClaudeCode, + Hermes, All, } +impl IntegrationHost { + pub(crate) const fn agent(self) -> Option { + match self { + Self::Codex => Some(CodingAgent::Codex), + Self::ClaudeCode => Some(CodingAgent::ClaudeCode), + Self::Hermes => Some(CodingAgent::Hermes), + Self::All => None, + } + } + + pub(crate) const fn as_arg(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::ClaudeCode => "claude-code", + Self::Hermes => "hermes", + Self::All => "all", + } + } + + pub(crate) const fn label(self) -> &'static str { + match self.agent() { + Some(agent) => agent.label(), + None => "all", + } + } + + pub(crate) const fn executable(self) -> Option<&'static str> { + match self.agent() { + Some(agent) => Some(agent.executable()), + None => None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] #[value(rename_all = "kebab-case")] pub(crate) enum GatewayMode { @@ -2126,44 +2127,6 @@ fn header_json(headers: &HeaderMap, name: &str) -> Option { header_string(headers, name).and_then(|raw| serde_json::from_str(&raw).ok()) } -impl CodingAgent { - // Returns the gateway hook endpoint for the agent. These paths are stable integration surface - // because installed hook commands persist them in user or project configuration. - pub(crate) const fn hook_path(self) -> &'static str { - match self { - Self::ClaudeCode => "/hooks/claude-code", - Self::Codex => "/hooks/codex", - Self::Hermes => "/hooks/hermes", - } - } - - // Returns the canonical CLI spelling used in generated commands and diagnostics. Matches the - // clap `#[value(name = ...)]` overrides on the enum so install/run output can be copied back - // into commands. `claude` matches Anthropic's binary name and the TOML `[agents.claude]` key. - pub(crate) const fn as_arg(self) -> &'static str { - match self { - Self::ClaudeCode => "claude", - Self::Codex => "codex", - Self::Hermes => "hermes", - } - } - - // Infers an agent from the executable basename, accepting both canonical project names and - // common command aliases. Path components are stripped so configured absolute commands work. - pub(crate) fn infer(command: &str) -> Option { - let name = std::path::Path::new(command) - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(command); - match name { - "claude" | "claude-code" => Some(Self::ClaudeCode), - "codex" => Some(Self::Codex), - "hermes" | "hermes-agent" => Some(Self::Hermes), - _ => None, - } - } -} - impl GatewayMode { // Returns the installed hook-forward spelling for gateway mode headers. Keeping this separate // from debug output prevents enum formatting changes from affecting persisted hook commands. diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index e47bfdda0..8447934e5 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -393,19 +393,14 @@ async fn collect_agents( target_agent: Option, resolved: &ResolvedConfig, ) -> Vec { - let supported = [ - (CodingAgent::ClaudeCode, "claude", "claude"), - (CodingAgent::Codex, "codex", "codex"), - (CodingAgent::Hermes, "hermes", "hermes"), - ]; - let mut out = Vec::with_capacity(supported.len()); - for (agent, display_name, default_exec) in supported { + let mut out = Vec::with_capacity(CodingAgent::ALL.len()); + for agent in CodingAgent::ALL { if target_agent.is_some_and(|target| target != agent) { continue; } let configured = agent_configured(agent, &resolved.agents); let target_requested = target_agent == Some(agent); - let command = agent_command(agent, &resolved.agents, default_exec); + let command = agent_command(agent, &resolved.agents); let exec = command_executable(&command); let path = which_command(exec); let version = match &path { @@ -430,14 +425,41 @@ async fn collect_agents( if !hook_details.is_empty() { details.push(hook_details); } - if agent == CodingAgent::ClaudeCode - && let Some(warning) = version.as_deref().and_then(claude_hook_floor_warning) - { - status = combine_status(status, Status::Warn, true); - details.push(warning); + let version_required = configured || target_requested; + match version.as_deref() { + Some(version) => { + if let Err(error) = agent.validate_version_output(version) { + status = combine_status( + status, + if version_required { + Status::Fail + } else { + Status::Warn + }, + true, + ); + details.push(error); + } + } + None if path.is_some() => { + status = combine_status( + status, + if version_required { + Status::Fail + } else { + Status::Warn + }, + true, + ); + details.push(format!( + "could not determine version; NeMo Relay requires {}", + agent.version_requirement() + )); + } + None => {} } out.push(AgentInfo { - name: display_name, + name: agent.as_arg(), status, configured, command, @@ -468,10 +490,10 @@ fn command_executable(command: &str) -> &str { command.split_whitespace().next().unwrap_or(command) } -fn agent_command(agent: CodingAgent, agents: &AgentConfigs, default_exec: &str) -> String { +fn agent_command(agent: CodingAgent, agents: &AgentConfigs) -> String { configured_agent_command(agent, agents) .cloned() - .unwrap_or_else(|| default_exec.to_string()) + .unwrap_or_else(|| agent.executable().to_string()) } fn configured_agent_command(agent: CodingAgent, agents: &AgentConfigs) -> Option<&String> { @@ -488,14 +510,10 @@ fn agent_configured(agent: CodingAgent, agents: &AgentConfigs) -> bool { } fn configured_agent_names(agents: &AgentConfigs) -> Vec { - [ - (CodingAgent::ClaudeCode, "claude"), - (CodingAgent::Codex, "codex"), - (CodingAgent::Hermes, "hermes"), - ] - .into_iter() - .filter_map(|(agent, name)| agent_configured(agent, agents).then_some(name.to_string())) - .collect() + CodingAgent::ALL + .into_iter() + .filter_map(|agent| agent_configured(agent, agents).then_some(agent.as_arg().to_string())) + .collect() } fn agent_command_status(path: Option<&Path>, configured: bool, target_requested: bool) -> Status { @@ -531,12 +549,14 @@ fn hook_status( Ok(details) => (Status::Pass, details), Err(error) => ( Status::Fail, - format!("persistent MCP/hooks: {error}; rerun `nemo-relay config hermes`"), + format!( + "persistent MCP/hooks: {error}; run `nemo-relay install hermes --force`" + ), ), }, None if readiness_required => ( Status::Fail, - "hooks: not installed; run `nemo-relay config hermes`".into(), + "hooks: not installed; run `nemo-relay install hermes`".into(), ), None => (Status::Info, "hooks: not configured".into()), }, @@ -590,36 +610,6 @@ fn hook_file_status( } } -// Claude Code validates plugin hooks.json against a strict event-name whitelist and rejects the -// entire plugin's hooks on one unknown name. 2.1.116 is the oldest release that accepts every -// event in the generated hook config (`UserPromptExpansion` was added to the whitelist there), -// so older hosts silently load no relay hooks at all. Keep in sync with `HOOK_EVENTS` in -// installer.rs. -const CLAUDE_HOOK_EVENT_FLOOR: (u64, u64, u64) = (2, 1, 116); - -// Returns a doctor warning when a probed Claude Code version predates the hook-event floor. -// Unparseable version strings return None: a missing warning is recoverable, a false one is not. -fn claude_hook_floor_warning(version: &str) -> Option { - let parsed = parse_leading_semver(version)?; - (parsed < CLAUDE_HOOK_EVENT_FLOOR).then(|| { - let (major, minor, patch) = CLAUDE_HOOK_EVENT_FLOOR; - format!( - "version predates {major}.{minor}.{patch}; this Claude Code rejects \ - UserPromptExpansion and will silently load no relay hooks" - ) - }) -} - -// Parses the leading `major.minor.patch` token from a probed version line such as -// "2.1.206 (Claude Code)". Suffixes like prerelease tags fail the numeric parse and yield None. -fn parse_leading_semver(version: &str) -> Option<(u64, u64, u64)> { - let mut parts = version.split_whitespace().next()?.splitn(3, '.'); - let major = parts.next()?.parse().ok()?; - let minor = parts.next()?.parse().ok()?; - let patch = parts.next()?.parse().ok()?; - Some((major, minor, patch)) -} - async fn probe_version(binary: &Path) -> Option { // Spawn ` --version` and read the first line of stdout. Bounded by the network // timeout (re-used as a generic short timeout) so a misbehaving binary doesn't hang doctor. diff --git a/crates/cli/src/hermes.rs b/crates/cli/src/hermes.rs index e9c782a2f..87c6b190e 100644 --- a/crates/cli/src/hermes.rs +++ b/crates/cli/src/hermes.rs @@ -43,7 +43,7 @@ pub(crate) fn user_config_path(default_home: &Path) -> PathBuf { pub(crate) fn install_persistent(config: &Path, relay: &Path) -> Result, CliError> { let relay = relay.canonicalize().unwrap_or_else(|_| relay.to_path_buf()); - let relay = crate::plugin_shim::portable_executable_path(relay); + let relay = crate::plugin_host::portable_executable_path(relay); if !relay_is_executable(&relay) { return Err(CliError::Install(format!( "nemo-relay executable is missing or not executable at {}", @@ -69,6 +69,11 @@ pub(crate) fn install_persistent(config: &Path, relay: &Path) -> Result bool { + PersistentPaths::for_config(config.to_path_buf()) + .is_ok_and(|paths| paths.all().iter().any(|path| path.exists())) +} + pub(crate) fn uninstall_persistent(config: &Path) -> Result, CliError> { let paths = PersistentPaths::for_config(config.to_path_buf())?; if paths.all().iter().all(|path| !path.exists()) { @@ -141,7 +146,7 @@ pub(crate) fn diagnose_persistent(config_path: &Path) -> Result .collect::>(); if !missing.is_empty() { return Err(format!( - "Hermes Relay MCP is missing environment names {}; rerun `nemo-relay config hermes`", + "Hermes Relay MCP is missing environment names {}; run `nemo-relay install hermes --force`", missing.join(", ") )); } diff --git a/crates/cli/src/hermes/config.rs b/crates/cli/src/hermes/config.rs index 1a39b9434..eaa139f4f 100644 --- a/crates/cli/src/hermes/config.rs +++ b/crates/cli/src/hermes/config.rs @@ -43,9 +43,10 @@ pub(crate) fn persistent_hook_command(relay: &Path) -> String { } pub(super) fn persistent_hook_command_for_platform(relay: &Path, windows: bool) -> String { - format!( - "{} plugin-shim hook hermes", - crate::plugin_shim::shell_quote_for_platform(relay, windows) + crate::installer::persistent_hook_forward_command_for_platform( + relay, + crate::config::CodingAgent::Hermes, + windows, ) } diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 7e5781af8..4ba3d1735 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -2,8 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 use std::io::Read; +use std::path::Path; use std::time::Duration; +use futures_util::StreamExt; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; use serde_json::{Value, json}; @@ -13,10 +15,9 @@ use crate::error::CliError; // Claude Code validates plugin hooks.json against a strict event-name whitelist — one unknown // event rejects the entire plugin's hooks (no hooks register, silently). Both Claude vectors // (the transparent-run temp plugin and the marketplace plugin) are plugin hooks.json, so every -// event here must exist in the oldest supported Claude Code. UserPromptExpansion sets that -// floor: 2.1.116 (verified empirically; 2.1.114 rejects it — see `claude_hook_floor_warning` -// in doctor.rs). Codex receives a separate event schema because ignored unknown events would make -// generated hooks impossible to discover and trust exhaustively. +// event here must exist in the minimum Claude Code release prescribed by `coding_agent`. +// Codex receives a separate event schema because ignored unknown events would make generated +// hooks impossible to discover and trust exhaustively. const CLAUDE_HOOK_EVENTS: &[&str] = &[ "SessionStart", "UserPromptSubmit", @@ -48,6 +49,7 @@ const CODEX_HOOK_EVENTS: &[&str] = &[ ]; const HOOK_FORWARD_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_HOOK_RESPONSE_BYTES: usize = 1024 * 1024; pub(crate) const HERMES_HOOK_EVENTS: &[&str] = &[ "on_session_start", @@ -74,20 +76,88 @@ pub(crate) const HERMES_HOOK_EVENTS: &[&str] = &[ /// `--fail-closed` converts missing URLs, HTTP failures, and upstream errors into process errors. pub(crate) async fn hook_forward(command: HookForwardCommand) -> Result<(), CliError> { validate_optional_json("session metadata", command.session_metadata.as_deref())?; - - let input = read_hook_payload()?; - let Some(url) = hook_forward_url(&command)? else { - return Ok(()); + let fail_closed = + command.fail_closed || std::env::var("NEMO_RELAY_FAIL_CLOSED").ok().as_deref() == Some("1"); + let destination = hook_destination(&command); + let recovery = match destination + .recover + .then(|| recovery_plan(command.agent, &destination.gateway_url)) + .transpose() + { + Ok(recovery) => recovery, + Err(error) => return handle_hook_error(error, fail_closed), + }; + let input = match read_hook_payload( + recovery + .as_ref() + .map_or(crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, |launch| { + launch.max_hook_payload_bytes + }), + ) { + Ok(input) => input, + Err(error) => return handle_hook_error(error, fail_closed), }; - let response = send_hook_forward_request(&command, url, input).await?; - handle_hook_forward_response(response, command.fail_closed).await + let url = format!( + "{}{}", + destination.gateway_url.trim_end_matches('/'), + command.agent.hook_path() + ); + if let Some(launch) = recovery.as_ref() + && let Err(error) = recover_gateway(launch.gateway.clone()).await + { + return handle_hook_error(error, fail_closed); + } + let mut response = send_hook_forward_request(&command, &url, input.clone()).await?; + if response + .as_ref() + .is_err_and(|error| error.is_connect() || error.is_timeout()) + && destination.recover + { + let launch = recovery + .as_ref() + .expect("recoverable destinations have a recovery plan"); + if let Err(start_error) = recover_gateway(launch.gateway.clone()).await { + let transport_error = response + .as_ref() + .expect_err("recovery only follows a transport error"); + let error = format!( + "nemo-relay hook forward failed: {transport_error}; sidecar recovery failed: {start_error}" + ); + eprintln!("{error}"); + return if fail_closed { + Err(CliError::Install(error)) + } else { + Ok(()) + }; + } + response = send_hook_forward_request(&command, &url, input).await?; + } + handle_hook_forward_response(response, fail_closed).await +} + +fn handle_hook_error(error: CliError, fail_closed: bool) -> Result<(), CliError> { + eprintln!("nemo-relay hook forward failed: {error}"); + if fail_closed { Err(error) } else { Ok(()) } } // Reads the native hook payload from stdin and normalizes empty payloads to JSON object syntax. // This keeps hook commands observable even for agents or events that invoke hooks without input. -fn read_hook_payload() -> Result { - let mut input = String::new(); - std::io::stdin().read_to_string(&mut input)?; +fn read_hook_payload(limit: usize) -> Result { + read_hook_payload_from(std::io::stdin(), limit) +} + +fn read_hook_payload_from(reader: impl Read, limit: usize) -> Result { + let mut bytes = Vec::new(); + reader + .take(limit.saturating_add(1) as u64) + .read_to_end(&mut bytes)?; + if bytes.len() > limit { + return Err(CliError::Install(format!( + "hook payload exceeds the {limit}-byte limit" + ))); + } + let input = String::from_utf8(bytes) + .map_err(|error| CliError::Install(format!("hook payload is not valid UTF-8: {error}")))?; if input.trim().is_empty() { Ok("{}".to_string()) } else { @@ -95,36 +165,74 @@ fn read_hook_payload() -> Result { } } -// Builds the target gateway hook URL and applies fail-open/fail-closed behavior for missing -// gateway discovery. Returning `Ok(None)` is the fail-open path used by default hook commands. -fn hook_forward_url(command: &HookForwardCommand) -> Result, CliError> { - let Some(gateway_url) = resolve_hook_gateway_url( +struct HookDestination { + gateway_url: String, + recover: bool, +} + +// Installed hooks use the shared fixed gateway and may recover it. Transparent runs set the +// dynamic environment URL and already own that gateway's lifecycle, so hook subprocesses never +// replace it with a persistent sidecar. +fn hook_destination(command: &HookForwardCommand) -> HookDestination { + resolve_hook_destination( command.agent, command.gateway_url.clone(), std::env::var("NEMO_RELAY_GATEWAY_URL").ok(), - ) else { - eprintln!( - "nemo-relay hook forward failed: missing gateway URL; pass --gateway-url or set NEMO_RELAY_GATEWAY_URL" - ); - if command.fail_closed { - return Err(CliError::Install( - "missing gateway URL; pass --gateway-url or set NEMO_RELAY_GATEWAY_URL".into(), - )); - } - return Ok(None); - }; - Ok(Some(format!( - "{}{}", - gateway_url.trim_end_matches('/'), - command.agent.hook_path() - ))) + ) +} + +fn resolve_hook_destination( + agent: CodingAgent, + command_url: Option, + environment_url: Option, +) -> HookDestination { + if agent == CodingAgent::Hermes + && let Some(gateway_url) = environment_url + { + return HookDestination { + gateway_url, + recover: false, + }; + } + if let Some(gateway_url) = command_url { + return HookDestination { + gateway_url, + recover: true, + }; + } + if let Some(gateway_url) = environment_url { + return HookDestination { + gateway_url, + recover: false, + }; + } + HookDestination { + gateway_url: crate::sidecar::DEFAULT_URL.into(), + recover: true, + } +} + +fn recovery_plan( + agent: CodingAgent, + gateway_url: &str, +) -> Result { + let bind = crate::sidecar::loopback_bind(gateway_url).map_err(CliError::Install)?; + crate::sidecar::resolve_plugin_gateway(agent, &Default::default(), bind) +} + +async fn recover_gateway(gateway: crate::sidecar::GatewaySpec) -> Result<(), CliError> { + tokio::task::spawn_blocking(move || gateway.ensure()) + .await + .map_err(|error| CliError::Launch(format!("hook recovery task failed: {error}")))? + .map(|_| ()) + .map_err(CliError::Launch) } // Sends the hook payload with gateway-specific headers translated from CLI flags. The reqwest // transport result is returned separately so response handling can preserve fail-open semantics. async fn send_hook_forward_request( command: &HookForwardCommand, - url: String, + url: &str, input: String, ) -> Result, CliError> { Ok(reqwest::Client::builder() @@ -151,7 +259,14 @@ async fn handle_hook_forward_response( match response { Ok(response) => { let status = response.status(); - let body = response.text().await.unwrap_or_default(); + let body = match read_hook_response(response).await { + Ok(body) => body, + Err(error) if fail_closed => return Err(error), + Err(error) => { + eprintln!("nemo-relay hook forward failed: {error}"); + return Ok(()); + } + }; if !status.is_success() { if let Some(reason) = guardrail_rejection_reason(&body) { return Err(CliError::GuardrailRejected(reason)); @@ -180,6 +295,21 @@ async fn handle_hook_forward_response( } } +async fn read_hook_response(response: reqwest::Response) -> Result { + let mut stream = response.bytes_stream(); + let mut body = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + if body.len().saturating_add(chunk.len()) > MAX_HOOK_RESPONSE_BYTES { + return Err(CliError::Install(format!( + "hook forward response exceeds the {MAX_HOOK_RESPONSE_BYTES}-byte limit" + ))); + } + body.extend_from_slice(&chunk); + } + Ok(String::from_utf8_lossy(&body).into_owned()) +} + fn guardrail_rejection_reason(body: &str) -> Option { let value: Value = serde_json::from_str(body).ok()?; let error = value.get("error")?; @@ -194,20 +324,6 @@ fn guardrail_rejection_reason(body: &str) -> Option { .flatten() } -// Chooses the gateway URL for hook-forward. Hermes prefers the runtime environment URL because -// its hooks are installed persistently by setup but reused under `nemo-relay hermes` with an -// ephemeral gateway; other agents prefer the installed command URL for stable configuration. -fn resolve_hook_gateway_url( - agent: CodingAgent, - command_url: Option, - env_url: Option, -) -> Option { - match agent { - CodingAgent::Hermes => env_url.or(command_url), - _ => command_url.or(env_url), - } -} - /// Generates native hook configuration for the selected agent. /// /// The returned value always has a top-level `hooks` object. Claude/Codex use command hook @@ -230,6 +346,24 @@ pub(crate) fn hook_forward_command(executable: &str, agent: CodingAgent) -> Stri format!("{executable} hook-forward {}", agent.as_arg()) } +/// Canonical persistent hook command used by every supported host. +pub(crate) fn persistent_hook_forward_command(relay: &Path, agent: CodingAgent) -> String { + persistent_hook_forward_command_for_platform(relay, agent, cfg!(windows)) +} + +pub(crate) fn persistent_hook_forward_command_for_platform( + relay: &Path, + agent: CodingAgent, + windows: bool, +) -> String { + format!( + "{} hook-forward {} --gateway-url {}", + crate::plugin_host::shell_quote_for_platform(relay, windows), + agent.as_arg(), + crate::plugin_host::shell_quote_arg_for_platform(crate::sidecar::DEFAULT_URL, windows) + ) +} + fn claude_hooks(command: &str) -> Value { hooks_for_events(CLAUDE_HOOK_EVENTS, command, true) } diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index fc0c948c3..8baf76177 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -112,6 +112,9 @@ impl TransparentRun { crate::plugins::lifecycle::active_dynamic_plugin_components(explicit_config, &resolved)? }; let (agent, argv) = resolve_agent_and_argv(&command, &resolved.agents)?; + if !dry_run { + validate_agent_version(agent, &argv[0]).await?; + } let listener = TcpListener::bind("127.0.0.1:0").await?; let address = listener.local_addr()?; let gateway_url = format!("http://{address}"); @@ -228,11 +231,43 @@ fn resolved_argv(command: &RunCommand, agents: &AgentConfigs) -> Result] command = "..."` override is in the // resolved config. Matches the executable on $PATH that the wizard's detection probes for. const fn default_command_for(agent: CodingAgent) -> &'static str { - match agent { - CodingAgent::ClaudeCode => "claude", - CodingAgent::Codex => "codex", - CodingAgent::Hermes => "hermes", + agent.executable() +} + +async fn validate_agent_version(agent: CodingAgent, executable: &str) -> Result<(), CliError> { + let mut command = Command::new(executable); + command + .arg("--version") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + let output = tokio::time::timeout(Duration::from_secs(5), command.output()) + .await + .map_err(|_| { + CliError::Launch(format!( + "timed out while checking {}; NeMo Relay requires {}", + agent.label(), + agent.version_requirement() + )) + })??; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(CliError::Launch(format!( + "`{executable} --version` failed with {}{}", + output.status, + if stderr.trim().is_empty() { + String::new() + } else { + format!(": {}", stderr.trim()) + } + ))); } + let stdout = String::from_utf8_lossy(&output.stdout); + agent + .validate_version_output(&stdout) + .map(|_| ()) + .map_err(CliError::Launch) } // Uses an explicit `--agent` when present and otherwise infers the agent from argv[0]. Inference is @@ -406,7 +441,7 @@ impl PreparedRun { // Injects Codex hook and provider configuration through repeated `--config` flags. Codex // reserves built-in provider IDs, so run mode installs a temporary provider alias instead of // overriding `model_providers.openai`. Uses `features.hooks=true` introduced in codex-cli - // current supported Codex releases. Requires codex-cli >= 0.143.0. + // current supported Codex releases. The centralized host policy validates the version first. fn prepare_codex(&mut self, gateway_url: &str) { // Codex resolves auth via `CodexAuth::from_auth_dot_json` (`codex-rs/login/src/auth/ // manager.rs`): `auth_mode=ApiKey` uses `OPENAI_API_KEY`, `auth_mode=Chatgpt` uses the @@ -895,7 +930,7 @@ fn write_merged_hermes_hooks(path: &Path) -> Result<(), CliError> { }; let relay = std::env::current_exe() .map(|path| path.canonicalize().unwrap_or(path)) - .map(crate::plugin_shim::portable_executable_path) + .map(crate::plugin_host::portable_executable_path) .unwrap_or_else(|_| PathBuf::from("nemo-relay")); let contents = crate::hermes::transparent_config(&existing, &relay)?; std::fs::write(path, contents)?; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index dcba2d517..97bdb43b6 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -6,6 +6,7 @@ mod adapters; mod alignment; mod banner; +mod coding_agent; mod completions_install; mod config; mod doctor; @@ -21,8 +22,8 @@ mod mcp; mod mcp_environment; mod model; mod model_pricing; +mod plugin_host; mod plugin_install; -mod plugin_shim; mod plugins; mod server; mod session; @@ -77,7 +78,6 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result plugin_shim::run(command), Command::Install(command) => plugin_install::install(command), Command::Uninstall(command) => plugin_install::uninstall(command), Command::Run(command) => launcher::run(command, Some(server)).await, @@ -90,7 +90,7 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result { launcher::easy_path(CodingAgent::Hermes, command, Some(server)).await } - Command::Mcp(command) => mcp::run(command.agent.into(), server).await, + Command::Mcp(command) => mcp::run(command.agent, 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/mcp.rs b/crates/cli/src/mcp.rs index d395c3a45..dba799340 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -18,11 +18,15 @@ pub(crate) async fn run( agent: CodingAgent, server_args: &ServerArgs, ) -> Result { - // Configuration is resolved before reading stdin, but the gateway process is not acquired - // until the session receives a valid MCP initialize request. - let gateway = gateway::GatewayPlan::resolve(agent, server_args).await?; + // Starting the MCP process is the lifecycle boundary. Acquire the shared gateway before + // reading protocol frames so hosts can rely on process startup rather than their individual + // initialize and hook ordering. + let lease = gateway::GatewayPlan::resolve(agent, server_args) + .await? + .acquire() + .await?; let frames = transport::spawn_stdin_reader()?; - session::run(gateway, frames, tokio::io::stdout()).await?; + session::run(lease, frames, tokio::io::stdout()).await?; Ok(ExitCode::SUCCESS) } @@ -32,11 +36,6 @@ fn default_mcp_bind() -> SocketAddr { .expect("default MCP gateway bind is valid") } -#[cfg(test)] -fn request_requires_gateway(line: &str) -> bool { - protocol::evaluate_frame(line).requires_gateway -} - #[cfg(test)] async fn run_session( bind: SocketAddr, diff --git a/crates/cli/src/mcp/gateway.rs b/crates/cli/src/mcp/gateway.rs index 0b1029128..d8bfefa37 100644 --- a/crates/cli/src/mcp/gateway.rs +++ b/crates/cli/src/mcp/gateway.rs @@ -16,7 +16,6 @@ use crate::sidecar::{GatewayBootstrap, GatewaySpec}; const UNHEALTHY_CHECKS_BEFORE_RESTART: u8 = 3; -#[derive(Clone)] pub(super) struct GatewayPlan { spec: GatewaySpec, heartbeat_interval: Duration, @@ -45,10 +44,9 @@ impl GatewayPlan { }) } - pub(super) async fn acquire(&self) -> Result { + pub(super) async fn acquire(self) -> Result { let bootstrap = ensure_gateway(self.spec.clone(), self.generation.clone()).await?; - let plan = self.clone(); - let monitor = tokio::spawn(async move { plan.monitor(bootstrap.endpoint.url).await }); + let monitor = tokio::spawn(async move { self.monitor(bootstrap.endpoint.url).await }); Ok(GatewayLease { monitor }) } diff --git a/crates/cli/src/mcp/protocol.rs b/crates/cli/src/mcp/protocol.rs index cff8fedde..1c39aff20 100644 --- a/crates/cli/src/mcp/protocol.rs +++ b/crates/cli/src/mcp/protocol.rs @@ -10,38 +10,20 @@ pub(super) const MCP_PROTOCOL_VERSION: &str = "2025-06-18"; /// Result of decoding one newline-delimited MCP frame. pub(super) struct FrameAction { pub(super) response: Option, - pub(super) requires_gateway: bool, } -/// Parse a frame once and derive both its protocol response and bootstrap requirement. +/// Parse a frame once and derive its protocol response. pub(super) fn evaluate_frame(frame: &str) -> FrameAction { match serde_json::from_str::(frame) { Ok(message) => FrameAction { - requires_gateway: is_valid_initialize(&message), response: response_for(&message), }, Err(_) => FrameAction { response: Some(jsonrpc_error(Value::Null, -32700, "Parse error")), - requires_gateway: false, }, } } -fn is_valid_initialize(message: &Value) -> bool { - valid_jsonrpc_request(message) - && message.get("method").and_then(Value::as_str) == Some("initialize") - && message - .pointer("/params/protocolVersion") - .and_then(Value::as_str) - .is_some() -} - -fn valid_jsonrpc_request(message: &Value) -> bool { - message.is_object() - && message.get("jsonrpc").and_then(Value::as_str) == Some("2.0") - && message.get("id").is_some() -} - pub(super) fn response_for(message: &Value) -> Option { let id = message.get("id").cloned(); if !message.is_object() || message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { diff --git a/crates/cli/src/mcp/session.rs b/crates/cli/src/mcp/session.rs index b84871660..f35127c0e 100644 --- a/crates/cli/src/mcp/session.rs +++ b/crates/cli/src/mcp/session.rs @@ -5,36 +5,29 @@ use tokio::io::{AsyncWrite, AsyncWriteExt}; -use super::gateway::{GatewayLease, GatewayPlan}; +use super::gateway::GatewayLease; use super::protocol::{FrameAction, evaluate_frame}; use super::transport::FrameReceiver; use crate::error::CliError; pub(super) async fn run( - gateway: GatewayPlan, + mut lease: GatewayLease, mut frames: FrameReceiver, mut writer: W, ) -> Result<(), CliError> where W: AsyncWrite + Unpin, { - let mut lease: Option = None; loop { - let received = match lease.as_mut() { - Some(active) => tokio::select! { - frame = frames.recv() => frame, - result = active.wait() => return result, - }, - None => frames.recv().await, + let received = tokio::select! { + frame = frames.recv() => frame, + result = lease.wait() => return result, }; let Some(frame) = received else { return Ok(()); }; let frame = frame?; let action = evaluate_frame(&frame); - if action.requires_gateway && lease.is_none() { - lease = Some(gateway.acquire().await?); - } write_response(action, &mut writer).await?; } } diff --git a/crates/cli/src/plugin_shim/claude.rs b/crates/cli/src/plugin_host/claude.rs similarity index 56% rename from crates/cli/src/plugin_shim/claude.rs rename to crates/cli/src/plugin_host/claude.rs index f18da40bc..91cfa68b8 100644 --- a/crates/cli/src/plugin_shim/claude.rs +++ b/crates/cli/src/plugin_host/claude.rs @@ -5,11 +5,9 @@ use std::fs; use std::path::{Path, PathBuf}; -use std::process::ExitCode; use serde_json::{Value, json}; -use super::command::PluginShimProviderAction; use super::shared::{ FileSnapshot, backup, backup_path, home_dir, read_json_object, remove_backup, restore_file_snapshot, snapshot_optional_file, write_json, @@ -41,77 +39,61 @@ pub(crate) fn restore_claude_setup(snapshot: &ClaudeSetupSnapshot) -> Result<(), } } -pub(super) fn claude_provider( - action: PluginShimProviderAction, - gateway_url: &str, -) -> Result { - match action { - PluginShimProviderAction::Enable => { - let path = claude_settings_path()?; - let mut settings = read_json_object(&path)?; - if settings.get("env").is_some_and(|env| !env.is_object()) { - return Err(format!("{} has a non-object env field", path.display())); - } - let backup_snapshot = snapshot_optional_file(&backup_path(&path))?; - let managed_provider = - json_env_string(&settings, "ANTHROPIC_BASE_URL") == Some(gateway_url); - if !managed_provider && let Err(error) = backup_claude_settings(&path, true) { - restore_file_snapshot(&backup_snapshot)?; - return Err(error); - } - let env = settings - .as_object_mut() - .expect("read_json_object returns an object") - .entry("env") - .or_insert_with(|| json!({})); - let env = env.as_object_mut().expect("env was validated as an object"); - env.insert("ANTHROPIC_BASE_URL".into(), json!(gateway_url)); - if let Err(error) = write_json(&path, &settings) { - restore_file_snapshot(&backup_snapshot)?; - return Err(error); - } - println!("set ANTHROPIC_BASE_URL={gateway_url} in {}", path.display()); - Ok(ExitCode::SUCCESS) - } - PluginShimProviderAction::Restore => { - let path = claude_settings_path()?; - let backup = backup_path(&path); - if !backup.exists() { - println!( - "no backup found at {}; no managed Claude provider routing to restore", - backup.display() - ); - return Ok(ExitCode::SUCCESS); - } - let mut settings = read_json_object(&path)?; - if json_env_string(&settings, "ANTHROPIC_BASE_URL") == Some(gateway_url) { - let backup_settings = read_json_object(&backup)?; - restore_json_env_value(&mut settings, &backup_settings, "ANTHROPIC_BASE_URL")?; - write_json(&path, &settings)?; - remove_backup(&path)?; - println!( - "restored managed ANTHROPIC_BASE_URL in {} from {}", - path.display(), - backup.display() - ); - } else { - println!( - "current Claude provider routing is not managed by Relay; left {} unchanged", - path.display() - ); - } - Ok(ExitCode::SUCCESS) - } - PluginShimProviderAction::Status => { - println!( - "{}", - claude_settings_base_url().unwrap_or_else(|| { - "ANTHROPIC_BASE_URL is not configured in Claude settings".into() - }) - ); - Ok(ExitCode::SUCCESS) - } +pub(super) fn enable_claude_provider(gateway_url: &str) -> Result<(), String> { + let path = claude_settings_path()?; + let mut settings = read_json_object(&path)?; + if settings.get("env").is_some_and(|env| !env.is_object()) { + return Err(format!("{} has a non-object env field", path.display())); + } + let backup_snapshot = snapshot_optional_file(&backup_path(&path))?; + let managed_provider = json_env_string(&settings, "ANTHROPIC_BASE_URL") == Some(gateway_url); + if !managed_provider && let Err(error) = backup_claude_settings(&path, true) { + restore_file_snapshot(&backup_snapshot)?; + return Err(error); } + let env = settings + .as_object_mut() + .expect("read_json_object returns an object") + .entry("env") + .or_insert_with(|| json!({})); + let env = env.as_object_mut().expect("env was validated as an object"); + env.insert("ANTHROPIC_BASE_URL".into(), json!(gateway_url)); + if let Err(error) = write_json(&path, &settings) { + restore_file_snapshot(&backup_snapshot)?; + return Err(error); + } + println!("set ANTHROPIC_BASE_URL={gateway_url} in {}", path.display()); + Ok(()) +} + +pub(super) fn restore_claude_provider(gateway_url: &str) -> Result<(), String> { + let path = claude_settings_path()?; + let backup = backup_path(&path); + if !backup.exists() { + println!( + "no backup found at {}; no managed Claude provider routing to restore", + backup.display() + ); + return Ok(()); + } + let mut settings = read_json_object(&path)?; + if json_env_string(&settings, "ANTHROPIC_BASE_URL") == Some(gateway_url) { + let backup_settings = read_json_object(&backup)?; + restore_json_env_value(&mut settings, &backup_settings, "ANTHROPIC_BASE_URL")?; + write_json(&path, &settings)?; + remove_backup(&path)?; + println!( + "restored managed ANTHROPIC_BASE_URL in {} from {}", + path.display(), + backup.display() + ); + } else { + println!( + "current Claude provider routing is not managed by Relay; left {} unchanged", + path.display() + ); + } + Ok(()) } pub(super) fn json_env_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> { diff --git a/crates/cli/src/plugin_shim/codex.rs b/crates/cli/src/plugin_host/codex.rs similarity index 98% rename from crates/cli/src/plugin_shim/codex.rs rename to crates/cli/src/plugin_host/codex.rs index d2bc04f9d..118d14959 100644 --- a/crates/cli/src/plugin_shim/codex.rs +++ b/crates/cli/src/plugin_host/codex.rs @@ -21,7 +21,7 @@ use super::codex_app_server::{CodexAppServerClient, CodexHookMetadata, CodexHook use super::shared::{ FileSnapshot, atomic_write, backup, backup_path, current_exe, ensure_table, home_dir, portable_executable_path, read_json_object, remove_backup, restore_file_snapshot, shell_quote, - shell_quote_arg_for_platform, shell_quote_for_platform, snapshot_optional_file, write_json, + shell_quote_arg_for_platform, snapshot_optional_file, write_json, }; pub(super) const CODEX_PLUGIN_ID: &str = "nemo-relay-plugin@nemo-relay-local"; @@ -878,7 +878,10 @@ fn managed_codex_hook_for_relay( } fn legacy_relay_hook_command(command: &str) -> bool { - let Some((program, arguments)) = command.split_once(" plugin-shim hook codex") else { + let Some((program, arguments)) = [" hook-forward codex", " plugin-shim hook codex"] + .into_iter() + .find_map(|separator| command.split_once(separator)) + else { return false; }; if !arguments.is_empty() && !arguments.starts_with(" --gateway-url ") { @@ -1116,7 +1119,7 @@ pub(super) fn codex_home_dir() -> Result { pub(super) fn codex_hook_command(gateway_url: &str) -> String { format!( - "nemo-relay plugin-shim hook codex --gateway-url {}", + "nemo-relay hook-forward codex --gateway-url {}", shell_quote_arg_for_platform(gateway_url, cfg!(windows)) ) } @@ -1126,10 +1129,10 @@ pub(super) fn codex_plugin_hook_command(relay: &Path) -> String { } pub(super) fn codex_plugin_hook_command_for_platform(relay: &Path, windows: bool) -> String { - format!( - "{} plugin-shim hook codex --gateway-url {}", - shell_quote_for_platform(relay, windows), - shell_quote_arg_for_platform(super::DEFAULT_URL, windows) + crate::installer::persistent_hook_forward_command_for_platform( + relay, + CodingAgent::Codex, + windows, ) } @@ -1140,8 +1143,8 @@ pub(super) fn codex_hook_command_for_platform( windows: bool, ) -> String { format!( - "{} plugin-shim hook codex --gateway-url {}", - shell_quote_for_platform(relay, windows), + "{} hook-forward codex --gateway-url {}", + crate::plugin_host::shell_quote_for_platform(relay, windows), shell_quote_arg_for_platform(gateway_url, windows) ) } diff --git a/crates/cli/src/plugin_shim/codex_app_server.rs b/crates/cli/src/plugin_host/codex_app_server.rs similarity index 100% rename from crates/cli/src/plugin_shim/codex_app_server.rs rename to crates/cli/src/plugin_host/codex_app_server.rs diff --git a/crates/cli/src/plugin_host/mod.rs b/crates/cli/src/plugin_host/mod.rs new file mode 100644 index 000000000..fe013aa4d --- /dev/null +++ b/crates/cli/src/plugin_host/mod.rs @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Transactional host configuration for packaged coding-agent plugins. + +mod claude; +mod codex; +mod codex_app_server; +mod shared; + +pub(crate) use claude::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; +pub(crate) use codex::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; +pub(crate) use shared::portable_executable_path; +pub(crate) use shared::shell_quote_arg_for_platform; +pub(crate) use shared::shell_quote_for_platform; +#[cfg(test)] +pub(crate) use shared::strip_windows_verbatim_prefix; + +use std::path::Path; + +use serde_json::{Value, json}; + +use claude::claude_settings_base_url; +use codex::{codex_hook_trust_report, empty_codex_hook_trust_report}; +use codex::{codex_hooks_installed, codex_provider_installed, install_codex, uninstall_codex}; +use shared::{current_exe, healthz, print_check, print_info}; + +use crate::config::CodingAgent; + +#[cfg(test)] +pub(super) use crate::sidecar::DEFAULT_URL; + +pub(crate) fn install_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Result<(), String> { + install_codex(gateway_url, &plugin_root.join("hooks").join("hooks.json")).map(|_| ()) +} + +pub(crate) fn stop_plugin_gateway(agent: CodingAgent) -> Result<(), String> { + crate::sidecar::stop_owned_sidecar(agent) +} + +pub(crate) fn uninstall_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Result<(), String> { + uninstall_codex(gateway_url, &plugin_root.join("hooks").join("hooks.json")).map(|_| ()) +} + +pub(crate) fn enable_claude_provider(gateway_url: &str) -> Result<(), String> { + claude::enable_claude_provider(gateway_url) +} + +pub(crate) fn restore_claude_provider(gateway_url: &str) -> Result<(), String> { + claude::restore_claude_provider(gateway_url) +} + +pub(crate) fn doctor_plugin( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, +) -> Result<(), String> { + if doctor_ok( + agent, + gateway_url, + Some(&plugin_root.join("hooks").join("hooks.json")), + )? { + Ok(()) + } else { + Err(format!("{} plugin doctor checks failed", agent.as_arg())) + } +} + +pub(crate) fn doctor_plugin_json( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, +) -> Result { + let plugin_binary = current_exe().ok().is_some_and(|path| path.exists()); + let sidecar_running = healthz(gateway_url); + let (checks, ok, codex_trust) = match agent { + CodingAgent::ClaudeCode => { + let provider = claude_settings_base_url().as_deref() == Some(gateway_url); + ( + json!({ + "plugin_binary": plugin_binary, + "sidecar_running": sidecar_running, + "claude_provider_routing": provider + }), + plugin_binary && provider, + None, + ) + } + CodingAgent::Codex => { + let plugin_hooks_path = plugin_root.join("hooks").join("hooks.json"); + let provider = codex_provider_installed(gateway_url); + let hooks = codex_hooks_installed(&plugin_hooks_path)?; + let trust = if hooks { + codex_hook_trust_report(&plugin_hooks_path)? + } else { + empty_codex_hook_trust_report() + }; + let hooks_trusted = trust.ready(); + ( + json!({ + "plugin_binary": plugin_binary, + "sidecar_running": sidecar_running, + "codex_provider_alias": provider, + "codex_hooks": hooks, + "codex_hooks_trusted": hooks_trusted + }), + plugin_binary && provider && hooks && hooks_trusted, + Some(trust), + ) + } + other => { + return Err(format!( + "plugin doctor supports claude and codex, got {}", + other.as_arg() + )); + } + }; + let mut report = json!({ + "ok": ok, + "sidecar_health": if sidecar_running { + "running" + } else { + "not_running_mcp_start" + }, + "checks": checks + }); + if let Some(trust) = codex_trust { + report["codex_hook_trust"] = trust.to_json(); + } + Ok(report) +} + +fn doctor_ok( + agent: CodingAgent, + gateway_url: &str, + plugin_hooks_path: Option<&Path>, +) -> Result { + let mut ok = true; + ok &= print_check( + "plugin binary", + current_exe().ok().is_some_and(|path| path.exists()), + ); + if healthz(gateway_url) { + print_info("sidecar health", "running"); + } else { + print_info( + "sidecar health", + "not running; the plugin MCP starts it when the host launches", + ); + } + match agent { + CodingAgent::ClaudeCode => { + ok &= print_check( + "claude provider routing", + claude_settings_base_url().as_deref() == Some(gateway_url), + ); + } + CodingAgent::Codex => { + let plugin_hooks_path = plugin_hooks_path + .ok_or_else(|| "Codex plugin hooks path is required for doctor".to_string())?; + let provider = codex_provider_installed(gateway_url); + let hooks = codex_hooks_installed(plugin_hooks_path)?; + ok &= print_check("codex provider alias", provider); + ok &= print_check("codex hooks", hooks); + let trust = if hooks { + codex_hook_trust_report(plugin_hooks_path)? + } else { + empty_codex_hook_trust_report() + }; + ok &= print_check("codex hooks trusted and enabled", trust.ready()); + if !trust.ready() { + print_info("codex hook trust", &trust.summary()); + } + } + other => { + return Err(format!( + "plugin doctor supports claude and codex, got {}", + other.as_arg() + )); + } + } + Ok(ok) +} + +#[cfg(test)] +use crate::installer::generated_hooks; +#[cfg(test)] +use crate::sidecar::*; +#[cfg(test)] +use claude::*; +#[cfg(test)] +use codex::*; +#[cfg(test)] +use codex_app_server::*; +#[cfg(test)] +use shared::*; + +#[cfg(test)] +#[path = "../../tests/coverage/plugin_host_tests.rs"] +mod tests; diff --git a/crates/cli/src/plugin_shim/shared.rs b/crates/cli/src/plugin_host/shared.rs similarity index 52% rename from crates/cli/src/plugin_shim/shared.rs rename to crates/cli/src/plugin_host/shared.rs index 3d5ad45d3..361dfc7e0 100644 --- a/crates/cli/src/plugin_shim/shared.rs +++ b/crates/cli/src/plugin_host/shared.rs @@ -1,25 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Filesystem, hook transport, and process helpers shared by plugin shims. +//! Filesystem and platform helpers shared by host configuration. use std::env; use std::fs; -use std::io::{Read, Write}; -use std::net::{TcpStream, ToSocketAddrs}; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode}; -use std::time::Duration; use serde_json::{Value, json}; use toml_edit::{DocumentMut, Item, Table}; -use crate::config::CodingAgent; pub(super) use crate::file_io::atomic_write; -use crate::sidecar::{DEFAULT_URL, loopback_authority, parse_loopback_url}; -pub(super) use crate::sidecar::{current_exe, healthz, plugin_idle_timeout, relay_binary}; - -pub(super) const MAX_HOOK_RESPONSE_BYTES: usize = 1024 * 1024; +pub(super) use crate::sidecar::{current_exe, healthz}; pub(crate) fn shell_quote(path: &Path) -> String { shell_quote_for_platform(path, cfg!(windows)) @@ -188,144 +180,6 @@ pub(super) fn restore_file_snapshot(snapshot: &FileSnapshot) -> Result<(), Strin } } -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) struct HookForwardError { - message: String, - retryable: bool, -} - -impl HookForwardError { - pub(super) fn retryable(message: impl Into) -> Self { - Self { - message: message.into(), - retryable: true, - } - } - - pub(super) fn not_retryable(message: impl Into) -> Self { - Self { - message: message.into(), - retryable: false, - } - } - - pub(super) fn is_retryable(&self) -> bool { - self.retryable - } -} - -impl std::fmt::Display for HookForwardError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(&self.message) - } -} - -pub(super) fn post_hook( - agent: CodingAgent, - url: &str, - payload: &[u8], -) -> Result, HookForwardError> { - let hook_path = match agent { - CodingAgent::ClaudeCode => "/hooks/claude-code", - CodingAgent::Codex => "/hooks/codex", - CodingAgent::Hermes => "/hooks/hermes", - }; - let (host, port) = parse_loopback_url(url).map_err(HookForwardError::not_retryable)?; - let addrs = (host.as_str(), port) - .to_socket_addrs() - .map_err(|error| HookForwardError::retryable(format!("hook forward failed: {error}")))?; - let mut stream = None; - let mut connect_error = None; - for addr in addrs { - match TcpStream::connect_timeout(&addr, Duration::from_secs(2)) { - Ok(candidate) => { - stream = Some(candidate); - break; - } - Err(error) => connect_error = Some(error), - } - } - let Some(mut stream) = stream else { - let detail = connect_error - .map(|error| error.to_string()) - .unwrap_or_else(|| "no loopback address resolved".into()); - return Err(HookForwardError::retryable(format!( - "hook forward failed before sending request bytes: {detail}" - ))); - }; - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .map_err(|error| { - HookForwardError::not_retryable(format!("failed to set read timeout: {error}")) - })?; - stream - .set_write_timeout(Some(Duration::from_secs(2))) - .map_err(|error| { - HookForwardError::not_retryable(format!("failed to set write timeout: {error}")) - })?; - let request = format!( - "POST {hook_path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - loopback_authority(&host, port), - payload.len() - ); - stream - .write_all(request.as_bytes()) - .and_then(|_| stream.write_all(payload)) - .map_err(|error| { - HookForwardError::not_retryable(format!("hook forward failed: {error}")) - })?; - let mut response = Vec::new(); - stream - .take(MAX_HOOK_RESPONSE_BYTES.saturating_add(1) as u64) - .read_to_end(&mut response) - .map_err(|error| { - HookForwardError::not_retryable(format!("hook forward failed: {error}")) - })?; - if response.len() > MAX_HOOK_RESPONSE_BYTES { - return Err(HookForwardError::not_retryable(format!( - "hook forward response exceeds the {MAX_HOOK_RESPONSE_BYTES}-byte limit" - ))); - } - parse_http_response(&response).map_err(HookForwardError::not_retryable) -} - -pub(super) fn parse_http_response(response: &[u8]) -> Result, String> { - let Some(split) = response.windows(4).position(|window| window == b"\r\n\r\n") else { - return Err("hook forward failed: malformed HTTP response".into()); - }; - let headers = &response[..split]; - let body = response[split + 4..].to_vec(); - let status_line = headers - .split(|byte| *byte == b'\n') - .next() - .and_then(|line| std::str::from_utf8(line).ok()) - .unwrap_or_default(); - let status_code = status_line - .split_whitespace() - .nth(1) - .and_then(|value| value.parse::().ok()); - if status_code.is_some_and(|code| (200..=299).contains(&code)) { - Ok(body) - } else { - Err(format!( - "nemo-relay hook forward failed with {}", - status_line.trim() - )) - } -} - -pub(super) fn gateway_url(agent: CodingAgent, explicit: Option<&str>) -> String { - if let Some(url) = explicit { - return url.to_string(); - } - if matches!(agent, CodingAgent::ClaudeCode | CodingAgent::Hermes) - && let Ok(url) = env::var("NEMO_RELAY_GATEWAY_URL") - { - return url; - } - env::var("NEMO_RELAY_PLUGIN_GATEWAY_URL").unwrap_or_else(|_| DEFAULT_URL.into()) -} - #[cfg(windows)] pub(crate) fn portable_executable_path(path: PathBuf) -> PathBuf { use std::ffi::OsString; @@ -365,32 +219,3 @@ pub(crate) fn strip_windows_verbatim_prefix(encoded: &[u16]) -> Option> encoded.strip_prefix(VERBATIM_PREFIX).map(ToOwned::to_owned) } } - -pub(super) fn fail_closed() -> bool { - env::var("NEMO_RELAY_FAIL_CLOSED").ok().as_deref() == Some("1") -} - -pub(super) trait ExecOrStatus { - fn exec_or_status(&mut self) -> std::io::Result; -} - -#[cfg(unix)] -impl ExecOrStatus for Command { - fn exec_or_status(&mut self) -> std::io::Result { - use std::os::unix::process::CommandExt; - let error = self.exec(); - Err(error) - } -} - -#[cfg(not(unix))] -impl ExecOrStatus for Command { - fn exec_or_status(&mut self) -> std::io::Result { - let status = self.status()?; - Ok(status - .code() - .and_then(|code| u8::try_from(code).ok()) - .map(ExitCode::from) - .unwrap_or(ExitCode::FAILURE)) - } -} diff --git a/crates/cli/src/plugin_install/host.rs b/crates/cli/src/plugin_install/host.rs index 2a6d7f33f..23318896a 100644 --- a/crates/cli/src/plugin_install/host.rs +++ b/crates/cli/src/plugin_install/host.rs @@ -7,25 +7,24 @@ use std::env; use std::path::{Path, PathBuf}; use std::process::Command; -use semver::Version; use serde_json::Value; #[cfg(test)] use serde_json::json; -use crate::config::PluginHost; +use crate::config::IntegrationHost; use super::state::PluginInstallOptions; -use super::{MARKETPLACE_NAME, PLUGIN_NAME, RELAY_COMMAND, host_cli}; +use super::{MARKETPLACE_NAME, PLUGIN_NAME, RELAY_COMMAND}; pub(super) fn run_host_marketplace_registration( - host: PluginHost, + host: IntegrationHost, marketplace_root: &Path, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { run_command( - host_cli(host), + host.executable().expect("concrete plugin host"), &[ "plugin".into(), "marketplace".into(), @@ -38,13 +37,13 @@ pub(super) fn run_host_marketplace_registration( } pub(super) fn run_host_plugin_registration( - host: PluginHost, + host: IntegrationHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { match host { - PluginHost::Codex => run_command( - host_cli(host), + IntegrationHost::Codex => run_command( + host.executable().expect("concrete plugin host"), &[ "plugin".into(), "add".into(), @@ -53,8 +52,8 @@ pub(super) fn run_host_plugin_registration( options, runner, ), - PluginHost::ClaudeCode => run_command( - host_cli(host), + IntegrationHost::ClaudeCode => run_command( + host.executable().expect("concrete plugin host"), &[ "plugin".into(), "install".into(), @@ -65,18 +64,20 @@ pub(super) fn run_host_plugin_registration( options, runner, ), - PluginHost::All => unreachable!("all is expanded before host registration"), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before host registration") + } } } pub(super) fn run_host_plugin_removal( - host: PluginHost, + host: IntegrationHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { match host { - PluginHost::Codex => run_command( - host_cli(host), + IntegrationHost::Codex => run_command( + host.executable().expect("concrete plugin host"), &[ "plugin".into(), "remove".into(), @@ -85,24 +86,26 @@ pub(super) fn run_host_plugin_removal( options, runner, )?, - PluginHost::ClaudeCode => run_command( - host_cli(host), + IntegrationHost::ClaudeCode => run_command( + host.executable().expect("concrete plugin host"), &["plugin".into(), "uninstall".into(), PLUGIN_NAME.into()], options, runner, )?, - PluginHost::All => unreachable!("all is expanded before host unregistration"), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before host unregistration") + } } Ok(()) } pub(super) fn run_host_marketplace_removal( - host: PluginHost, + host: IntegrationHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { run_command( - host_cli(host), + host.executable().expect("concrete plugin host"), &[ "plugin".into(), "marketplace".into(), @@ -137,7 +140,7 @@ impl HostRegistrationReport { #[cfg(test)] pub(super) fn validate_host_registration( - host: PluginHost, + host: IntegrationHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result { @@ -154,14 +157,14 @@ pub(super) fn validate_host_registration( } Err(format!( "{} plugin host registration is incomplete: missing {}", - host_cli(host), + host.executable().expect("concrete plugin host"), missing.join(", ") )) } } pub(super) fn host_registration_report( - host: PluginHost, + host: IntegrationHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result { @@ -173,15 +176,17 @@ pub(super) fn host_registration_report( } require_host_cli(host, options, runner)?; Ok(match host { - PluginHost::ClaudeCode => HostRegistrationReport { + IntegrationHost::ClaudeCode => HostRegistrationReport { host_plugin_registered: claude_plugin_registered(options, runner)?, host_marketplace_registered: claude_marketplace_registered(options, runner)?, }, - PluginHost::Codex => HostRegistrationReport { + IntegrationHost::Codex => HostRegistrationReport { host_plugin_registered: codex_plugin_registered(options, runner)?, host_marketplace_registered: codex_marketplace_registered(options, runner)?, }, - PluginHost::All => unreachable!("all is expanded before host registration checks"), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before host registration checks") + } }) } @@ -294,10 +299,10 @@ pub(super) fn require_relay( runner .current_executable() .map(|path| path.canonicalize().unwrap_or(path)) - .map(crate::plugin_shim::portable_executable_path) + .map(crate::plugin_host::portable_executable_path) } -pub(super) fn validate_relay_plugin_shim( +pub(super) fn validate_relay_hook_forward( relay: &Path, options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -305,13 +310,13 @@ pub(super) fn validate_relay_plugin_shim( if options.dry_run { return Ok(()); } - let args = ["plugin-shim".into(), "hook".into(), "--help".into()]; + let args = ["hook-forward".into(), "--help".into()]; let status = runner.run_quiet(relay, &args)?; if status == 0 { Ok(()) } else { Err(format!( - "{} failed with exit code {status}; installed hooks require `nemo-relay plugin-shim hook` support", + "{} failed with exit code {status}; installed hooks require `nemo-relay hook-forward` support", format_command(&relay.display().to_string(), &args) )) } @@ -338,45 +343,35 @@ pub(super) fn validate_relay_mcp( } pub(super) fn require_host_cli( - host: PluginHost, + host: IntegrationHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { if options.dry_run { return Ok(()); } - let cli = host_cli(host); + let cli = host + .executable() + .expect("all is expanded before CLI validation"); runner .resolve_executable(cli)? .map(|_| ()) .ok_or_else(|| format!("required `{cli}` CLI was not found on PATH")) } -pub(super) fn validate_codex_version( +pub(super) fn validate_host_version( + host: IntegrationHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { if options.dry_run { return Ok(()); } - let output = run_capture_command("codex", &["--version".into()], options, runner)?; - let version = parse_codex_version(&output.stdout).ok_or_else(|| { - format!( - "could not parse `codex --version` output {:?}; the Relay plugin requires codex-cli 0.143.0 or newer", - output.stdout.trim() - ) - })?; - if version >= Version::new(0, 143, 0) { - Ok(()) - } else { - Err(format!( - "codex-cli {version} is unsupported; the Relay plugin requires codex-cli 0.143.0 or newer" - )) - } -} - -fn parse_codex_version(raw: &str) -> Option { - Version::parse(raw.trim().strip_prefix("codex-cli ")?).ok() + let agent = host + .agent() + .expect("all is expanded before host version validation"); + let output = run_capture_command(agent.executable(), &["--version".into()], options, runner)?; + agent.validate_version_output(&output.stdout).map(|_| ()) } pub(super) fn run_command( diff --git a/crates/cli/src/plugin_install/marketplace.rs b/crates/cli/src/plugin_install/marketplace.rs index c13891331..c9a8971f8 100644 --- a/crates/cli/src/plugin_install/marketplace.rs +++ b/crates/cli/src/plugin_install/marketplace.rs @@ -9,7 +9,7 @@ use std::path::Path; use serde_json::{Value, json}; -use crate::config::{CodingAgent, PluginHost}; +use crate::config::IntegrationHost; use crate::install_generation::{GENERATION_FILE_ENV, write_new_generation}; use crate::installer::generated_hooks; @@ -17,7 +17,7 @@ use super::state::{PluginInstallOptions, PluginLayout, remove_path, write_json}; use super::{MARKETPLACE_NAME, PLUGIN_NAME}; pub(super) fn write_plugin_marketplace( - host: PluginHost, + host: IntegrationHost, layout: &PluginLayout, relay: &Path, options: &PluginInstallOptions, @@ -26,7 +26,7 @@ pub(super) fn write_plugin_marketplace( } pub(super) fn write_plugin_marketplace_for_generation( - host: PluginHost, + host: IntegrationHost, layout: &PluginLayout, relay: &Path, active_generation_fence: &Path, @@ -72,9 +72,9 @@ pub(super) fn write_plugin_marketplace_for_generation( Ok(()) } -pub(super) fn marketplace_manifest(host: PluginHost) -> Value { +pub(super) fn marketplace_manifest(host: IntegrationHost) -> Value { match host { - PluginHost::Codex => json!({ + IntegrationHost::Codex => json!({ "name": MARKETPLACE_NAME, "interface": { "displayName": "NeMo Relay Local" @@ -92,7 +92,7 @@ pub(super) fn marketplace_manifest(host: PluginHost) -> Value { "category": "Coding" }] }), - PluginHost::ClaudeCode => json!({ + IntegrationHost::ClaudeCode => json!({ "name": MARKETPLACE_NAME, "metadata": { "description": "Local NeMo Relay plugins for Claude Code." @@ -108,24 +108,32 @@ pub(super) fn marketplace_manifest(host: PluginHost) -> Value { "category": "development" }] }), - PluginHost::All => unreachable!("all is expanded before manifest generation"), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before manifest generation") + } } } -pub(super) fn plugin_manifest(host: PluginHost) -> Value { +pub(super) fn plugin_manifest(host: IntegrationHost) -> Value { let description = match host { - PluginHost::Codex => { + IntegrationHost::Codex => { "Native Relay gateway lifecycle and Codex hooks for complete local observability." } - PluginHost::ClaudeCode => { + IntegrationHost::ClaudeCode => { "Native Relay gateway lifecycle and Claude Code hooks for complete local observability." } - PluginHost::All => unreachable!("all is expanded before manifest generation"), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before manifest generation") + } }; let keywords = match host { - PluginHost::Codex => json!(["nemo-relay", "codex", "hooks", "observability"]), - PluginHost::ClaudeCode => json!(["nemo-relay", "claude-code", "hooks", "observability"]), - PluginHost::All => unreachable!("all is expanded before manifest generation"), + IntegrationHost::Codex => json!(["nemo-relay", "codex", "hooks", "observability"]), + IntegrationHost::ClaudeCode => { + json!(["nemo-relay", "claude-code", "hooks", "observability"]) + } + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before manifest generation") + } }; let mut manifest = json!({ "name": PLUGIN_NAME, @@ -143,7 +151,7 @@ pub(super) fn plugin_manifest(host: PluginHost) -> Value { if plugin_uses_mcp(host) { manifest["mcpServers"] = json!("./.mcp.json"); } - if matches!(host, PluginHost::Codex) { + if matches!(host, IntegrationHost::Codex) { manifest["interface"] = json!({ "displayName": "NeMo Relay Plugin", "shortDescription": "Run the native Relay gateway and capture Codex lifecycle events.", @@ -160,13 +168,13 @@ pub(super) fn plugin_manifest(host: PluginHost) -> Value { } pub(super) fn plugin_mcp_config( - host: PluginHost, + host: IntegrationHost, relay: &Path, generation_fence: &Path, ) -> Result { let generation_fence = absolute_or_self(generation_fence); let server = match host { - PluginHost::Codex => json!({ + IntegrationHost::Codex => json!({ "command": relay, "args": ["mcp", "--agent", "codex"], "env": { @@ -177,20 +185,25 @@ pub(super) fn plugin_mcp_config( "required": true, "startup_timeout_sec": 20 }), - PluginHost::ClaudeCode => json!({ + IntegrationHost::ClaudeCode => json!({ "command": relay, "args": ["mcp", "--agent", "claude"], "env": { "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632", (GENERATION_FILE_ENV): generation_fence - } + }, + "alwaysLoad": true }), - PluginHost::All => unreachable!("all is expanded before MCP generation"), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before MCP generation") + } }; Ok(match host { - PluginHost::Codex => json!({ "nemo-relay": server }), - PluginHost::ClaudeCode => json!({ "mcpServers": { "nemo-relay": server } }), - PluginHost::All => unreachable!("all is expanded before MCP generation"), + IntegrationHost::Codex => json!({ "nemo-relay": server }), + IntegrationHost::ClaudeCode => json!({ "mcpServers": { "nemo-relay": server } }), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before MCP generation") + } }) } @@ -203,18 +216,14 @@ fn absolute_or_self(path: &Path) -> std::path::PathBuf { .unwrap_or_else(|_| path.to_owned()) } -pub(super) fn plugin_hooks(host: PluginHost, relay: &Path) -> Value { - match host { - PluginHost::Codex => generated_hooks( - CodingAgent::Codex, - &crate::plugin_shim::codex_plugin_hook_command(relay), - ), - PluginHost::ClaudeCode => generated_hooks( - CodingAgent::ClaudeCode, - "nemo-relay plugin-shim hook claude", - ), - PluginHost::All => unreachable!("all is expanded before hook generation"), - } +pub(super) fn plugin_hooks(host: IntegrationHost, relay: &Path) -> Value { + let agent = host + .agent() + .expect("all is expanded before hook generation"); + generated_hooks( + agent, + &crate::installer::persistent_hook_forward_command(relay, agent), + ) } pub(super) fn plugin_mcp_env_vars() -> Result, String> { @@ -230,16 +239,20 @@ pub(super) fn plugin_mcp_env_vars_from( crate::mcp_environment::forwarded_names(environment, config) } -pub(super) fn plugin_has_hooks_template(host: PluginHost) -> bool { +pub(super) fn plugin_has_hooks_template(host: IntegrationHost) -> bool { match host { - PluginHost::Codex | PluginHost::ClaudeCode => true, - PluginHost::All => unreachable!("all is expanded before hook generation"), + IntegrationHost::Codex | IntegrationHost::ClaudeCode => true, + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before hook generation") + } } } -pub(super) fn plugin_uses_mcp(host: PluginHost) -> bool { +pub(super) fn plugin_uses_mcp(host: IntegrationHost) -> bool { match host { - PluginHost::Codex | PluginHost::ClaudeCode => true, - PluginHost::All => unreachable!("all is expanded before MCP generation"), + IntegrationHost::Codex | IntegrationHost::ClaudeCode => true, + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before MCP generation") + } } } diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/plugin_install/mod.rs index 9cd3795d2..0a15c64ff 100644 --- a/crates/cli/src/plugin_install/mod.rs +++ b/crates/cli/src/plugin_install/mod.rs @@ -18,14 +18,14 @@ use std::time::{Duration, Instant}; use serde::Serialize; use serde_json::{Value, json}; -use crate::config::{InstallCommand, PluginHost, UninstallCommand}; +use crate::config::{CodingAgent, InstallCommand, IntegrationHost, UninstallCommand}; use crate::error::CliError; use crate::install_generation::{GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration}; use host::{ CommandRunner, RealCommandRunner, host_registration_report, require_host_cli, require_relay, run_host_marketplace_registration, run_host_marketplace_removal, run_host_plugin_registration, - run_host_plugin_removal, validate_relay_mcp, validate_relay_plugin_shim, + run_host_plugin_removal, validate_relay_hook_forward, validate_relay_mcp, }; use marketplace::{ marketplace_manifest, plugin_hooks, plugin_manifest, plugin_mcp_config, plugin_uses_mcp, @@ -113,7 +113,7 @@ impl HostPluginReadiness { } struct PendingHostPluginReadiness { - host: PluginHost, + host: IntegrationHost, state_path: PathBuf, receiver: Receiver, } @@ -125,7 +125,7 @@ struct PendingHostPluginReadiness { /// into the persistent host-plugin workflow. pub(crate) fn collect_default_host_plugin_readiness() -> Vec { let install_dir = default_install_dir().canonicalize_or_self(); - let pending = [PluginHost::Codex, PluginHost::ClaudeCode] + let pending = [IntegrationHost::Codex, IntegrationHost::ClaudeCode] .into_iter() .filter(|host| state_path(*host, &install_dir).exists()) .map(|host| spawn_default_host_plugin_readiness(host, install_dir.clone())) @@ -143,7 +143,7 @@ pub(crate) fn collect_default_host_plugin_readiness() -> Vec PendingHostPluginReadiness { let state_path = state_path(host, &install_dir); @@ -188,14 +188,14 @@ fn receive_host_plugin_readiness( } fn failed_host_plugin_readiness( - host: PluginHost, + host: IntegrationHost, state_path: PathBuf, details: impl Into, ) -> HostPluginReadiness { let layout = PluginLayout::new(host, state_path.parent().unwrap_or_else(|| Path::new("."))); let mut readiness = HostPluginReadiness { - host: host_arg(host).to_string(), - remediation: format!("nemo-relay install {} --force", host_arg(host)), + host: host.as_arg().to_string(), + remediation: format!("nemo-relay install {} --force", host.as_arg()), state_path, marketplace: Some(layout.marketplace_root), plugin: Some(layout.plugin_root), @@ -229,7 +229,10 @@ pub(crate) fn install(command: InstallCommand) -> Result { command.host, HostSelectionMode::Install, &options, - |host, options, runner, setup_runner| install_host(host, options, runner, setup_runner), + |host, options, runner, setup_runner| match host { + IntegrationHost::Hermes => install_hermes_host(options, runner), + _ => install_host(host, options, runner, setup_runner), + }, ) } @@ -253,12 +256,15 @@ pub(crate) fn uninstall(command: UninstallCommand) -> Result command.host, HostSelectionMode::InstalledState, &options, - |host, options, runner, setup_runner| uninstall_host(host, options, runner, setup_runner), + |host, options, runner, setup_runner| match host { + IntegrationHost::Hermes => uninstall_hermes_host(options), + _ => uninstall_host(host, options, runner, setup_runner), + }, ) } pub(crate) fn doctor( - host: PluginHost, + host: IntegrationHost, install_dir: Option, json: bool, ) -> Result { @@ -278,19 +284,22 @@ pub(crate) fn doctor( host, HostSelectionMode::InstalledState, &options, - |host, options, runner, setup_runner| doctor_host(host, options, runner, setup_runner), + |host, options, runner, setup_runner| match host { + IntegrationHost::Hermes => doctor_hermes_host(options, runner), + _ => doctor_host(host, options, runner, setup_runner), + }, ) } fn run_for_hosts( - host: PluginHost, + host: IntegrationHost, mode: HostSelectionMode, options: &PluginInstallOptions, mut action: F, ) -> Result where F: FnMut( - PluginHost, + IntegrationHost, &PluginInstallOptions, &dyn CommandRunner, &dyn PluginSetupRunner, @@ -301,15 +310,15 @@ where let hosts = select_hosts(host, mode, options, &runner)?; if hosts.is_empty() { return Err(CliError::Install(match host { - PluginHost::All => match mode { + IntegrationHost::All => match mode { HostSelectionMode::Install => { - "no supported Claude Code or Codex host CLI was detected".into() + "no supported Claude Code, Codex, or Hermes host CLI was detected".into() } HostSelectionMode::InstalledState => { - "no installed Claude Code or Codex plugin state was found".into() + "no installed Claude Code, Codex, or Hermes integration state was found".into() } }, - _ => "no supported plugin host selected".into(), + _ => "no supported integration host selected".into(), })); } for host in hosts { @@ -318,25 +327,33 @@ where Ok(ExitCode::SUCCESS) } -fn doctor_json(host: PluginHost, options: &PluginInstallOptions) -> Result { +fn doctor_json( + host: IntegrationHost, + options: &PluginInstallOptions, +) -> Result { let runner = RealCommandRunner; let setup_runner = RealPluginSetupRunner; let hosts = select_hosts(host, HostSelectionMode::InstalledState, options, &runner)?; if hosts.is_empty() { return Err(CliError::Install(match host { - PluginHost::All => "no installed Claude Code or Codex plugin state was found".into(), - _ => "no supported plugin host selected".into(), + IntegrationHost::All => { + "no installed Claude Code, Codex, or Hermes integration state was found".into() + } + _ => "no supported integration host selected".into(), })); } let reports = hosts .into_iter() - .map(|host| doctor_host_json_value(host, options, &runner, &setup_runner)) + .map(|host| match host { + IntegrationHost::Hermes => doctor_hermes_json_value(options, &runner), + _ => doctor_host_json_value(host, options, &runner, &setup_runner), + }) .collect::, _>>() .map_err(CliError::Install)?; let ready = reports .iter() .all(|report| report.get("ok").and_then(Value::as_bool) == Some(true)); - if matches!(host, PluginHost::All) { + if matches!(host, IntegrationHost::All) { print_json(&json!({ "schema_version": 1, "plugins": reports @@ -355,24 +372,30 @@ fn doctor_json(host: PluginHost, options: &PluginInstallOptions) -> Result Result, CliError> { - if host != PluginHost::All { +) -> Result, CliError> { + if host != IntegrationHost::All { return Ok(vec![host]); } let mut hosts = Vec::new(); - for candidate in [PluginHost::Codex, PluginHost::ClaudeCode] { + for candidate in [ + IntegrationHost::Codex, + IntegrationHost::ClaudeCode, + IntegrationHost::Hermes, + ] { let selected = match mode { HostSelectionMode::Install => runner - .resolve_executable(host_cli(candidate)) + .resolve_executable(candidate.executable().expect("concrete plugin host")) .map_err(CliError::Install)? .is_some(), - HostSelectionMode::InstalledState => { - state_path(candidate, &options.install_dir).exists() - } + HostSelectionMode::InstalledState => match candidate { + IntegrationHost::Hermes => hermes_config_path() + .is_ok_and(|path| crate::hermes::persistent_state_exists(&path)), + _ => state_path(candidate, &options.install_dir).exists(), + }, }; if selected { hosts.push(candidate); @@ -381,8 +404,110 @@ fn select_hosts( Ok(hosts) } +fn hermes_config_path() -> Result { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .map(|home| crate::hermes::user_config_path(&home)) + .ok_or_else(|| "cannot determine home directory (set HOME or USERPROFILE)".into()) +} + +fn install_hermes_host( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + require_host_cli(IntegrationHost::Hermes, options, runner)?; + host::validate_host_version(IntegrationHost::Hermes, options, runner)?; + let relay = require_relay(options, runner)?; + validate_relay_hook_forward(&relay, options, runner)?; + validate_relay_mcp(&relay, options, runner)?; + let config = hermes_config_path()?; + if options.dry_run { + println!("configure Hermes MCP and hooks at {}", config.display()); + return Ok(()); + } + crate::hermes::install_persistent(&config, &relay).map_err(|error| error.to_string())?; + if !options.skip_doctor { + crate::hermes::diagnose_persistent(&config)?; + } + println!("installed Hermes integration"); + Ok(()) +} + +fn uninstall_hermes_host(options: &PluginInstallOptions) -> Result<(), String> { + let config = hermes_config_path()?; + if options.dry_run { + println!( + "remove Relay-owned Hermes MCP and hooks from {}", + config.display() + ); + return Ok(()); + } + crate::hermes::uninstall_persistent(&config).map_err(|error| error.to_string())?; + println!("uninstalled Hermes integration"); + Ok(()) +} + +fn doctor_hermes_host( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + let report = doctor_hermes_json_value(options, runner)?; + for check in report["readiness_checks"] + .as_array() + .expect("Hermes readiness checks are an array") + { + println!( + "{}: {} ({})", + check["name"].as_str().unwrap_or_default(), + if check["ok"] == json!(true) { + "ok" + } else { + "failed" + }, + check["details"].as_str().unwrap_or_default() + ); + } + (report["ok"] == json!(true)).then_some(()).ok_or_else(|| { + format!( + "Hermes integration doctor checks failed; remediation: {}", + report["remediation"].as_str().unwrap_or_default() + ) + }) +} + +fn doctor_hermes_json_value( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + let config = hermes_config_path()?; + let version = host::validate_host_version(IntegrationHost::Hermes, options, runner); + let integration = crate::hermes::diagnose_persistent(&config); + let ok = version.is_ok() && integration.is_ok(); + Ok(json!({ + "ok": ok, + "host": IntegrationHost::Hermes.as_arg(), + "remediation": format!("nemo-relay install {} --force", IntegrationHost::Hermes.as_arg()), + "config": config, + "readiness_checks": [ + { + "name": "Hermes Agent version", + "ok": version.is_ok(), + "details": version + .map(|_| format!("{} is installed", CodingAgent::Hermes.version_requirement())) + .unwrap_or_else(|error| error), + }, + { + "name": "Hermes MCP and hooks", + "ok": integration.is_ok(), + "details": integration.unwrap_or_else(|error| error), + } + ] + })) +} + fn install_host( - host: PluginHost, + host: IntegrationHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -397,7 +522,7 @@ fn install_host( } fn install_host_with_operation_timeout( - host: PluginHost, + host: IntegrationHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -417,20 +542,18 @@ fn install_host_with_operation_timeout( } fn install_host_locked( - host: PluginHost, + host: IntegrationHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, ) -> Result<(), String> { let relay = require_relay(options, runner)?; - validate_relay_plugin_shim(&relay, options, runner)?; + validate_relay_hook_forward(&relay, options, runner)?; if plugin_uses_mcp(host) { validate_relay_mcp(&relay, options, runner)?; } require_host_cli(host, options, runner)?; - if matches!(host, PluginHost::Codex) { - host::validate_codex_version(options, runner)?; - } + host::validate_host_version(host, options, runner)?; let layout = PluginLayout::new(host, &options.install_dir); let plugin_preflight = if !options.dry_run && plugin_uses_mcp(host) { Some(prepare_plugin_install(host, &layout, options, runner)?) @@ -532,7 +655,7 @@ fn install_host_locked( registration.host_marketplace_added = true; run_host_plugin_registration(host, options, runner)?; registration.host_plugin_added = true; - if !matches!(host, PluginHost::Codex) { + if !matches!(host, IntegrationHost::Codex) { setup_installed = true; } run_plugin_setup(host, &layout, options, setup_runner)?; @@ -590,14 +713,14 @@ fn install_host_locked( } println!( "installed {} plugin marketplace at {}", - host_label(host), + host.label(), layout.marketplace_root.display() ); Ok(()) } fn uninstall_host( - host: PluginHost, + host: IntegrationHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -612,7 +735,7 @@ fn uninstall_host( } fn uninstall_host_with_operation_timeout( - host: PluginHost, + host: IntegrationHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -632,7 +755,7 @@ fn uninstall_host_with_operation_timeout( } fn uninstall_host_locked( - host: PluginHost, + host: IntegrationHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -664,7 +787,7 @@ fn uninstall_host_locked( } fn retire_installed_generation( - host: PluginHost, + host: IntegrationHost, plugin_root: &Path, local_install_exists: bool, options: &PluginInstallOptions, @@ -699,7 +822,7 @@ fn retire_installed_generation( } fn retire_replacement_before_rollback( - host: PluginHost, + host: IntegrationHost, layout: &PluginLayout, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, @@ -720,15 +843,15 @@ fn retire_replacement_before_rollback( Ok(Some(retirement)) } -fn existing_plugin_install_requires_force_error(host: PluginHost) -> String { +fn existing_plugin_install_requires_force_error(host: IntegrationHost) -> String { format!( "an existing fenced {} plugin install was found; rerun `nemo-relay install {} --force` to replace it safely", - host_label(host), - host_arg(host) + host.label(), + host.as_arg() ) } -fn missing_generation_fence_error(host: PluginHost, generation_fence: &Path) -> String { +fn missing_generation_fence_error(host: IntegrationHost, generation_fence: &Path) -> String { unsafe_generation_fence_error( host, &format!("is missing at {}", generation_fence.display()), @@ -736,7 +859,7 @@ fn missing_generation_fence_error(host: PluginHost, generation_fence: &Path) -> } fn invalid_generation_fence_error( - host: PluginHost, + host: IntegrationHost, generation_fence: &Path, cause: &str, ) -> String { @@ -749,20 +872,22 @@ fn invalid_generation_fence_error( ) } -fn unsafe_generation_fence_error(host: PluginHost, problem: &str) -> String { +fn unsafe_generation_fence_error(host: IntegrationHost, problem: &str) -> String { match host { - PluginHost::Codex => format!( + IntegrationHost::Codex => format!( "cannot safely replace or uninstall an existing Codex plugin because its MCP generation marker {problem}; close all Codex clients and standalone `nemo-relay mcp` processes, run `codex plugin remove nemo-relay-plugin@nemo-relay-local` and `codex plugin marketplace remove nemo-relay-local`, remove the stale marketplace and state from the selected install directory, then run `nemo-relay install codex --force` to create a fenced install (and `nemo-relay uninstall codex` afterward if removal was intended)" ), - PluginHost::ClaudeCode => format!( + IntegrationHost::ClaudeCode => format!( "cannot safely replace or uninstall an existing Claude Code plugin because its MCP generation marker {problem}; close all Claude Code clients and standalone `nemo-relay mcp` processes, run `claude plugin uninstall nemo-relay-plugin` and `claude plugin marketplace remove nemo-relay-local`, remove the stale marketplace and state from the selected install directory, then run `nemo-relay install claude-code --force` to create a fenced install (and `nemo-relay uninstall claude-code` afterward if removal was intended)" ), - PluginHost::All => unreachable!("all is expanded before generation validation"), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before generation validation") + } } } -fn legacy_plugin_without_mcp(host: PluginHost, plugin_root: &Path) -> Result { - if !matches!(host, PluginHost::ClaudeCode) || plugin_root.join(".mcp.json").exists() { +fn legacy_plugin_without_mcp(host: IntegrationHost, plugin_root: &Path) -> Result { + if !matches!(host, IntegrationHost::ClaudeCode) || plugin_root.join(".mcp.json").exists() { return Ok(false); } let manifest_path = plugin_manifest_path(host, plugin_root); @@ -786,7 +911,7 @@ fn legacy_plugin_without_mcp(host: PluginHost, plugin_root: &Path) -> Result { readiness.host_plugin_registered = Some(report.host_plugin_registered); @@ -1091,7 +1224,7 @@ fn generated_manifest_check(path: &Path, expected: &Value, label: &str) -> Resul } fn generated_mcp_config_check( - host: PluginHost, + host: IntegrationHost, path: &Path, expected: &Value, ) -> Result { @@ -1107,14 +1240,18 @@ fn generated_mcp_config_check( return Ok(format!("valid at {}", path.display())); } let expected_server = match host { - PluginHost::Codex => &expected["nemo-relay"], - PluginHost::ClaudeCode => &expected["mcpServers"]["nemo-relay"], - PluginHost::All => unreachable!("all is expanded before MCP validation"), + IntegrationHost::Codex => &expected["nemo-relay"], + IntegrationHost::ClaudeCode => &expected["mcpServers"]["nemo-relay"], + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before MCP validation") + } }; let actual_server = match host { - PluginHost::Codex => &actual["nemo-relay"], - PluginHost::ClaudeCode => &actual["mcpServers"]["nemo-relay"], - PluginHost::All => unreachable!("all is expanded before MCP validation"), + IntegrationHost::Codex => &actual["nemo-relay"], + IntegrationHost::ClaudeCode => &actual["mcpServers"]["nemo-relay"], + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before MCP validation") + } }; let expected_vars = expected_server["env_vars"] .as_array() @@ -1137,7 +1274,7 @@ fn generated_mcp_config_check( "MCP server at {} is missing forwarded environment variables: {}; run `nemo-relay install {} --force`", path.display(), missing.join(", "), - host_arg(host) + host.as_arg() )); } Err(format!( @@ -1146,22 +1283,26 @@ fn generated_mcp_config_check( )) } -fn marketplace_manifest_path(host: PluginHost, root: &Path) -> PathBuf { +fn marketplace_manifest_path(host: IntegrationHost, root: &Path) -> PathBuf { match host { - PluginHost::Codex => root + IntegrationHost::Codex => root .join(".agents") .join("plugins") .join("marketplace.json"), - PluginHost::ClaudeCode => root.join(".claude-plugin").join("marketplace.json"), - PluginHost::All => unreachable!("all is expanded before layout resolution"), + IntegrationHost::ClaudeCode => root.join(".claude-plugin").join("marketplace.json"), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before layout resolution") + } } } -fn plugin_manifest_path(host: PluginHost, root: &Path) -> PathBuf { +fn plugin_manifest_path(host: IntegrationHost, root: &Path) -> PathBuf { match host { - PluginHost::Codex => root.join(".codex-plugin").join("plugin.json"), - PluginHost::ClaudeCode => root.join(".claude-plugin").join("plugin.json"), - PluginHost::All => unreachable!("all is expanded before layout resolution"), + IntegrationHost::Codex => root.join(".codex-plugin").join("plugin.json"), + IntegrationHost::ClaudeCode => root.join(".claude-plugin").join("plugin.json"), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before layout resolution") + } } } @@ -1204,7 +1345,7 @@ struct PluginInstallPreflight { } fn prepare_plugin_install( - host: PluginHost, + host: IntegrationHost, layout: &PluginLayout, options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -1237,13 +1378,15 @@ fn prepare_plugin_install( .unwrap_or_else(|| layout.plugin_root.clone()); let previous_generation_fence = previous_plugin_root.join(GENERATION_FILE_NAME); let local_install_exists = match host { - PluginHost::Codex => layout.marketplace_root.exists(), - PluginHost::ClaudeCode => { + IntegrationHost::Codex => layout.marketplace_root.exists(), + IntegrationHost::ClaudeCode => { plugin_manifest_path(host, &previous_plugin_root).exists() || previous_plugin_root.join(".mcp.json").exists() || previous_generation_fence.exists() } - PluginHost::All => unreachable!("all is expanded before install preflight"), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before install preflight") + } }; let previous_install_exists = state_bytes.is_some() || local_install_exists @@ -1336,14 +1479,14 @@ impl ForceInstallSnapshot { } fn stage_plugin_marketplace( - host: PluginHost, + host: IntegrationHost, relay: &Path, target: &PluginLayout, options: &PluginInstallOptions, ) -> Result { let parent = options.install_dir.join(format!( ".{}-install-stage-{}", - host_arg(host), + host.as_arg(), uuid::Uuid::now_v7() )); let layout = PluginLayout::new(host, &parent); @@ -1361,7 +1504,7 @@ fn stage_plugin_marketplace( } fn begin_force_replacement( - host: PluginHost, + host: IntegrationHost, layout: &PluginLayout, preflight: PluginInstallPreflight, options: &PluginInstallOptions, @@ -1386,7 +1529,7 @@ fn begin_force_replacement( .unwrap_or(&options.install_dir); let backup_marketplace_root = backup_parent.join(format!( ".{}-marketplace-backup-{}", - host_arg(host), + host.as_arg(), uuid::Uuid::now_v7() )); let backup_plugin_root = @@ -1396,7 +1539,7 @@ fn begin_force_replacement( .unwrap_or(&options.install_dir) .join(format!( ".{}-plugin-backup-{}", - host_arg(host), + host.as_arg(), uuid::Uuid::now_v7() )) }); @@ -1484,7 +1627,7 @@ fn begin_force_replacement( } fn restore_force_replacement_after_error( - host: PluginHost, + host: IntegrationHost, layout: &PluginLayout, snapshot: &mut ForceInstallSnapshot, options: &PluginInstallOptions, @@ -1501,7 +1644,7 @@ fn restore_force_replacement_after_error( } fn restore_force_replacement( - host: PluginHost, + host: IntegrationHost, layout: &PluginLayout, snapshot: &mut ForceInstallSnapshot, options: &PluginInstallOptions, @@ -1628,7 +1771,7 @@ fn restore_force_replacement( } fn force_cleanup_existing_install( - host: PluginHost, + host: IntegrationHost, layout: &PluginLayout, options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -1652,7 +1795,7 @@ fn force_cleanup_existing_install( } fn rollback_install( - host: PluginHost, + host: IntegrationHost, layout: &PluginLayout, registration: HostRegistrationProgress, setup_installed: bool, @@ -1681,7 +1824,7 @@ fn rollback_install( } fn run_host_unregistration( - host: PluginHost, + host: IntegrationHost, state: &mut PluginState, install_dir: &Path, options: &PluginInstallOptions, @@ -1702,30 +1845,6 @@ fn run_host_unregistration( Ok(()) } -fn host_arg(host: PluginHost) -> &'static str { - match host { - PluginHost::Codex => "codex", - PluginHost::ClaudeCode => "claude-code", - PluginHost::All => "all", - } -} - -fn host_label(host: PluginHost) -> &'static str { - match host { - PluginHost::Codex => "Codex", - PluginHost::ClaudeCode => "Claude Code", - PluginHost::All => "all", - } -} - -fn host_cli(host: PluginHost) -> &'static str { - match host { - PluginHost::Codex => "codex", - PluginHost::ClaudeCode => "claude", - PluginHost::All => unreachable!("all is expanded before host CLI resolution"), - } -} - fn print_json(value: &Value) -> Result<(), String> { let rendered = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?; println!("{rendered}"); diff --git a/crates/cli/src/plugin_install/operation_lock.rs b/crates/cli/src/plugin_install/operation_lock.rs index ceb306c64..adc2e6f69 100644 --- a/crates/cli/src/plugin_install/operation_lock.rs +++ b/crates/cli/src/plugin_install/operation_lock.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use std::thread; use std::time::{Duration, Instant}; -use crate::config::PluginHost; +use crate::config::IntegrationHost; use crate::file_io::{LockAttempt, try_lock_exclusive}; pub(super) const DEFAULT_OPERATION_LOCK_TIMEOUT: Duration = Duration::from_secs(5); @@ -21,7 +21,7 @@ pub(super) struct PluginOperationLock { impl PluginOperationLock { pub(super) fn acquire( - host: PluginHost, + host: IntegrationHost, global_lock_dir: &Path, install_dir: &Path, timeout: Duration, @@ -37,7 +37,7 @@ impl PluginOperationLock { } fn acquire_lock_file( - host: PluginHost, + host: IntegrationHost, directory: &Path, deadline: Instant, scope: &str, @@ -87,14 +87,16 @@ fn acquire_lock_file( } } -pub(super) fn operation_lock_path(host: PluginHost, install_dir: &Path) -> PathBuf { +pub(super) fn operation_lock_path(host: IntegrationHost, install_dir: &Path) -> PathBuf { install_dir.join(format!(".nemo-relay-{}-operation.lock", host_name(host))) } -fn host_name(host: PluginHost) -> &'static str { +fn host_name(host: IntegrationHost) -> &'static str { match host { - PluginHost::Codex => "codex", - PluginHost::ClaudeCode => "claude-code", - PluginHost::All => unreachable!("all is expanded before operation locking"), + IntegrationHost::Codex => "codex", + IntegrationHost::ClaudeCode => "claude-code", + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before operation locking") + } } } diff --git a/crates/cli/src/plugin_install/setup.rs b/crates/cli/src/plugin_install/setup.rs index c988d0ea0..f2ed1a21a 100644 --- a/crates/cli/src/plugin_install/setup.rs +++ b/crates/cli/src/plugin_install/setup.rs @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Plugin-shim setup, restore, and doctor delegation. +//! Host setup, restore, and doctor delegation. -use crate::config::{CodingAgent, PluginHost}; -use crate::plugin_shim; +use crate::config::{CodingAgent, IntegrationHost}; +use crate::plugin_host; use serde_json::Value; use std::path::Path; @@ -13,7 +13,7 @@ use super::state::PluginInstallOptions; use super::state::PluginLayout; pub(super) fn run_plugin_setup( - host: PluginHost, + host: IntegrationHost, layout: &PluginLayout, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, @@ -26,7 +26,7 @@ pub(super) fn run_plugin_setup( } pub(super) fn run_plugin_uninstall( - host: PluginHost, + host: IntegrationHost, plugin_root: &Path, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, @@ -39,7 +39,7 @@ pub(super) fn run_plugin_uninstall( } pub(super) fn run_plugin_doctor( - host: PluginHost, + host: IntegrationHost, plugin_root: &Path, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, @@ -52,34 +52,34 @@ pub(super) fn run_plugin_doctor( } pub(super) fn run_plugin_doctor_json( - host: PluginHost, + host: IntegrationHost, plugin_root: &Path, setup_runner: &dyn PluginSetupRunner, ) -> Result { setup_runner.doctor_json(host, DEFAULT_GATEWAY_URL, plugin_root) } -pub(super) fn setup_action_description(host: PluginHost, action: &str) -> String { +pub(super) fn setup_action_description(host: IntegrationHost, action: &str) -> String { match (host, action) { - (PluginHost::Codex, "configure") => { + (IntegrationHost::Codex, "configure") => { "configure Codex provider and trust plugin-owned hooks".into() } - (PluginHost::Codex, "restore") => "remove Codex provider and plugin hook trust".into(), - (PluginHost::Codex, "doctor") => "check Codex provider and plugin-owned hooks".into(), - (PluginHost::ClaudeCode, "configure") => { + (IntegrationHost::Codex, "restore") => "remove Codex provider and plugin hook trust".into(), + (IntegrationHost::Codex, "doctor") => "check Codex provider and plugin-owned hooks".into(), + (IntegrationHost::ClaudeCode, "configure") => { "enable Claude Code provider routing through NeMo Relay".into() } - (PluginHost::ClaudeCode, "restore") => { + (IntegrationHost::ClaudeCode, "restore") => { "restore Claude Code provider routing from NeMo Relay backup".into() } - (PluginHost::ClaudeCode, "doctor") => "check Claude Code provider routing".into(), - (PluginHost::All, _) => unreachable!("all is expanded before plugin setup"), + (IntegrationHost::ClaudeCode, "doctor") => "check Claude Code provider routing".into(), + (IntegrationHost::All, _) => unreachable!("all is expanded before plugin setup"), (_, _) => unreachable!("unsupported setup action"), } } pub(super) trait PluginSetupRunner { - fn snapshot(&self, _host: PluginHost) -> Result, String> { + fn snapshot(&self, _host: IntegrationHost) -> Result, String> { Ok(None) } @@ -87,22 +87,31 @@ pub(super) trait PluginSetupRunner { Ok(()) } - fn refresh_gateway(&self, _host: PluginHost) -> Result<(), String> { + fn refresh_gateway(&self, _host: IntegrationHost) -> Result<(), String> { Ok(()) } - fn setup(&self, host: PluginHost, gateway_url: &str, plugin_root: &Path) -> Result<(), String>; + fn setup( + &self, + host: IntegrationHost, + gateway_url: &str, + plugin_root: &Path, + ) -> Result<(), String>; fn uninstall( &self, - host: PluginHost, + host: IntegrationHost, + gateway_url: &str, + plugin_root: &Path, + ) -> Result<(), String>; + fn doctor( + &self, + host: IntegrationHost, gateway_url: &str, plugin_root: &Path, ) -> Result<(), String>; - fn doctor(&self, host: PluginHost, gateway_url: &str, plugin_root: &Path) - -> Result<(), String>; fn doctor_json( &self, - host: PluginHost, + host: IntegrationHost, gateway_url: &str, plugin_root: &Path, ) -> Result; @@ -111,94 +120,113 @@ pub(super) trait PluginSetupRunner { pub(super) struct RealPluginSetupRunner; pub(super) enum PluginSetupSnapshot { - Codex(plugin_shim::CodexSetupSnapshot), - Claude(plugin_shim::ClaudeSetupSnapshot), + Codex(plugin_host::CodexSetupSnapshot), + Claude(plugin_host::ClaudeSetupSnapshot), #[cfg(test)] Mock, } impl PluginSetupRunner for RealPluginSetupRunner { - fn snapshot(&self, host: PluginHost) -> Result, String> { + fn snapshot(&self, host: IntegrationHost) -> Result, String> { match host { - PluginHost::Codex => plugin_shim::snapshot_codex_setup() + IntegrationHost::Codex => plugin_host::snapshot_codex_setup() .map(PluginSetupSnapshot::Codex) .map(Some), - PluginHost::ClaudeCode => plugin_shim::snapshot_claude_setup() + IntegrationHost::ClaudeCode => plugin_host::snapshot_claude_setup() .map(PluginSetupSnapshot::Claude) .map(Some), - PluginHost::All => unreachable!("all is expanded before plugin setup"), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before plugin setup") + } } } fn restore_snapshot(&self, snapshot: &PluginSetupSnapshot) -> Result<(), String> { match snapshot { - PluginSetupSnapshot::Codex(snapshot) => plugin_shim::restore_codex_setup(snapshot), - PluginSetupSnapshot::Claude(snapshot) => plugin_shim::restore_claude_setup(snapshot), + PluginSetupSnapshot::Codex(snapshot) => plugin_host::restore_codex_setup(snapshot), + PluginSetupSnapshot::Claude(snapshot) => plugin_host::restore_claude_setup(snapshot), #[cfg(test)] PluginSetupSnapshot::Mock => Ok(()), } } - fn refresh_gateway(&self, host: PluginHost) -> Result<(), String> { + fn refresh_gateway(&self, host: IntegrationHost) -> Result<(), String> { match host { - PluginHost::Codex => plugin_shim::stop_plugin_gateway(CodingAgent::Codex), - PluginHost::ClaudeCode => plugin_shim::stop_plugin_gateway(CodingAgent::ClaudeCode), - PluginHost::All => unreachable!("all is expanded before plugin setup"), + IntegrationHost::Codex => plugin_host::stop_plugin_gateway(CodingAgent::Codex), + IntegrationHost::ClaudeCode => { + plugin_host::stop_plugin_gateway(CodingAgent::ClaudeCode) + } + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before plugin setup") + } } } - fn setup(&self, host: PluginHost, gateway_url: &str, plugin_root: &Path) -> Result<(), String> { + fn setup( + &self, + host: IntegrationHost, + gateway_url: &str, + plugin_root: &Path, + ) -> Result<(), String> { match host { - PluginHost::Codex => plugin_shim::install_codex_plugin(gateway_url, plugin_root), - PluginHost::ClaudeCode => plugin_shim::enable_claude_provider(gateway_url), - PluginHost::All => unreachable!("all is expanded before plugin setup"), + IntegrationHost::Codex => plugin_host::install_codex_plugin(gateway_url, plugin_root), + IntegrationHost::ClaudeCode => plugin_host::enable_claude_provider(gateway_url), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before plugin setup") + } } } fn uninstall( &self, - host: PluginHost, + host: IntegrationHost, gateway_url: &str, plugin_root: &Path, ) -> Result<(), String> { match host { - PluginHost::Codex => plugin_shim::uninstall_codex_plugin(gateway_url, plugin_root), - PluginHost::ClaudeCode => plugin_shim::restore_claude_provider(gateway_url), - PluginHost::All => unreachable!("all is expanded before plugin uninstall"), + IntegrationHost::Codex => plugin_host::uninstall_codex_plugin(gateway_url, plugin_root), + IntegrationHost::ClaudeCode => plugin_host::restore_claude_provider(gateway_url), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before plugin uninstall") + } } } fn doctor( &self, - host: PluginHost, + host: IntegrationHost, gateway_url: &str, plugin_root: &Path, ) -> Result<(), String> { match host { - PluginHost::Codex => { - plugin_shim::doctor_plugin(CodingAgent::Codex, gateway_url, plugin_root) + IntegrationHost::Codex => { + plugin_host::doctor_plugin(CodingAgent::Codex, gateway_url, plugin_root) } - PluginHost::ClaudeCode => { - plugin_shim::doctor_plugin(CodingAgent::ClaudeCode, gateway_url, plugin_root) + IntegrationHost::ClaudeCode => { + plugin_host::doctor_plugin(CodingAgent::ClaudeCode, gateway_url, plugin_root) + } + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before plugin doctor") } - PluginHost::All => unreachable!("all is expanded before plugin doctor"), } } fn doctor_json( &self, - host: PluginHost, + host: IntegrationHost, gateway_url: &str, plugin_root: &Path, ) -> Result { match host { - PluginHost::Codex => { - plugin_shim::doctor_plugin_json(CodingAgent::Codex, gateway_url, plugin_root) + IntegrationHost::Codex => { + plugin_host::doctor_plugin_json(CodingAgent::Codex, gateway_url, plugin_root) + } + IntegrationHost::ClaudeCode => { + plugin_host::doctor_plugin_json(CodingAgent::ClaudeCode, gateway_url, plugin_root) } - PluginHost::ClaudeCode => { - plugin_shim::doctor_plugin_json(CodingAgent::ClaudeCode, gateway_url, plugin_root) + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before plugin doctor") } - PluginHost::All => unreachable!("all is expanded before plugin doctor"), } } } diff --git a/crates/cli/src/plugin_install/state.rs b/crates/cli/src/plugin_install/state.rs index e40e86fdc..b98c5a96a 100644 --- a/crates/cli/src/plugin_install/state.rs +++ b/crates/cli/src/plugin_install/state.rs @@ -10,10 +10,10 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; -use crate::config::PluginHost; +use crate::config::IntegrationHost; use crate::install_generation::GENERATION_FILE_NAME; -use super::{PLUGIN_NAME, host_arg}; +use super::PLUGIN_NAME; #[derive(Debug, Clone)] pub(super) struct PluginInstallOptions { @@ -44,7 +44,7 @@ impl HostRegistrationProgress { #[derive(Debug, Clone)] pub(super) struct PluginLayout { - pub(super) host: PluginHost, + pub(super) host: IntegrationHost, pub(super) marketplace_root: PathBuf, pub(super) marketplace_manifest: PathBuf, pub(super) plugin_root: PathBuf, @@ -56,23 +56,27 @@ pub(super) struct PluginLayout { } impl PluginLayout { - pub(super) fn new(host: PluginHost, install_dir: &Path) -> Self { - let marketplace_root = install_dir.join(format!("{}-marketplace", host_arg(host))); + pub(super) fn new(host: IntegrationHost, install_dir: &Path) -> Self { + let marketplace_root = install_dir.join(format!("{}-marketplace", host.as_arg())); let marketplace_manifest = match host { - PluginHost::Codex => marketplace_root + IntegrationHost::Codex => marketplace_root .join(".agents") .join("plugins") .join("marketplace.json"), - PluginHost::ClaudeCode => marketplace_root + IntegrationHost::ClaudeCode => marketplace_root .join(".claude-plugin") .join("marketplace.json"), - PluginHost::All => unreachable!("all is expanded before layout resolution"), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before layout resolution") + } }; let plugin_root = marketplace_root.join("plugins").join(PLUGIN_NAME); let plugin_manifest = match host { - PluginHost::Codex => plugin_root.join(".codex-plugin").join("plugin.json"), - PluginHost::ClaudeCode => plugin_root.join(".claude-plugin").join("plugin.json"), - PluginHost::All => unreachable!("all is expanded before layout resolution"), + IntegrationHost::Codex => plugin_root.join(".codex-plugin").join("plugin.json"), + IntegrationHost::ClaudeCode => plugin_root.join(".claude-plugin").join("plugin.json"), + IntegrationHost::Hermes | IntegrationHost::All => { + unreachable!("all is expanded before layout resolution") + } }; let mcp_config = plugin_root.join(".mcp.json"); let generation_fence = plugin_root.join(GENERATION_FILE_NAME); @@ -173,7 +177,7 @@ pub(super) fn write_state( } pub(super) fn mark_plugin_setup_installed( - host: PluginHost, + host: IntegrationHost, layout: &PluginLayout, options: &PluginInstallOptions, ) -> Result<(), String> { @@ -189,7 +193,7 @@ pub(super) fn mark_plugin_setup_installed( } pub(super) fn write_state_for_host( - host: PluginHost, + host: IntegrationHost, state: &PluginState, install_dir: &Path, options: &PluginInstallOptions, @@ -202,7 +206,7 @@ pub(super) fn write_state_for_host( write_json( &path, &json!({ - "host": host_arg(host), + "host": host.as_arg(), "marketplaceRoot": state.marketplace_root, "pluginRoot": state.plugin_root, "hostUnregistered": state.host_plugin_removed && state.host_marketplace_removed, @@ -213,7 +217,7 @@ pub(super) fn write_state_for_host( ) } -pub(super) fn read_state(host: PluginHost, install_dir: &Path) -> Option { +pub(super) fn read_state(host: IntegrationHost, install_dir: &Path) -> Option { let raw = fs::read_to_string(state_path(host, install_dir)).ok()?; let value = serde_json::from_str::(&raw).ok()?; let legacy_host_unregistered = value @@ -238,8 +242,8 @@ pub(super) fn read_state(host: PluginHost, install_dir: &Path) -> Option PathBuf { - install_dir.join(format!("{}.json", host_arg(host))) +pub(super) fn state_path(host: IntegrationHost, install_dir: &Path) -> PathBuf { + install_dir.join(format!("{}.json", host.as_arg())) } pub(super) fn write_json(path: &Path, value: &Value) -> Result<(), String> { diff --git a/crates/cli/src/plugin_shim/command.rs b/crates/cli/src/plugin_shim/command.rs deleted file mode 100644 index c451e8899..000000000 --- a/crates/cli/src/plugin_shim/command.rs +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Hidden `plugin-shim` CLI surface used by installed hooks and installer orchestration. - -use clap::{Args, Subcommand, ValueEnum}; - -use crate::config::CodingAgent; - -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginShimCommand { - #[command(subcommand)] - pub(crate) command: PluginShimSubcommand, -} - -#[derive(Debug, Clone, Subcommand)] -pub(crate) enum PluginShimSubcommand { - Serve(PluginShimServeCommand), - Hook(PluginShimHookCommand), - Install(PluginShimInstallCommand), - Uninstall(PluginShimUninstallCommand), - Provider(PluginShimProviderCommand), - Doctor(PluginShimDoctorCommand), -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginShimServeCommand { - #[arg(allow_hyphen_values = true, trailing_var_arg = true)] - pub(crate) args: Vec, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginShimHookCommand { - #[arg(value_enum)] - pub(crate) agent: CodingAgent, - #[arg(long)] - pub(crate) gateway_url: Option, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginShimInstallCommand { - #[arg(value_enum)] - pub(crate) agent: CodingAgent, - #[arg(long, default_value = "http://127.0.0.1:47632")] - pub(crate) gateway_url: String, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginShimUninstallCommand { - #[arg(value_enum)] - pub(crate) agent: CodingAgent, - #[arg(long, default_value = "http://127.0.0.1:47632")] - pub(crate) gateway_url: String, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginShimProviderCommand { - #[arg(value_enum)] - pub(crate) agent: CodingAgent, - #[arg(value_enum)] - pub(crate) action: PluginShimProviderAction, - #[arg(long, default_value = "http://127.0.0.1:47632")] - pub(crate) gateway_url: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] -#[value(rename_all = "kebab-case")] -pub(crate) enum PluginShimProviderAction { - Enable, - Restore, - Status, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginShimDoctorCommand { - #[arg(value_enum)] - pub(crate) agent: CodingAgent, - #[arg(long, default_value = "http://127.0.0.1:47632")] - pub(crate) gateway_url: String, -} diff --git a/crates/cli/src/plugin_shim/mod.rs b/crates/cli/src/plugin_shim/mod.rs deleted file mode 100644 index 7f45f015e..000000000 --- a/crates/cli/src/plugin_shim/mod.rs +++ /dev/null @@ -1,434 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Platform-neutral launcher and hook shim for packaged coding-agent plugins. - -mod claude; -mod codex; -mod codex_app_server; -mod command; -mod shared; - -pub(crate) use claude::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; -pub(crate) use codex::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; -pub(crate) use shared::portable_executable_path; -pub(crate) use shared::shell_quote_for_platform; -#[cfg(test)] -pub(crate) use shared::strip_windows_verbatim_prefix; - -pub(crate) use command::PluginShimCommand; - -use std::env; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode}; - -use serde_json::{Value, json}; - -use claude::{claude_provider, claude_settings_base_url}; -use codex::{codex_hook_trust_report, empty_codex_hook_trust_report}; -use codex::{codex_hooks_installed, codex_provider_installed, install_codex, uninstall_codex}; -use command::{ - PluginShimDoctorCommand, PluginShimInstallCommand, PluginShimProviderAction, - PluginShimProviderCommand, PluginShimSubcommand, PluginShimUninstallCommand, -}; -#[cfg(test)] -use shared::MAX_HOOK_RESPONSE_BYTES; -use shared::{ - ExecOrStatus, HookForwardError, current_exe, fail_closed, gateway_url, healthz, home_dir, - plugin_idle_timeout, post_hook, print_check, print_info, relay_binary, -}; - -use crate::config::{CodingAgent, ServerArgs}; -use crate::error::CliError; - -pub(super) use crate::sidecar::{DEFAULT_BIND, DEFAULT_URL}; -const DEFAULT_HOOK_STDIN_BYTES: usize = crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES; - -pub(crate) fn run(command: PluginShimCommand) -> Result { - match command.command { - PluginShimSubcommand::Serve(command) => serve(command.args), - PluginShimSubcommand::Hook(command) => hook(command.agent, command.gateway_url.as_deref()), - PluginShimSubcommand::Install(command) => install(command), - PluginShimSubcommand::Uninstall(command) => uninstall(command), - PluginShimSubcommand::Provider(command) => provider(command), - PluginShimSubcommand::Doctor(command) => doctor(command), - } - .map_err(CliError::Install) -} - -fn serve(args: Vec) -> Result { - let relay = relay_binary()?; - let bind = env::var("NEMO_RELAY_PLUGIN_BIND").unwrap_or_else(|_| DEFAULT_BIND.into()); - let mut command = Command::new(relay); - command.arg("--bind").arg(bind).args(args); - command.env( - "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", - plugin_idle_timeout()?.as_secs().to_string(), - ); - command - .exec_or_status() - .map_err(|error| format!("failed to start nemo-relay sidecar: {error}")) -} - -fn hook(agent: CodingAgent, explicit_gateway_url: Option<&str>) -> Result { - let url = gateway_url(agent, explicit_gateway_url); - let plugin_launch = crate::sidecar::loopback_bind(&url).and_then(|bind| { - crate::sidecar::resolve_plugin_gateway(agent, &ServerArgs::default(), bind) - .map_err(|error| error.to_string()) - }); - let max_hook_payload_bytes = plugin_launch - .as_ref() - .map_or(DEFAULT_HOOK_STDIN_BYTES, |launch| { - launch.max_hook_payload_bytes - }); - let mut input = std::io::stdin(); - let mut output = std::io::stdout(); - hook_with_io( - HookInvocation { - agent, - gateway_url: Some(&url), - max_payload_bytes: max_hook_payload_bytes, - preflight_gateway: matches!(agent, CodingAgent::Codex), - }, - &mut input, - &mut output, - |_agent, _url| match plugin_launch.as_ref() { - Ok(launch) => launch.gateway.ensure().map(|_| ()), - Err(error) => Err(error.clone()), - }, - post_hook, - fail_closed, - ) -} - -#[derive(Clone, Copy)] -struct HookInvocation<'a> { - agent: CodingAgent, - gateway_url: Option<&'a str>, - max_payload_bytes: usize, - preflight_gateway: bool, -} - -fn hook_with_io( - invocation: HookInvocation<'_>, - input: &mut R, - output: &mut W, - mut ensure_sidecar: E, - mut post_hook: P, - fail_closed: F, -) -> Result -where - R: Read, - W: Write, - E: FnMut(CodingAgent, &str) -> Result<(), String>, - P: FnMut(CodingAgent, &str, &[u8]) -> Result, HookForwardError>, - F: FnOnce() -> bool, -{ - let agent = invocation.agent; - let url = gateway_url(agent, invocation.gateway_url); - let fail_closed = fail_closed(); - let mut payload = Vec::new(); - if let Err(error) = read_hook_payload(input, &mut payload, invocation.max_payload_bytes) { - if fail_closed { - return Err(error); - } - eprintln!("{error}"); - return Ok(ExitCode::SUCCESS); - } - if payload.iter().all(u8::is_ascii_whitespace) { - payload = b"{}".to_vec(); - } - let preflight = if invocation.preflight_gateway { - ensure_sidecar(agent, &url) - } else { - Ok(()) - }; - let forwarded = match preflight { - Err(error) => Err(HookForwardError::not_retryable(format!( - "gateway identity preflight failed: {error}" - ))), - Ok(()) => match post_hook(agent, &url, &payload) { - Err(error) if error.is_retryable() => match ensure_sidecar(agent, &url) { - Ok(()) => post_hook(agent, &url, &payload), - Err(start_error) => Err(HookForwardError::not_retryable(format!( - "{error}; sidecar bootstrap failed: {start_error}" - ))), - }, - result => result, - }, - }; - match forwarded { - Ok(body) => { - if !body.is_empty() { - output - .write_all(&body) - .map_err(|error| format!("failed to write hook response: {error}"))?; - } - Ok(ExitCode::SUCCESS) - } - Err(error) if fail_closed => Err(error.to_string()), - Err(error) => { - eprintln!("{error}"); - Ok(ExitCode::SUCCESS) - } - } -} - -fn read_hook_payload( - input: &mut R, - payload: &mut Vec, - limit: usize, -) -> Result<(), String> { - input - .take(limit.saturating_add(1) as u64) - .read_to_end(payload) - .map_err(|error| format!("failed to read hook payload: {error}"))?; - if payload.len() > limit { - return Err(format!("hook payload exceeds the {limit}-byte limit")); - } - Ok(()) -} - -fn install(command: PluginShimInstallCommand) -> Result { - match command.agent { - CodingAgent::Codex => install_codex(&command.gateway_url, &plugin_hooks_path_from_env()?), - CodingAgent::Hermes => { - let home = home_dir()?; - let relay = relay_binary()?; - crate::hermes::install_persistent(&crate::hermes::user_config_path(&home), &relay) - .map_err(|error| error.to_string())?; - Ok(ExitCode::SUCCESS) - } - other => Err(format!( - "plugin install supports codex and hermes, got {}", - other.as_arg() - )), - } -} - -fn uninstall(command: PluginShimUninstallCommand) -> Result { - match command.agent { - CodingAgent::Codex => uninstall_codex(&command.gateway_url, &plugin_hooks_path_from_env()?), - CodingAgent::Hermes => { - let home = home_dir()?; - crate::hermes::uninstall_persistent(&crate::hermes::user_config_path(&home)) - .map_err(|error| error.to_string())?; - Ok(ExitCode::SUCCESS) - } - other => Err(format!( - "plugin uninstall supports codex and hermes, got {}", - other.as_arg() - )), - } -} - -fn provider(command: PluginShimProviderCommand) -> Result { - match command.agent { - CodingAgent::ClaudeCode => claude_provider(command.action, &command.gateway_url), - other => Err(format!( - "plugin provider supports claude, got {}", - other.as_arg() - )), - } -} - -fn doctor(command: PluginShimDoctorCommand) -> Result { - let plugin_hooks = matches!(command.agent, CodingAgent::Codex) - .then(plugin_hooks_path_from_env) - .transpose()?; - Ok( - if doctor_ok(command.agent, &command.gateway_url, plugin_hooks.as_deref())? { - ExitCode::SUCCESS - } else { - ExitCode::FAILURE - }, - ) -} - -pub(crate) fn install_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Result<(), String> { - install_codex(gateway_url, &plugin_root.join("hooks").join("hooks.json")).map(|_| ()) -} - -pub(crate) fn stop_plugin_gateway(agent: CodingAgent) -> Result<(), String> { - crate::sidecar::stop_owned_sidecar(agent) -} - -pub(crate) fn codex_plugin_hook_command(relay: &std::path::Path) -> String { - codex::codex_plugin_hook_command(relay) -} - -pub(crate) fn uninstall_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Result<(), String> { - uninstall_codex(gateway_url, &plugin_root.join("hooks").join("hooks.json")).map(|_| ()) -} - -pub(crate) fn enable_claude_provider(gateway_url: &str) -> Result<(), String> { - claude_provider(PluginShimProviderAction::Enable, gateway_url).map(|_| ()) -} - -pub(crate) fn restore_claude_provider(gateway_url: &str) -> Result<(), String> { - claude_provider(PluginShimProviderAction::Restore, gateway_url).map(|_| ()) -} - -pub(crate) fn doctor_plugin( - agent: CodingAgent, - gateway_url: &str, - plugin_root: &Path, -) -> Result<(), String> { - if doctor_ok( - agent, - gateway_url, - Some(&plugin_root.join("hooks").join("hooks.json")), - )? { - Ok(()) - } else { - Err(format!("{} plugin doctor checks failed", agent.as_arg())) - } -} - -pub(crate) fn doctor_plugin_json( - agent: CodingAgent, - gateway_url: &str, - plugin_root: &Path, -) -> Result { - let plugin_binary = current_exe().ok().is_some_and(|path| path.exists()); - let sidecar_running = healthz(gateway_url); - let (checks, ok, codex_trust) = match agent { - CodingAgent::ClaudeCode => { - let provider = claude_settings_base_url().as_deref() == Some(gateway_url); - ( - json!({ - "plugin_binary": plugin_binary, - "sidecar_running": sidecar_running, - "claude_provider_routing": provider - }), - plugin_binary && provider, - None, - ) - } - CodingAgent::Codex => { - let plugin_hooks_path = plugin_root.join("hooks").join("hooks.json"); - let provider = codex_provider_installed(gateway_url); - let hooks = codex_hooks_installed(&plugin_hooks_path)?; - let trust = if hooks { - codex_hook_trust_report(&plugin_hooks_path)? - } else { - empty_codex_hook_trust_report() - }; - let hooks_trusted = trust.ready(); - ( - json!({ - "plugin_binary": plugin_binary, - "sidecar_running": sidecar_running, - "codex_provider_alias": provider, - "codex_hooks": hooks, - "codex_hooks_trusted": hooks_trusted - }), - plugin_binary && provider && hooks && hooks_trusted, - Some(trust), - ) - } - other => { - return Err(format!( - "plugin doctor supports claude and codex, got {}", - other.as_arg() - )); - } - }; - let mut report = json!({ - "ok": ok, - "sidecar_health": if sidecar_running { - "running" - } else if matches!(agent, CodingAgent::Codex) { - "not_running_mcp_start" - } else { - "not_running_lazy_start" - }, - "checks": checks - }); - if let Some(trust) = codex_trust { - report["codex_hook_trust"] = trust.to_json(); - } - Ok(report) -} - -fn doctor_ok( - agent: CodingAgent, - gateway_url: &str, - plugin_hooks_path: Option<&Path>, -) -> Result { - let mut ok = true; - ok &= print_check( - "plugin binary", - current_exe().ok().is_some_and(|path| path.exists()), - ); - if healthz(gateway_url) { - print_info("sidecar health", "running"); - } else if matches!(agent, CodingAgent::Codex) { - print_info( - "sidecar health", - "not running; the required plugin MCP starts it before the captured turn", - ); - } else { - print_info( - "sidecar health", - "not running; the plugin MCP or first hook starts it lazily", - ); - } - match agent { - CodingAgent::ClaudeCode => { - ok &= print_check( - "claude provider routing", - claude_settings_base_url().as_deref() == Some(gateway_url), - ); - } - CodingAgent::Codex => { - let plugin_hooks_path = plugin_hooks_path - .ok_or_else(|| "Codex plugin hooks path is required for doctor".to_string())?; - let provider = codex_provider_installed(gateway_url); - let hooks = codex_hooks_installed(plugin_hooks_path)?; - ok &= print_check("codex provider alias", provider); - ok &= print_check("codex hooks", hooks); - let trust = if hooks { - codex_hook_trust_report(plugin_hooks_path)? - } else { - empty_codex_hook_trust_report() - }; - ok &= print_check("codex hooks trusted and enabled", trust.ready()); - if !trust.ready() { - print_info("codex hook trust", &trust.summary()); - } - } - other => { - return Err(format!( - "plugin doctor supports claude and codex, got {}", - other.as_arg() - )); - } - } - Ok(ok) -} - -fn plugin_hooks_path_from_env() -> Result { - env::var_os("PLUGIN_ROOT") - .map(PathBuf::from) - .map(|root| root.join("hooks").join("hooks.json")) - .ok_or_else(|| "PLUGIN_ROOT is required for Codex plugin hook setup".into()) -} - -#[cfg(test)] -use crate::installer::generated_hooks; -#[cfg(test)] -use crate::sidecar::*; -#[cfg(test)] -use claude::*; -#[cfg(test)] -use codex::*; -#[cfg(test)] -use codex_app_server::*; -#[cfg(test)] -use shared::*; - -#[cfg(test)] -#[path = "../../tests/coverage/plugin_shim_tests.rs"] -mod tests; diff --git a/crates/cli/src/setup/model.rs b/crates/cli/src/setup/model.rs index 8167615a2..9a15eac9d 100644 --- a/crates/cli/src/setup/model.rs +++ b/crates/cli/src/setup/model.rs @@ -322,6 +322,9 @@ pub(crate) fn hermes_config_path_for_agents( /// Installs Hermes's lifecycle-bound MCP client and exact trusted Relay hooks transactionally. pub(crate) fn install_hermes_integration(home: &Path) -> Result, CliError> { + CodingAgent::Hermes + .validate_executable(Path::new(CodingAgent::Hermes.executable())) + .map_err(CliError::Install)?; let relay = std::env::current_exe().map_err(|error| { CliError::Install(format!( "failed to resolve the nemo-relay executable: {error}" @@ -401,11 +404,7 @@ pub(super) fn read_agents_from_doc(doc: &DocumentMut) -> Vec { } pub(super) fn agent_key_and_command(agent: CodingAgent) -> (&'static str, &'static str) { - match agent { - CodingAgent::ClaudeCode => ("claude", "claude"), - CodingAgent::Codex => ("codex", "codex"), - CodingAgent::Hermes => ("hermes", "hermes"), - } + (agent.as_arg(), agent.executable()) } pub(super) fn preview_paths(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec { diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs index b14b3e485..c35bf39ea 100644 --- a/crates/cli/src/sidecar.rs +++ b/crates/cli/src/sidecar.rs @@ -303,7 +303,7 @@ fn incompatible_relay_error(agent: CodingAgent, url: &str) -> String { let remediation = match agent { CodingAgent::Codex => "run `nemo-relay install codex --force`", CodingAgent::ClaudeCode => "run `nemo-relay install claude-code --force`", - CodingAgent::Hermes => "rerun `nemo-relay config hermes`", + CodingAgent::Hermes => "run `nemo-relay install hermes --force`", }; format!( "{url} is occupied by NeMo Relay with a different version or persistent configuration; stop it, wait for its idle shutdown, or {remediation} before retrying" diff --git a/crates/cli/src/sidecar/health.rs b/crates/cli/src/sidecar/health.rs index 31f7ee2ff..9cdcc529f 100644 --- a/crates/cli/src/sidecar/health.rs +++ b/crates/cli/src/sidecar/health.rs @@ -197,10 +197,10 @@ fn split_http_response(response: &[u8]) -> Option<(&[u8], &[u8])> { pub(crate) fn parse_loopback_url(url: &str) -> Result<(String, u16), String> { let parsed = Url::parse(url) - .map_err(|error| format!("invalid plugin shim loopback URL {url}: {error}"))?; + .map_err(|error| format!("invalid shared gateway loopback URL {url}: {error}"))?; if parsed.scheme() != "http" { return Err(format!( - "plugin shim only supports http loopback URLs: {url}" + "shared gateway recovery only supports http loopback URLs: {url}" )); } let host = parsed @@ -214,7 +214,7 @@ pub(crate) fn parse_loopback_url(url: &str) -> Result<(String, u16), String> { .is_ok_and(|address| address.is_loopback()); if !loopback { return Err(format!( - "plugin shim only supports loopback gateway URLs: {url}" + "shared gateway recovery only supports loopback gateway URLs: {url}" )); } let port = parsed diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 4f5ef8850..290bbbb0a 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -179,7 +179,7 @@ fn cli_mcp_help_describes_lifecycle_bound_native_gateway() { } #[test] -fn cli_mcp_initializes_and_exits_cleanly_when_stdio_closes() { +fn cli_mcp_starts_gateway_before_initialize_and_exits_cleanly() { let temp = tempfile::tempdir().unwrap(); let mut child = Command::new(gateway_bin()) .args(["--bind", "127.0.0.1:0", "mcp"]) @@ -187,20 +187,21 @@ fn cli_mcp_initializes_and_exits_cleanly_when_stdio_closes() { .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) .env("TMPDIR", temp.path()) - .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "1") + .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "30") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); - child - .stdin - .take() - .unwrap() + let owner = wait_for_owned_sidecar(temp.path(), "codex", None); + let address = sidecar_address(temp.path(), "codex"); + let mut stdin = child.stdin.take().unwrap(); + stdin .write_all( b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\"}}\n", ) .unwrap(); + drop(stdin); let output = wait_child_with_output(child); @@ -221,17 +222,20 @@ fn cli_mcp_initializes_and_exits_cleanly_when_stdio_closes() { ) .unwrap(); assert!(log.contains("Gateway http://127.0.0.1:")); + stop_owned_sidecar(&owner); + wait_for_port_closed(address); } #[test] -fn cli_mcp_does_not_launch_gateway_when_stdio_closes_before_request() { +fn cli_mcp_starts_gateway_even_when_stdio_closes_before_request() { let temp = tempfile::tempdir().unwrap(); let mut child = Command::new(gateway_bin()) - .args(["mcp"]) + .args(["--bind", "127.0.0.1:0", "mcp"]) .env("HOME", temp.path()) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) .env("TMPDIR", temp.path()) + .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "30") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -246,7 +250,11 @@ fn cli_mcp_does_not_launch_gateway_when_stdio_closes_before_request() { String::from_utf8_lossy(&output.stderr) ); assert!(output.stdout.is_empty()); - assert!(find_runtime_file(temp.path(), "codex-sidecar.log").is_none()); + assert!(find_runtime_file(temp.path(), "codex-sidecar.log").is_some()); + let owner = wait_for_owned_sidecar(temp.path(), "codex", None); + let address = sidecar_address(temp.path(), "codex"); + stop_owned_sidecar(&owner); + wait_for_port_closed(address); } #[cfg(unix)] @@ -264,7 +272,7 @@ fn cli_internal_hermes_install_writes_mcp_hooks_trust_and_doctor_ready_state() { std::fs::create_dir_all(directory).unwrap(); } let hermes = bin.join("hermes"); - std::fs::write(&hermes, "#!/bin/sh\necho 'Hermes 1.0.0'\n").unwrap(); + std::fs::write(&hermes, "#!/bin/sh\necho 'Hermes Agent v0.18.2 (test)'\n").unwrap(); std::fs::set_permissions(&hermes, std::fs::Permissions::from_mode(0o755)).unwrap(); let path = std::env::join_paths(std::iter::once(bin.clone()).chain(std::env::split_paths( &std::env::var_os("PATH").unwrap_or_default(), @@ -272,7 +280,7 @@ fn cli_internal_hermes_install_writes_mcp_hooks_trust_and_doctor_ready_state() { .unwrap(); let install = Command::new(gateway_bin()) - .args(["plugin-shim", "install", "hermes"]) + .args(["install", "hermes", "--skip-doctor"]) .env("HOME", &home) .env("HERMES_HOME", &hermes_home) .env("XDG_CONFIG_HOME", &xdg) @@ -306,7 +314,7 @@ fn cli_internal_hermes_install_writes_mcp_hooks_trust_and_doctor_ready_state() { let command = config["hooks"]["on_session_start"][0]["command"] .as_str() .unwrap(); - assert!(command.contains("plugin-shim hook hermes")); + assert!(command.contains("hook-forward hermes")); let approvals: serde_json::Value = serde_json::from_str( &std::fs::read_to_string(hermes_home.join("shell-hooks-allowlist.json")).unwrap(), ) @@ -352,7 +360,7 @@ fn cli_internal_hermes_install_writes_mcp_hooks_trust_and_doctor_ready_state() { ); let uninstall = Command::new(gateway_bin()) - .args(["plugin-shim", "uninstall", "hermes"]) + .args(["uninstall", "hermes"]) .env("HOME", &home) .env("HERMES_HOME", &hermes_home) .env("XDG_CONFIG_HOME", &xdg) @@ -435,7 +443,7 @@ fn cli_hooks_and_mcp_share_the_same_persistent_identity_for_each_host() { drop(probe); let gateway_url = format!("http://{address}"); let mut hook = Command::new(gateway_bin()) - .args(["plugin-shim", "hook", agent, "--gateway-url", &gateway_url]) + .args(["hook-forward", agent, "--gateway-url", &gateway_url]) .env("HOME", temp.path()) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) @@ -582,8 +590,7 @@ fn run_fake_bootstrap_listener(proof: FakeBootstrapProof) -> (Output, Vec Output { let mut command = Command::new(gateway_bin()); command .args([ - "plugin-shim", - "hook", + "hook-forward", "codex", "--gateway-url", "http://127.0.0.1:1", @@ -658,11 +665,13 @@ fn run_codex_hook_with_launch_resolution_error( .env("XDG_CONFIG_HOME", temp.join("xdg")) .env("XDG_RUNTIME_DIR", temp.join("runtime")) .env("TMPDIR", temp) - .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "not-a-number") .env_remove("NEMO_RELAY_FAIL_CLOSED") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + if invalid_idle_timeout { + command.env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "not-a-number"); + } if fail_closed { command.env("NEMO_RELAY_FAIL_CLOSED", "1"); } @@ -676,10 +685,10 @@ fn cli_codex_hook_launch_resolution_error_respects_forwarding_policy() { let temp = tempfile::tempdir().unwrap(); for fail_closed in [false, true] { - let output = run_codex_hook_with_launch_resolution_error(temp.path(), fail_closed, b"{}"); + let output = + run_codex_hook_with_launch_resolution_error(temp.path(), fail_closed, b"{}", true); assert_eq!(output.status.success(), !fail_closed); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("gateway identity preflight failed")); assert!(stderr.contains("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS")); assert!(output.stdout.is_empty()); } @@ -693,12 +702,12 @@ fn cli_codex_hook_launch_resolution_error_retains_default_payload_cap() { for fail_closed in [false, true] { let output = - run_codex_hook_with_launch_resolution_error(temp.path(), fail_closed, &payload); + run_codex_hook_with_launch_resolution_error(temp.path(), fail_closed, &payload, false); assert_eq!(output.status.success(), !fail_closed); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("hook payload exceeds the 20971520-byte limit")); - assert!(!stderr.contains("gateway identity preflight failed")); + assert!(!stderr.contains("sidecar recovery")); assert!(output.stdout.is_empty()); } } @@ -2400,40 +2409,6 @@ fn cli_run_rejects_zero_body_limit_env() { assert!(stderr.contains("greater than 0")); } -#[test] -fn cli_hook_forward_fails_open_without_gateway_url() { - let mut child = Command::new(gateway_bin()) - .env_remove("NEMO_RELAY_GATEWAY_URL") - .args(["hook-forward", "codex"]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child.stdin.take().unwrap().write_all(b"").unwrap(); - let output = child.wait_with_output().unwrap(); - - assert!(output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("missing gateway URL")); -} - -#[test] -fn cli_hook_forward_fails_closed_without_gateway_url() { - let mut child = Command::new(gateway_bin()) - .env_remove("NEMO_RELAY_GATEWAY_URL") - .args(["hook-forward", "codex", "--fail-closed"]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child.stdin.take().unwrap().write_all(b"{}").unwrap(); - let output = child.wait_with_output().unwrap(); - - assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("missing gateway URL")); -} - #[test] fn cli_hook_forward_posts_payload_headers_and_prints_response() { let (server_url, received) = spawn_single_request_server(200, r#"{"continue":true}"#); @@ -2441,8 +2416,6 @@ fn cli_hook_forward_posts_payload_headers_and_prints_response() { .args([ "hook-forward", "codex", - "--gateway-url", - &server_url, "--profile", "coverage", "--session-metadata", @@ -2451,6 +2424,7 @@ fn cli_hook_forward_posts_payload_headers_and_prints_response() { "passthrough", "--fail-closed", ]) + .env("NEMO_RELAY_GATEWAY_URL", &server_url) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -2480,13 +2454,8 @@ fn cli_hook_forward_posts_payload_headers_and_prints_response() { fn cli_hook_forward_hermes_shell_hook_returns_empty_object() { let (server_url, received) = spawn_single_request_server(200, r#"{}"#); let mut child = Command::new(gateway_bin()) - .args([ - "hook-forward", - "hermes", - "--gateway-url", - &server_url, - "--fail-closed", - ]) + .args(["hook-forward", "hermes", "--fail-closed"]) + .env("NEMO_RELAY_GATEWAY_URL", &server_url) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -2513,13 +2482,8 @@ fn cli_hook_forward_hermes_shell_hook_returns_empty_object() { fn cli_hook_forward_reports_http_failure_when_fail_closed() { let (server_url, received) = spawn_single_request_server(503, "unavailable"); let mut child = Command::new(gateway_bin()) - .args([ - "hook-forward", - "hermes", - "--gateway-url", - &server_url, - "--fail-closed", - ]) + .args(["hook-forward", "hermes", "--fail-closed"]) + .env("NEMO_RELAY_GATEWAY_URL", &server_url) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -2541,7 +2505,8 @@ fn cli_hook_forward_exits_two_for_guardrail_rejection() { r#"{"error":{"message":"guardrail rejected: blocked by policy","type":"nemo_relay_guardrail_rejected","reason":"blocked by policy"}}"#, ); let mut child = Command::new(gateway_bin()) - .args(["hook-forward", "codex", "--gateway-url", &server_url]) + .args(["hook-forward", "codex"]) + .env("NEMO_RELAY_GATEWAY_URL", &server_url) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -2559,13 +2524,8 @@ fn cli_hook_forward_exits_two_for_guardrail_rejection() { #[test] fn cli_hook_forward_reports_transport_failure_when_fail_closed() { let mut child = Command::new(gateway_bin()) - .args([ - "hook-forward", - "codex", - "--gateway-url", - "http://127.0.0.1:1", - "--fail-closed", - ]) + .args(["hook-forward", "codex", "--fail-closed"]) + .env("NEMO_RELAY_GATEWAY_URL", "http://127.0.0.1:1") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -2578,10 +2538,41 @@ fn cli_hook_forward_reports_transport_failure_when_fail_closed() { assert!(String::from_utf8_lossy(&output.stderr).contains("hook forward failed")); } +#[test] +fn cli_hook_forward_bounds_responses_under_both_failure_policies() { + const MAX_HOOK_RESPONSE_BYTES: usize = 1024 * 1024; + for fail_closed in [false, true] { + let (server_url, received) = + spawn_single_request_server(200, "x".repeat(MAX_HOOK_RESPONSE_BYTES + 1)); + let mut command = Command::new(gateway_bin()); + command + .args(["hook-forward", "hermes"]) + .env("NEMO_RELAY_GATEWAY_URL", &server_url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if fail_closed { + command.arg("--fail-closed"); + } + let mut child = command.spawn().unwrap(); + child.stdin.take().unwrap().write_all(b"{}").unwrap(); + let output = child.wait_with_output().unwrap(); + + assert_eq!(output.status.success(), !fail_closed); + assert!(output.stdout.is_empty()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("hook forward response exceeds the 1048576-byte limit") + ); + assert!(received.recv_timeout(Duration::from_secs(2)).is_ok()); + } +} + fn spawn_single_request_server( status: u16, - body: &'static str, + body: impl Into, ) -> (String, mpsc::Receiver) { + let body = body.into(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let (sender, receiver) = mpsc::channel(); diff --git a/crates/cli/tests/coverage/coding_agent_tests.rs b/crates/cli/tests/coverage/coding_agent_tests.rs new file mode 100644 index 000000000..c785ceed9 --- /dev/null +++ b/crates/cli/tests/coverage/coding_agent_tests.rs @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +#[test] +fn agent_descriptors_are_complete_and_unique() { + let arguments = CodingAgent::ALL.map(CodingAgent::as_arg); + let executables = CodingAgent::ALL.map(CodingAgent::executable); + let hook_paths = CodingAgent::ALL.map(CodingAgent::hook_path); + + assert_eq!(arguments, ["claude", "codex", "hermes"]); + assert_eq!(executables, ["claude", "codex", "hermes"]); + assert_eq!( + hook_paths, + ["/hooks/claude-code", "/hooks/codex", "/hooks/hermes"] + ); + assert_eq!(CodingAgent::ClaudeCode.label(), "Claude Code"); + assert_eq!(CodingAgent::Codex.label(), "Codex"); + assert_eq!(CodingAgent::Hermes.label(), "Hermes Agent"); +} + +#[test] +fn centralized_minimum_versions_accept_stable_boundaries() { + let cases = [ + (CodingAgent::ClaudeCode, "2.1.121 (Claude Code)"), + (CodingAgent::Codex, "codex-cli 0.143.0"), + (CodingAgent::Hermes, "Hermes Agent v0.18.2 (2026.7.7.2)"), + ]; + + for (agent, output) in cases { + assert_eq!( + agent.validate_version_output(output).unwrap(), + agent.minimum_version() + ); + } +} + +#[test] +fn centralized_minimum_versions_reject_old_prerelease_and_malformed_output() { + let cases = [ + (CodingAgent::ClaudeCode, "2.1.120 (Claude Code)"), + (CodingAgent::ClaudeCode, "2.1.121-beta.1 (Claude Code)"), + (CodingAgent::Codex, "codex-cli 0.142.9"), + (CodingAgent::Codex, "codex-cli 0.143.0-alpha.1"), + (CodingAgent::Hermes, "Hermes Agent v0.18.1"), + (CodingAgent::Hermes, "Hermes Agent v0.18.2-rc.1"), + ]; + + for (agent, output) in cases { + assert!( + agent.validate_version_output(output).is_err(), + "{agent:?}: {output}" + ); + } + for agent in CodingAgent::ALL { + assert!(agent.validate_version_output("unknown version").is_err()); + assert!(agent.validate_version_output("").is_err()); + } +} + +#[test] +fn agent_inference_accepts_supported_binary_aliases() { + assert_eq!( + CodingAgent::infer("/opt/bin/claude"), + Some(CodingAgent::ClaudeCode) + ); + assert_eq!( + CodingAgent::infer("claude-code"), + Some(CodingAgent::ClaudeCode) + ); + assert_eq!(CodingAgent::infer("codex"), Some(CodingAgent::Codex)); + assert_eq!( + CodingAgent::infer("hermes-agent"), + Some(CodingAgent::Hermes) + ); + assert_eq!(CodingAgent::infer("unknown"), None); +} diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index a93cab3c5..a3d2e6adb 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -623,7 +623,7 @@ fn agent_helper_statuses_cover_configured_target_and_hook_paths() { assert_eq!(command_executable("codex --full-auto"), "codex"); assert_eq!(command_executable(""), ""); assert_eq!( - agent_command(CodingAgent::ClaudeCode, &AgentConfigs::default(), "claude"), + agent_command(CodingAgent::ClaudeCode, &AgentConfigs::default()), "claude" ); assert_eq!( @@ -724,7 +724,7 @@ fn collect_environment_and_completions_cover_missing_home_and_unknown_shell() { async fn collect_agents_filters_target_and_records_version() { let temp = tempfile::tempdir().unwrap(); let codex = temp.path().join("codex"); - std::fs::write(&codex, "#!/bin/sh\nprintf 'codex 1.2.3\\n'\n").unwrap(); + std::fs::write(&codex, "#!/bin/sh\nprintf 'codex-cli 0.143.0\\n'\n").unwrap(); make_executable(&codex); let mut resolved = ResolvedConfig::default(); @@ -735,7 +735,7 @@ async fn collect_agents_filters_target_and_records_version() { assert_eq!(agents[0].name, "codex"); assert_eq!(agents[0].status, Status::Pass); assert_eq!(agents[0].path.as_deref(), Some(codex.as_path())); - assert_eq!(agents[0].version.as_deref(), Some("codex 1.2.3")); + assert_eq!(agents[0].version.as_deref(), Some("codex-cli 0.143.0")); } #[cfg(unix)] @@ -1625,26 +1625,3 @@ fn format_agents_json_matches_doctor_agents_shape() { assert_eq!(parsed[0]["version"], "2.1.4"); assert_eq!(parsed[0]["path"], "/opt/homebrew/bin/claude"); } - -#[test] -fn claude_hook_floor_warning_pins_version_boundary() { - // 2.1.116 is the first Claude Code whose plugin hook whitelist accepts UserPromptExpansion; - // 2.1.114 is the newest published version that rejects it (2.1.115 was never published). - let cases = [ - ("2.1.114 (Claude Code)", true), - ("2.1.116 (Claude Code)", false), - ("2.1.206 (Claude Code)", false), - ("2.0.999 (Claude Code)", true), - ("3.0.0 (Claude Code)", false), - ("2.1.116-beta (Claude Code)", false), - ("not a version", false), - ("", false), - ]; - for (version, expect_warning) in cases { - assert_eq!( - claude_hook_floor_warning(version).is_some(), - expect_warning, - "unexpected floor verdict for {version:?}" - ); - } -} diff --git a/crates/cli/tests/coverage/hermes_tests.rs b/crates/cli/tests/coverage/hermes_tests.rs index c4760a846..106264c37 100644 --- a/crates/cli/tests/coverage/hermes_tests.rs +++ b/crates/cli/tests/coverage/hermes_tests.rs @@ -76,14 +76,14 @@ fn hook_command_round_trips_paths_and_recognizes_owned_legacy_spellings() { let relay = Path::new("/tmp/NeMo $Relay`test'/bin/nemo-relay"); assert_eq!( persistent_hook_command_for_platform(relay, false), - "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' plugin-shim hook hermes" + "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward hermes --gateway-url http://127.0.0.1:47632" ); assert_eq!( persistent_hook_command_for_platform( Path::new(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"), true, ), - r#""C:\Program Files\NeMo 100%%\bin\nemo-relay.exe" plugin-shim hook hermes"# + r#""C:\Program Files\NeMo 100%%\bin\nemo-relay.exe" hook-forward hermes --gateway-url http://127.0.0.1:47632"# ); for command in [ "nemo-relay hook-forward hermes", diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs index eabc1db49..51874ceb7 100644 --- a/crates/cli/tests/coverage/installer_tests.rs +++ b/crates/cli/tests/coverage/installer_tests.rs @@ -3,6 +3,41 @@ use super::*; +#[test] +fn hook_payload_reader_normalizes_blank_input_and_accepts_the_exact_limit() { + assert_eq!(read_hook_payload_from(" \n\t".as_bytes(), 3).unwrap(), "{}"); + assert_eq!( + read_hook_payload_from("1234".as_bytes(), 4).unwrap(), + "1234" + ); +} + +#[test] +fn hook_payload_reader_rejects_oversized_invalid_and_unreadable_input() { + let oversized = read_hook_payload_from("12345".as_bytes(), 4) + .unwrap_err() + .to_string(); + assert!(oversized.contains("exceeds the 4-byte limit")); + + let invalid = read_hook_payload_from([0xff].as_slice(), 1) + .unwrap_err() + .to_string(); + assert!(invalid.contains("not valid UTF-8")); + + struct FailingReader; + impl std::io::Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("synthetic hook input failure")) + } + } + assert!( + read_hook_payload_from(FailingReader, 4) + .unwrap_err() + .to_string() + .contains("synthetic hook input failure") + ); +} + #[test] fn hermes_config_merge_preserves_existing_yaml() { let existing = r#" @@ -41,29 +76,26 @@ fn hermes_config_merge_rejects_invalid_yaml() { #[test] fn hermes_hook_forward_prefers_dynamic_env_url() { - assert_eq!( - resolve_hook_gateway_url( - CodingAgent::Hermes, - Some("http://installed".into()), - Some("http://dynamic".into()), - ) - .as_deref(), - Some("http://dynamic") - ); - assert_eq!( - resolve_hook_gateway_url(CodingAgent::Hermes, Some("http://installed".into()), None,) - .as_deref(), - Some("http://installed") + let destination = resolve_hook_destination( + CodingAgent::Hermes, + Some("http://installed".into()), + Some("http://dynamic".into()), ); - assert_eq!( - resolve_hook_gateway_url( - CodingAgent::Codex, - Some("http://installed".into()), - Some("http://dynamic".into()), - ) - .as_deref(), - Some("http://installed") + assert_eq!(destination.gateway_url, "http://dynamic"); + assert!(!destination.recover); + + let destination = + resolve_hook_destination(CodingAgent::Hermes, Some("http://installed".into()), None); + assert_eq!(destination.gateway_url, "http://installed"); + assert!(destination.recover); + + let destination = resolve_hook_destination( + CodingAgent::Codex, + Some("http://installed".into()), + Some("http://dynamic".into()), ); + assert_eq!(destination.gateway_url, "http://installed"); + assert!(destination.recover); } #[test] @@ -195,7 +227,7 @@ fn packaged_hook_configs_are_valid_json() { } #[test] -fn packaged_plugin_hooks_use_expected_shim_commands() { +fn packaged_plugin_hooks_use_expected_forwarding_commands() { let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../integrations/coding-agents"); let claude = serde_json::from_str::( @@ -218,15 +250,27 @@ fn packaged_plugin_hooks_use_expected_shim_commands() { assert_eq!( claude["hooks"]["SessionStart"][0]["hooks"][0]["command"], - json!("nemo-relay plugin-shim hook claude") + json!(format!( + "nemo-relay hook-forward claude --gateway-url {}", + crate::sidecar::DEFAULT_URL + )) ); assert_eq!( codex["hooks"]["SessionStart"][0]["hooks"][0]["command"], - json!("nemo-relay plugin-shim hook codex") + json!(format!( + "nemo-relay hook-forward codex --gateway-url {}", + crate::sidecar::DEFAULT_URL + )) ); assert_eq!( codex["hooks"], - generated_hooks(CodingAgent::Codex, "nemo-relay plugin-shim hook codex")["hooks"] + generated_hooks( + CodingAgent::Codex, + &format!( + "nemo-relay hook-forward codex --gateway-url {}", + crate::sidecar::DEFAULT_URL + ), + )["hooks"] ); assert!( claude["hooks"] diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index 8a3a334e0..8cbeb424c 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -615,7 +615,7 @@ fn prepares_hermes_hook_environment() { Some(&hooks_path) ); let hooks = std::fs::read_to_string(&hooks_path).unwrap(); - assert!(hooks.contains("plugin-shim hook hermes")); + assert!(hooks.contains("hook-forward hermes")); assert!(prepared.notes[0].contains("temporarily merged")); prepared.restore().unwrap(); @@ -732,7 +732,7 @@ hooks: .unwrap(); let patched = std::fs::read_to_string(&hooks_path).unwrap(); - assert!(patched.contains("plugin-shim hook hermes")); + assert!(patched.contains("hook-forward hermes")); let patched_yaml: serde_json::Value = serde_yaml::from_str(&patched).unwrap(); assert!(patched_yaml["mcp_servers"].get("nemo-relay").is_none()); assert_eq!( @@ -913,7 +913,7 @@ fn fake_agent_command(temp: &Path, output: &Path) -> Vec { std::fs::write( &script, format!( - "#!/bin/sh\nprintf '%s' \"$NEMO_RELAY_GATEWAY_URL\" > \"{}\"\nexit 7\n", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'codex-cli 0.143.0'\n exit 0\nfi\nprintf '%s' \"$NEMO_RELAY_GATEWAY_URL\" > \"{}\"\nexit 7\n", output.display() ), ) @@ -1086,7 +1086,7 @@ async fn execute_live_run_restores_hermes_hooks_when_health_check_fails() { assert!( std::fs::read_to_string(&hooks_path) .unwrap() - .contains("plugin-shim hook hermes") + .contains("hook-forward hermes") ); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/crates/cli/tests/coverage/main_tests.rs b/crates/cli/tests/coverage/main_tests.rs index 1b545f3f8..300308451 100644 --- a/crates/cli/tests/coverage/main_tests.rs +++ b/crates/cli/tests/coverage/main_tests.rs @@ -6,7 +6,7 @@ use std::ffi::OsString; use super::*; use crate::config::{ - CompletionsCommand, McpAgent, PluginsCommand, PluginsEditCommand, PluginsInspectCommand, + CompletionsCommand, PluginsCommand, PluginsEditCommand, PluginsInspectCommand, PluginsListCommand, PluginsSubcommand, PluginsValidateCommand, PricingSubcommand, PricingValidateCommand, ServerArgs, }; @@ -88,7 +88,7 @@ fn cli_parses_native_mcp_subcommand_and_bind_override() { let cli = Cli::try_parse_from(["nemo-relay", "mcp"]).unwrap(); assert!(matches!( cli.command, - Some(Command::Mcp(command)) if command.agent == McpAgent::Codex + Some(Command::Mcp(command)) if command.agent == CodingAgent::Codex )); assert!(cli.server.bind.is_none()); @@ -99,13 +99,13 @@ fn cli_parses_native_mcp_subcommand_and_bind_override() { let cli = Cli::try_parse_from(["nemo-relay", "mcp", "--agent", "claude"]).unwrap(); assert!(matches!( cli.command, - Some(Command::Mcp(command)) if command.agent == McpAgent::ClaudeCode + Some(Command::Mcp(command)) if command.agent == CodingAgent::ClaudeCode )); let cli = Cli::try_parse_from(["nemo-relay", "mcp", "--agent", "hermes"]).unwrap(); assert!(matches!( cli.command, - Some(Command::Mcp(command)) if command.agent == McpAgent::Hermes + Some(Command::Mcp(command)) if command.agent == CodingAgent::Hermes )); } @@ -235,20 +235,6 @@ fn safe_dispatch_plugin_json_errors_return_exit_codes() { #[tokio::test] async fn run_command_dispatches_safe_plugin_and_install_paths() { - let cli = Cli::try_parse_from(["nemo-relay", "plugin-shim", "install", "claude"]).unwrap(); - let error = run_command(cli.command.unwrap(), &cli.server) - .await - .unwrap_err() - .to_string(); - assert!(error.contains("plugin install supports codex and hermes")); - - let cli = Cli::try_parse_from(["nemo-relay", "plugin-shim", "uninstall", "claude"]).unwrap(); - let error = run_command(cli.command.unwrap(), &cli.server) - .await - .unwrap_err() - .to_string(); - assert!(error.contains("plugin uninstall supports codex and hermes")); - let dir = tempfile::tempdir().unwrap(); let install_dir = dir.path().join("plugin-install"); let install_dir_arg = install_dir.to_string_lossy().to_string(); diff --git a/crates/cli/tests/coverage/mcp_tests.rs b/crates/cli/tests/coverage/mcp_tests.rs index 9cf0fa50b..cf7f8631b 100644 --- a/crates/cli/tests/coverage/mcp_tests.rs +++ b/crates/cli/tests/coverage/mcp_tests.rs @@ -140,36 +140,11 @@ fn invalid_and_unknown_requests_return_jsonrpc_errors() { } #[test] -fn gateway_bootstrap_is_deferred_until_initialize() { - assert_eq!(default_mcp_bind(), "127.0.0.1:47632".parse().unwrap()); - assert!(!request_requires_gateway("not-json\n")); - assert!(!request_requires_gateway( - r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"# - )); - assert!(!request_requires_gateway( - r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"# - )); - assert!(!request_requires_gateway( - r#"{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-06-18"}}"# - )); - assert!(!request_requires_gateway( - r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"# - )); - assert!(!request_requires_gateway( - r#"{"jsonrpc":"1.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"# - )); - assert!(request_requires_gateway( - r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"# - )); -} - -#[test] -fn invalid_jsonrpc_never_bootstraps_and_returns_invalid_request() { +fn invalid_jsonrpc_returns_invalid_request() { let action = crate::mcp::protocol::evaluate_frame( r#"{"jsonrpc":"1.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#, ); - assert!(!action.requires_gateway); assert_eq!( action.response, Some(jsonrpc_error(json!(1), -32600, "Invalid Request")) diff --git a/crates/cli/tests/coverage/plugin_shim_tests.rs b/crates/cli/tests/coverage/plugin_host_tests.rs similarity index 81% rename from crates/cli/tests/coverage/plugin_shim_tests.rs rename to crates/cli/tests/coverage/plugin_host_tests.rs index 964f710db..934fe6290 100644 --- a/crates/cli/tests/coverage/plugin_shim_tests.rs +++ b/crates/cli/tests/coverage/plugin_host_tests.rs @@ -425,337 +425,6 @@ impl Drop for EnvVarGuard { } } -#[test] -fn hook_with_io_defaults_blank_payload_and_writes_non_empty_response() { - let mut input = std::io::Cursor::new(b" \n\t".to_vec()); - let mut output = Vec::new(); - let ensured = std::cell::RefCell::new(Vec::new()); - let seen_payload = std::cell::RefCell::new(Vec::new()); - - let status = hook_with_io( - HookInvocation { - agent: CodingAgent::Codex, - gateway_url: Some("http://127.0.0.1:59999"), - max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, - preflight_gateway: false, - }, - &mut input, - &mut output, - |agent, url| { - ensured - .borrow_mut() - .push((agent.as_arg().to_string(), url.to_string())); - Ok(()) - }, - |agent, url, payload| { - assert_eq!(agent, CodingAgent::Codex); - assert_eq!(url, "http://127.0.0.1:59999"); - seen_payload.borrow_mut().extend_from_slice(payload); - Ok(br#"{"decision":"allow"}"#.to_vec()) - }, - || false, - ) - .unwrap(); - - assert_eq!(status, ExitCode::SUCCESS); - assert_eq!(&*seen_payload.borrow(), b"{}"); - assert_eq!(output, br#"{"decision":"allow"}"#); - assert!(ensured.into_inner().is_empty()); -} - -#[test] -fn hook_payload_reader_rejects_bytes_beyond_its_limit() { - let mut input = std::io::Cursor::new(b"12345".to_vec()); - let mut payload = Vec::new(); - - let error = read_hook_payload(&mut input, &mut payload, 4).unwrap_err(); - - assert!(error.contains("exceeds the 4-byte limit")); - assert_eq!(payload, b"12345"); -} - -#[test] -fn hook_with_io_applies_fail_open_and_fail_closed_to_oversized_stdin() { - for fail_closed in [false, true] { - let mut input = std::io::Cursor::new(b"12345".to_vec()); - let mut output = Vec::new(); - let result = hook_with_io( - HookInvocation { - agent: CodingAgent::Codex, - gateway_url: Some(DEFAULT_URL), - max_payload_bytes: 4, - preflight_gateway: true, - }, - &mut input, - &mut output, - |_agent, _url| panic!("oversized input must not bootstrap the gateway"), - |_agent, _url, _payload| panic!("oversized input must not be forwarded"), - || fail_closed, - ); - - if fail_closed { - assert!(result.unwrap_err().contains("exceeds the 4-byte limit")); - } else { - assert_eq!(result.unwrap(), ExitCode::SUCCESS); - } - assert!(output.is_empty()); - } -} - -#[test] -fn hook_with_io_applies_fail_open_and_fail_closed_to_stdin_read_errors() { - struct FailingReader; - - impl Read for FailingReader { - fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { - Err(std::io::Error::other("synthetic stdin failure")) - } - } - - for fail_closed in [false, true] { - let mut input = FailingReader; - let mut output = Vec::new(); - let result = hook_with_io( - HookInvocation { - agent: CodingAgent::Codex, - gateway_url: Some(DEFAULT_URL), - max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, - preflight_gateway: true, - }, - &mut input, - &mut output, - |_agent, _url| panic!("unreadable input must not bootstrap the gateway"), - |_agent, _url, _payload| panic!("unreadable input must not be forwarded"), - || fail_closed, - ); - - if fail_closed { - let error = result.unwrap_err(); - assert!(error.contains("failed to read hook payload")); - assert!(error.contains("synthetic stdin failure")); - } else { - assert_eq!(result.unwrap(), ExitCode::SUCCESS); - } - assert!(output.is_empty()); - } -} - -#[test] -fn hook_with_io_bootstraps_and_retries_once_only_for_pre_send_connection_failure() { - let mut input = std::io::Cursor::new(br#"{"event":"prompt"}"#.to_vec()); - let mut output = Vec::new(); - let ensures = std::cell::Cell::new(0); - let posts = std::cell::Cell::new(0); - - let status = hook_with_io( - HookInvocation { - agent: CodingAgent::Codex, - gateway_url: Some("http://127.0.0.1:59997"), - max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, - preflight_gateway: false, - }, - &mut input, - &mut output, - |_agent, _url| { - ensures.set(ensures.get() + 1); - Ok(()) - }, - |_agent, _url, _payload| { - posts.set(posts.get() + 1); - if posts.get() == 1 { - Err(HookForwardError::retryable( - "connection refused before send", - )) - } else { - Ok(b"retried".to_vec()) - } - }, - || true, - ) - .unwrap(); - - assert_eq!(status, ExitCode::SUCCESS); - assert_eq!(output, b"retried"); - assert_eq!(ensures.get(), 1); - assert_eq!(posts.get(), 2); -} - -#[test] -fn hook_with_io_does_not_bootstrap_or_retry_ambiguous_forward_failure() { - let mut input = std::io::Cursor::new(b"{}".to_vec()); - let mut output = Vec::new(); - let ensures = std::cell::Cell::new(0); - let posts = std::cell::Cell::new(0); - - let error = hook_with_io( - HookInvocation { - agent: CodingAgent::Codex, - gateway_url: Some(DEFAULT_URL), - max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, - preflight_gateway: false, - }, - &mut input, - &mut output, - |_agent, _url| { - ensures.set(ensures.get() + 1); - Ok(()) - }, - |_agent, _url, _payload| { - posts.set(posts.get() + 1); - Err(HookForwardError::not_retryable("response read failed")) - }, - || true, - ) - .unwrap_err(); - - assert!(error.contains("response read failed")); - assert_eq!(ensures.get(), 0); - assert_eq!(posts.get(), 1); -} - -#[test] -fn hook_with_io_propagates_bootstrap_error_without_second_post() { - let mut input = std::io::Cursor::new(b"{}".to_vec()); - let mut output = Vec::new(); - let posts = std::cell::Cell::new(0); - - let error = hook_with_io( - HookInvocation { - agent: CodingAgent::Codex, - gateway_url: Some(DEFAULT_URL), - max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, - preflight_gateway: false, - }, - &mut input, - &mut output, - |_agent, _url| Err("startup log says bind failed".into()), - |_agent, _url, _payload| { - posts.set(posts.get() + 1); - Err(HookForwardError::retryable( - "connection refused before send", - )) - }, - || true, - ) - .unwrap_err(); - - assert!(error.contains("sidecar bootstrap failed")); - assert!(error.contains("startup log says bind failed")); - assert_eq!(posts.get(), 1); -} - -#[test] -fn hook_with_io_applies_fail_open_and_fail_closed_forwarding_policies() { - let mut input = std::io::Cursor::new(br#"{"event":"tool"}"#.to_vec()); - let mut output = Vec::new(); - let status = hook_with_io( - HookInvocation { - agent: CodingAgent::ClaudeCode, - gateway_url: Some("http://127.0.0.1:59998"), - max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, - preflight_gateway: false, - }, - &mut input, - &mut output, - |_agent, _url| Ok(()), - |_agent, _url, _payload| Err(HookForwardError::not_retryable("forward failed open")), - || false, - ) - .unwrap(); - - assert_eq!(status, ExitCode::SUCCESS); - assert!(output.is_empty()); - - let mut input = std::io::Cursor::new(br#"{"event":"tool"}"#.to_vec()); - let mut output = Vec::new(); - let error = hook_with_io( - HookInvocation { - agent: CodingAgent::ClaudeCode, - gateway_url: Some("http://127.0.0.1:59998"), - max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, - preflight_gateway: false, - }, - &mut input, - &mut output, - |_agent, _url| Ok(()), - |_agent, _url, _payload| Err(HookForwardError::not_retryable("forward failed closed")), - || true, - ) - .unwrap_err(); - - assert!(error.contains("forward failed closed")); - assert!(output.is_empty()); -} - -#[test] -fn codex_hook_preflight_rejects_healthy_wrong_fingerprint_under_both_policies() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let _config = EnvVarGuard::set_path("XDG_CONFIG_HOME", &dir.path().join("config")); - let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &dir.path().join("runtime")); - - for fail_closed in [false, true] { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap(); - let server = thread::spawn(move || { - for _ in 0..2 { - let (mut stream, _) = listener.accept().unwrap(); - let request = read_http_request(&mut stream); - let request = String::from_utf8_lossy(&request); - assert!(request.starts_with("GET /healthz")); - assert!( - request.contains("X-NeMo-Relay-Bootstrap-Fingerprint: expected-fingerprint") - ); - let body = format!( - r#"{{"status":"incompatible","service":"nemo-relay","version":"{}","bootstrap_protocol":1}}"#, - env!("CARGO_PKG_VERSION") - ); - stream - .write_all( - format!( - "HTTP/1.1 409 Conflict\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ) - .as_bytes(), - ) - .unwrap(); - } - }); - let url = format!("http://{address}"); - let mut input = std::io::Cursor::new(b"{}".to_vec()); - let mut output = Vec::new(); - let result = hook_with_io( - HookInvocation { - agent: CodingAgent::Codex, - gateway_url: Some(&url), - max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, - preflight_gateway: true, - }, - &mut input, - &mut output, - |_agent, _url| { - GatewaySpec::new(CodingAgent::Codex, address) - .with_fingerprint("expected-fingerprint") - .ensure() - .map(|_| ()) - }, - |_agent, _url, _payload| panic!("wrong-fingerprint gateway must not receive the hook"), - || fail_closed, - ); - if fail_closed { - assert!( - result - .unwrap_err() - .contains("gateway identity preflight failed") - ); - } else { - assert_eq!(result.unwrap(), ExitCode::SUCCESS); - } - assert!(output.is_empty()); - server.join().unwrap(); - } -} - #[test] fn backup_preserves_first_snapshot() { let dir = tempdir().unwrap(); @@ -1579,13 +1248,7 @@ fn codex_doctor_requires_app_server_reported_trust_but_allows_stopped_sidecar() let (_path, _hooks, _log) = fake_codex_app_server(dir.path(), &trusted); let _plugin_root = EnvVarGuard::set_path("PLUGIN_ROOT", &plugin_root); - let status = doctor(PluginShimDoctorCommand { - agent: CodingAgent::Codex, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap(); - - assert_eq!(status, std::process::ExitCode::SUCCESS); + doctor_plugin(CodingAgent::Codex, DEFAULT_URL, &plugin_root).unwrap(); let report = doctor_plugin_json(CodingAgent::Codex, DEFAULT_URL, &plugin_root).unwrap(); assert_eq!(report["checks"]["codex_hooks_trusted"], json!(true)); assert_eq!( @@ -1629,35 +1292,10 @@ supports_websockets = false } #[test] -fn plugin_shim_helpers_reject_unsupported_agents_and_report_lazy_claude_status() { +fn plugin_host_doctor_rejects_unsupported_agents_and_reports_lazy_claude_status() { let dir = tempdir().unwrap(); let _home = HomeScope::enter(dir.path()); - assert!( - install(PluginShimInstallCommand { - agent: CodingAgent::ClaudeCode, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap_err() - .contains("supports codex") - ); - assert!( - uninstall(PluginShimUninstallCommand { - agent: CodingAgent::ClaudeCode, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap_err() - .contains("supports codex") - ); - assert!( - provider(PluginShimProviderCommand { - agent: CodingAgent::Codex, - action: PluginShimProviderAction::Status, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap_err() - .contains("supports claude") - ); assert!( doctor_plugin(CodingAgent::Hermes, DEFAULT_URL, dir.path()) .unwrap_err() @@ -1671,7 +1309,7 @@ fn plugin_shim_helpers_reject_unsupported_agents_and_report_lazy_claude_status() let report = doctor_plugin_json(CodingAgent::ClaudeCode, DEFAULT_URL, dir.path()).unwrap(); assert_eq!(report["ok"], json!(false)); - assert_eq!(report["sidecar_health"], json!("not_running_lazy_start")); + assert_eq!(report["sidecar_health"], json!("not_running_mcp_start")); assert_eq!(report["checks"]["claude_provider_routing"], json!(false)); } @@ -2265,7 +1903,7 @@ fn claude_restore_without_backup_preserves_matching_user_relay_url() { ) .unwrap(); - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + restore_claude_provider(DEFAULT_URL).unwrap(); let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); assert_eq!( @@ -2294,7 +1932,7 @@ fn claude_enable_rolls_back_backup_when_settings_write_fails() { .unwrap(); fs::create_dir(settings.with_extension("json.tmp")).unwrap(); - let error = claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap_err(); + let error = enable_claude_provider(DEFAULT_URL).unwrap_err(); assert!(error.contains("failed to write")); assert!(!backup_path(&settings).exists()); @@ -2315,7 +1953,7 @@ fn claude_enable_does_not_back_up_when_env_shape_is_invalid() { ) .unwrap(); - let error = claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap_err(); + let error = enable_claude_provider(DEFAULT_URL).unwrap_err(); assert!(error.contains("non-object env field")); assert!(!backup_path(&settings).exists()); @@ -2340,7 +1978,7 @@ fn claude_restore_with_backup_preserves_user_settings_added_after_install() { .unwrap(), ) .unwrap(); - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); + enable_claude_provider(DEFAULT_URL).unwrap(); fs::write( &settings, serde_json::to_vec_pretty(&json!({ @@ -2355,7 +1993,7 @@ fn claude_restore_with_backup_preserves_user_settings_added_after_install() { ) .unwrap(); - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + restore_claude_provider(DEFAULT_URL).unwrap(); let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); assert_eq!( @@ -2384,7 +2022,7 @@ fn claude_restore_with_backup_preserves_user_changed_provider_url() { .unwrap(), ) .unwrap(); - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); + enable_claude_provider(DEFAULT_URL).unwrap(); fs::write( &settings, serde_json::to_vec_pretty(&json!({ @@ -2396,7 +2034,7 @@ fn claude_restore_with_backup_preserves_user_changed_provider_url() { ) .unwrap(); - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + restore_claude_provider(DEFAULT_URL).unwrap(); let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); assert_eq!( @@ -2423,7 +2061,7 @@ fn claude_reinstall_refreshes_backup_after_user_owned_restore() { ) .unwrap(); - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); + enable_claude_provider(DEFAULT_URL).unwrap(); fs::write( &settings, serde_json::to_vec_pretty(&json!({ @@ -2434,10 +2072,10 @@ fn claude_reinstall_refreshes_backup_after_user_owned_restore() { .unwrap(), ) .unwrap(); - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + restore_claude_provider(DEFAULT_URL).unwrap(); assert!(backup_path(&settings).exists()); - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); + enable_claude_provider(DEFAULT_URL).unwrap(); let refreshed_backup: Value = serde_json::from_str(&fs::read_to_string(backup_path(&settings)).unwrap()).unwrap(); assert_eq!( @@ -2445,7 +2083,7 @@ fn claude_reinstall_refreshes_backup_after_user_owned_restore() { Some("https://custom.example") ); - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + restore_claude_provider(DEFAULT_URL).unwrap(); let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); assert_eq!( @@ -2472,8 +2110,8 @@ fn claude_reinstall_uses_fresh_backup_after_prior_restore() { ) .unwrap(); - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + enable_claude_provider(DEFAULT_URL).unwrap(); + restore_claude_provider(DEFAULT_URL).unwrap(); assert!(!backup_path(&settings).exists()); fs::write( @@ -2487,8 +2125,8 @@ fn claude_reinstall_uses_fresh_backup_after_prior_restore() { ) .unwrap(); - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + enable_claude_provider(DEFAULT_URL).unwrap(); + restore_claude_provider(DEFAULT_URL).unwrap(); let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); assert_eq!( @@ -2873,11 +2511,11 @@ fn codex_hook_command_uses_cmd_quoting_for_windows_paths() { assert_eq!( command, - r#""C:\Program Files\NeMo 100%%\bin\nemo-relay.exe" plugin-shim hook codex --gateway-url http://127.0.0.1:47632"# + r#""C:\Program Files\NeMo 100%%\bin\nemo-relay.exe" hook-forward codex --gateway-url http://127.0.0.1:47632"# ); assert_eq!( codex_plugin_hook_command_for_platform(&relay, true), - r#""C:\Program Files\NeMo 100%%\bin\nemo-relay.exe" plugin-shim hook codex --gateway-url http://127.0.0.1:47632"# + r#""C:\Program Files\NeMo 100%%\bin\nemo-relay.exe" hook-forward codex --gateway-url http://127.0.0.1:47632"# ); assert_eq!( shell_quote_arg_for_platform("foo&bar", true), @@ -2909,11 +2547,11 @@ fn codex_hook_command_uses_posix_single_quote_escaping() { assert_eq!( command, - "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' plugin-shim hook codex --gateway-url http://127.0.0.1:47632" + "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward codex --gateway-url http://127.0.0.1:47632" ); assert_eq!( codex_plugin_hook_command_for_platform(&relay, false), - "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' plugin-shim hook codex --gateway-url http://127.0.0.1:47632" + "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward codex --gateway-url http://127.0.0.1:47632" ); assert_eq!(shell_quote_arg_for_platform("", false), "''"); assert_eq!( @@ -2922,128 +2560,6 @@ fn codex_hook_command_uses_posix_single_quote_escaping() { ); } -#[test] -fn hook_forward_connect_attempt_is_bounded() { - let error = post_hook(CodingAgent::Codex, "http://127.0.0.1:9", b"{}").unwrap_err(); - - assert!(error.to_string().contains("hook forward failed")); -} - -#[test] -fn hook_forward_posts_to_local_sidecar_and_healthz_verifies_relay_identity() { - let hook_listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let hook_port = hook_listener.local_addr().unwrap().port(); - let hook_thread = thread::spawn(move || { - let (mut stream, _) = hook_listener.accept().unwrap(); - let request = read_http_request(&mut stream); - let raw = String::from_utf8_lossy(&request); - assert!(raw.starts_with("POST /hooks/codex HTTP/1.1")); - assert!(raw.contains("Content-Length: 7")); - stream - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") - .unwrap(); - }); - - let body = post_hook( - CodingAgent::Codex, - &format!("http://127.0.0.1:{hook_port}"), - br#"{"x":1}"#, - ) - .unwrap(); - assert_eq!(body, b"ok"); - hook_thread.join().unwrap(); - - let health_listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let health_port = health_listener.local_addr().unwrap().port(); - let health_thread = thread::spawn(move || { - let (mut stream, _) = health_listener.accept().unwrap(); - let request = read_http_request(&mut stream); - assert!(String::from_utf8_lossy(&request).starts_with("GET /healthz")); - let body = format!( - r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":1}}"#, - env!("CARGO_PKG_VERSION") - ); - stream - .write_all( - format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() - ) - .as_bytes(), - ) - .unwrap(); - stream.write_all(body.as_bytes()).unwrap(); - }); - assert!(healthz(&format!("http://127.0.0.1:{health_port}"))); - health_thread.join().unwrap(); -} - -#[test] -fn hermes_hook_forward_posts_the_canonical_payload_to_the_hermes_endpoint() { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = listener.local_addr().unwrap().port(); - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let request = read_http_request(&mut stream); - let raw = String::from_utf8_lossy(&request); - assert!(raw.starts_with("POST /hooks/hermes HTTP/1.1")); - assert!(raw.ends_with(r#"{"hook_event_name":"on_session_start"}"#)); - stream - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}") - .unwrap(); - }); - - let body = post_hook( - CodingAgent::Hermes, - &format!("http://127.0.0.1:{port}"), - br#"{"hook_event_name":"on_session_start"}"#, - ) - .unwrap(); - - assert_eq!(body, b"{}"); - server.join().unwrap(); -} - -#[test] -fn hook_forward_rejects_an_oversized_http_response() { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = listener.local_addr().unwrap().port(); - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let _ = read_http_request(&mut stream); - let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"); - let _ = stream.write_all(&vec![b'x'; MAX_HOOK_RESPONSE_BYTES + 1]); - }); - - let error = post_hook( - CodingAgent::Codex, - &format!("http://127.0.0.1:{port}"), - b"{}", - ) - .unwrap_err(); - - assert!(error.to_string().contains("response exceeds")); - server.join().unwrap(); -} - -#[test] -fn hook_http_response_requires_numeric_2xx_status() { - assert_eq!( - parse_http_response(b"HTTP/1.1 204 No Content\r\n\r\npayload").unwrap(), - b"payload" - ); - assert!( - parse_http_response(b"HTTP/1.1 500 upstream 2 bad\r\n\r\npayload") - .unwrap_err() - .contains("HTTP/1.1 500 upstream 2 bad") - ); - assert!( - parse_http_response(b"HTTP/1.1 OK 2\r\n\r\npayload") - .unwrap_err() - .contains("HTTP/1.1 OK 2") - ); -} - #[test] fn unready_sidecar_child_is_terminated_and_pid_removed() { let dir = tempdir().unwrap(); @@ -3363,7 +2879,7 @@ fn codex_install_hooks_persist_custom_gateway_url() { .as_str() .unwrap(); - assert!(command.contains("plugin-shim hook codex")); + assert!(command.contains("hook-forward codex")); assert!(command.contains("--gateway-url http://127.0.0.1:47633")); } @@ -3774,115 +3290,6 @@ fn shared_filesystem_helpers_cover_tables_snapshots_and_lock_branches() { assert_eq!(fs::read_to_string(&existing).unwrap(), "before"); } -#[test] -fn shared_url_env_and_response_helpers_cover_error_branches() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let _plugin_url = - EnvVarGuard::set_value("NEMO_RELAY_PLUGIN_GATEWAY_URL", "http://127.0.0.1:47640"); - let _claude_url = EnvVarGuard::set_value("NEMO_RELAY_GATEWAY_URL", "http://127.0.0.1:47641"); - let _timeout = EnvVarGuard::set_value("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "7"); - let _fail_closed = EnvVarGuard::set_value("NEMO_RELAY_FAIL_CLOSED", "1"); - - assert_eq!( - gateway_url(CodingAgent::Codex, None), - "http://127.0.0.1:47640" - ); - assert_eq!( - gateway_url(CodingAgent::ClaudeCode, None), - "http://127.0.0.1:47641" - ); - assert_eq!( - gateway_url(CodingAgent::Hermes, None), - "http://127.0.0.1:47641" - ); - assert_eq!( - gateway_url(CodingAgent::Codex, Some("http://127.0.0.1:9")), - "http://127.0.0.1:9" - ); - assert_eq!(plugin_idle_timeout().unwrap(), Duration::from_secs(7)); - assert_eq!( - plugin_heartbeat_interval().unwrap(), - Duration::from_secs(7) / 3 - ); - assert!(fail_closed()); - - assert_eq!( - runtime_dir_for( - Some("/run/user/1000".into()), - Some("/tmp/ignored".into()), - None, - dir.path().join("tmp"), - Some("ignored".into()), - None, - ), - std::path::PathBuf::from("/run/user/1000").join("nemo-relay-plugin") - ); - assert_eq!( - runtime_dir_for( - None, - None, - None, - dir.path().join("tmp"), - Some("user/name".into()), - None, - ), - dir.path() - .join("tmp") - .join("user_name") - .join("nemo-relay-plugin") - ); - assert_eq!( - sidecar_lock_name("http://localhost:47632/hooks"), - "localhost-47632" - ); - assert_eq!(sidecar_lock_name("not a url!*"), "not_a_url__"); - - assert_eq!( - parse_loopback_url("http://localhost:47632/path").unwrap(), - ("localhost".to_string(), 47632) - ); - assert_eq!( - parse_loopback_url("http://[::1]:47632/path").unwrap(), - ("::1".to_string(), 47632) - ); - assert!( - parse_loopback_url("https://127.0.0.1:47632") - .unwrap_err() - .contains("http loopback") - ); - assert!( - parse_loopback_url("http://192.168.1.2:47632") - .unwrap_err() - .contains("loopback") - ); - assert!( - parse_loopback_url("http://127.0.0.1") - .unwrap_err() - .contains("missing port") - ); - assert!( - parse_loopback_url("http://127.0.0.1:nope") - .unwrap_err() - .contains("invalid plugin shim loopback URL") - ); - - assert_eq!( - parse_http_response(b"HTTP/1.1 204 No Content\r\nHeader: value\r\n\r\nbody").unwrap(), - b"body" - ); - assert!( - parse_http_response(b"HTTP/1.1 500 Server Error\r\n\r\nbad") - .unwrap_err() - .contains("HTTP/1.1 500") - ); - assert!( - parse_http_response(b"HTTP/1.1 200 OK\n\nbody") - .unwrap_err() - .contains("malformed") - ); -} - #[test] fn shared_defaults_cover_runtime_username_and_empty_segments() { let dir = tempdir().unwrap(); @@ -3892,14 +3299,11 @@ fn shared_defaults_cover_runtime_username_and_empty_segments() { let _timeout = EnvVarGuard::remove("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS"); let _fail_closed = EnvVarGuard::remove("NEMO_RELAY_FAIL_CLOSED"); - assert_eq!(gateway_url(CodingAgent::Codex, None), DEFAULT_URL); - assert_eq!(gateway_url(CodingAgent::Hermes, None), DEFAULT_URL); assert_eq!(plugin_idle_timeout().unwrap(), Duration::from_secs(300)); assert_eq!( plugin_heartbeat_interval().unwrap(), Duration::from_secs(30) ); - assert!(!fail_closed()); assert_eq!( runtime_dir_for( None, @@ -3959,14 +3363,13 @@ fn claude_provider_enable_status_and_restore_cover_managed_backup_paths() { claude_settings_base_url().as_deref(), Some("https://api.anthropic.com") ); - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); + enable_claude_provider(DEFAULT_URL).unwrap(); assert_eq!(claude_settings_base_url().as_deref(), Some(DEFAULT_URL)); assert_eq!( json_env_string(&read_json_object(&settings_path).unwrap(), "OTHER"), Some("kept") ); - claude_provider(PluginShimProviderAction::Status, DEFAULT_URL).unwrap(); - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + restore_claude_provider(DEFAULT_URL).unwrap(); assert_eq!( claude_settings_base_url().as_deref(), Some("https://api.anthropic.com") @@ -4019,14 +3422,14 @@ fn claude_provider_restore_noops_without_matching_backup_or_managed_value() { ) .unwrap(); - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + restore_claude_provider(DEFAULT_URL).unwrap(); assert_eq!( claude_settings_base_url().as_deref(), Some("https://custom.example") ); backup_claude_settings(&settings_path, false).unwrap(); - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + restore_claude_provider(DEFAULT_URL).unwrap(); assert_eq!( claude_settings_base_url().as_deref(), Some("https://custom.example") @@ -4043,7 +3446,7 @@ fn claude_provider_errors_for_non_object_env_and_restore_env_type_mismatch() { fs::write(&settings_path, r#"{"env": "bad"}"#).unwrap(); assert!( - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL) + enable_claude_provider(DEFAULT_URL) .unwrap_err() .contains("non-object env field") ); @@ -4091,7 +3494,7 @@ fn claude_backup_bootstraps_missing_settings_and_replaces_stale_backup() { } #[test] -fn plugin_shim_entrypoints_reject_unsupported_agents_and_report_json() { +fn plugin_host_entrypoints_reject_unsupported_agents_and_report_json() { let dir = tempdir().unwrap(); let _home = HomeScope::enter(dir.path()); let settings_path = dir.path().join(".claude").join("settings.json"); @@ -4125,7 +3528,7 @@ fn plugin_shim_entrypoints_reject_unsupported_agents_and_report_json() { .unwrap(); let report = doctor_plugin_json(CodingAgent::ClaudeCode, DEFAULT_URL, &plugin_root).unwrap(); - assert_eq!(report["sidecar_health"], json!("not_running_lazy_start")); + assert_eq!(report["sidecar_health"], json!("not_running_mcp_start")); assert_eq!(report["checks"]["claude_provider_routing"], json!(true)); let codex_report = doctor_plugin_json(CodingAgent::Codex, DEFAULT_URL, &plugin_root).unwrap(); assert_eq!( @@ -4149,135 +3552,6 @@ fn plugin_shim_entrypoints_reject_unsupported_agents_and_report_json() { .unwrap_err() .contains("codex plugin doctor checks failed") ); - assert!( - install(PluginShimInstallCommand { - agent: CodingAgent::ClaudeCode, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap_err() - .contains("supports codex") - ); - assert!( - uninstall(PluginShimUninstallCommand { - agent: CodingAgent::ClaudeCode, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap_err() - .contains("supports codex") - ); - assert!( - provider(PluginShimProviderCommand { - agent: CodingAgent::Codex, - action: PluginShimProviderAction::Status, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap_err() - .contains("supports claude") - ); -} - -#[test] -fn plugin_shim_dispatcher_covers_supported_errors_and_serve_failure() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let missing_relay = dir.path().join("missing-nemo-relay"); - let _binary_override = EnvVarGuard::set_path("NEMO_RELAY_PLUGIN_BINARY", &missing_relay); - - let error = run(PluginShimCommand { - command: PluginShimSubcommand::Serve(super::command::PluginShimServeCommand { - args: vec![], - }), - }) - .unwrap_err() - .to_string(); - assert!(error.contains("does not exist")); - - let error = run(PluginShimCommand { - command: PluginShimSubcommand::Install(PluginShimInstallCommand { - agent: CodingAgent::ClaudeCode, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap_err() - .to_string(); - assert!(error.contains("supports codex")); - - let error = run(PluginShimCommand { - command: PluginShimSubcommand::Uninstall(PluginShimUninstallCommand { - agent: CodingAgent::ClaudeCode, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap_err() - .to_string(); - assert!(error.contains("supports codex")); - - let error = run(PluginShimCommand { - command: PluginShimSubcommand::Provider(PluginShimProviderCommand { - agent: CodingAgent::Codex, - action: PluginShimProviderAction::Status, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap_err() - .to_string(); - assert!(error.contains("supports claude")); - - let error = run(PluginShimCommand { - command: PluginShimSubcommand::Doctor(PluginShimDoctorCommand { - agent: CodingAgent::Hermes, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap_err() - .to_string(); - assert!(error.contains("supports claude and codex")); -} - -#[test] -fn plugin_shim_dispatcher_covers_claude_provider_status_and_doctor() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - - run(PluginShimCommand { - command: PluginShimSubcommand::Provider(PluginShimProviderCommand { - agent: CodingAgent::ClaudeCode, - action: PluginShimProviderAction::Enable, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap(); - - assert_eq!( - run(PluginShimCommand { - command: PluginShimSubcommand::Provider(PluginShimProviderCommand { - agent: CodingAgent::ClaudeCode, - action: PluginShimProviderAction::Status, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap(), - std::process::ExitCode::SUCCESS - ); - assert_eq!( - run(PluginShimCommand { - command: PluginShimSubcommand::Doctor(PluginShimDoctorCommand { - agent: CodingAgent::ClaudeCode, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap(), - std::process::ExitCode::SUCCESS - ); - - run(PluginShimCommand { - command: PluginShimSubcommand::Provider(PluginShimProviderCommand { - agent: CodingAgent::ClaudeCode, - action: PluginShimProviderAction::Restore, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap(); } fn event_contains_command(config: &Value, event: &str, command: &str) -> bool { diff --git a/crates/cli/tests/coverage/plugin_install_setup_tests.rs b/crates/cli/tests/coverage/plugin_install_setup_tests.rs index a92be7cce..f9a0148fa 100644 --- a/crates/cli/tests/coverage/plugin_install_setup_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_setup_tests.rs @@ -10,7 +10,7 @@ struct DefaultsOnlyRunner; impl PluginSetupRunner for DefaultsOnlyRunner { fn setup( &self, - _host: PluginHost, + _host: IntegrationHost, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -19,7 +19,7 @@ impl PluginSetupRunner for DefaultsOnlyRunner { fn uninstall( &self, - _host: PluginHost, + _host: IntegrationHost, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -28,7 +28,7 @@ impl PluginSetupRunner for DefaultsOnlyRunner { fn doctor( &self, - _host: PluginHost, + _host: IntegrationHost, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -37,7 +37,7 @@ impl PluginSetupRunner for DefaultsOnlyRunner { fn doctor_json( &self, - _host: PluginHost, + _host: IntegrationHost, _gateway_url: &str, _plugin_root: &Path, ) -> Result { @@ -49,40 +49,48 @@ impl PluginSetupRunner for DefaultsOnlyRunner { fn setup_runner_defaults_are_explicit_no_ops() { let runner = DefaultsOnlyRunner; - assert!(runner.snapshot(PluginHost::Codex).unwrap().is_none()); + assert!(runner.snapshot(IntegrationHost::Codex).unwrap().is_none()); runner.restore_snapshot(&PluginSetupSnapshot::Mock).unwrap(); - runner.refresh_gateway(PluginHost::Codex).unwrap(); + runner.refresh_gateway(IntegrationHost::Codex).unwrap(); } #[test] fn setup_descriptions_reject_unexpanded_hosts_and_unknown_actions() { assert!( - std::panic::catch_unwind(|| setup_action_description(PluginHost::All, "configure")) + std::panic::catch_unwind(|| setup_action_description(IntegrationHost::All, "configure")) .is_err() ); assert!( - std::panic::catch_unwind(|| setup_action_description(PluginHost::Codex, "unknown")) + std::panic::catch_unwind(|| setup_action_description(IntegrationHost::Codex, "unknown")) .is_err() ); let runner = RealPluginSetupRunner; let root = Path::new("unused"); - assert!(std::panic::catch_unwind(|| runner.snapshot(PluginHost::All)).is_err()); - assert!(std::panic::catch_unwind(|| runner.refresh_gateway(PluginHost::All)).is_err()); + assert!(std::panic::catch_unwind(|| runner.snapshot(IntegrationHost::All)).is_err()); + assert!(std::panic::catch_unwind(|| runner.refresh_gateway(IntegrationHost::All)).is_err()); assert!( - std::panic::catch_unwind(|| runner.setup(PluginHost::All, DEFAULT_GATEWAY_URL, root)) + std::panic::catch_unwind(|| runner.setup(IntegrationHost::All, DEFAULT_GATEWAY_URL, root)) .is_err() ); assert!( - std::panic::catch_unwind(|| runner.uninstall(PluginHost::All, DEFAULT_GATEWAY_URL, root)) - .is_err() + std::panic::catch_unwind(|| runner.uninstall( + IntegrationHost::All, + DEFAULT_GATEWAY_URL, + root + )) + .is_err() ); assert!( - std::panic::catch_unwind(|| runner.doctor(PluginHost::All, DEFAULT_GATEWAY_URL, root)) + std::panic::catch_unwind(|| runner.doctor(IntegrationHost::All, DEFAULT_GATEWAY_URL, root)) .is_err() ); assert!( - std::panic::catch_unwind(|| runner.doctor_json(PluginHost::All, DEFAULT_GATEWAY_URL, root)) - .is_err() + std::panic::catch_unwind(|| runner.doctor_json( + IntegrationHost::All, + DEFAULT_GATEWAY_URL, + root + )) + .is_err() ); } diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index 4e693505a..a9543108c 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -16,11 +16,12 @@ use tempfile::tempdir; use super::host::{ CommandOutput, HostRegistrationReport, format_command, host_registration_report, require_host_cli, require_relay, run_capture_command, run_command, run_path_command, - validate_codex_version, validate_host_registration, validate_relay_mcp, - validate_relay_plugin_shim, + validate_host_registration, validate_host_version, validate_relay_hook_forward, + validate_relay_mcp, }; use super::*; -use crate::plugin_shim::strip_windows_verbatim_prefix; +use crate::config::CodingAgent; +use crate::plugin_host::strip_windows_verbatim_prefix; const OPERATION_LOCK_HELPER_DIR_ENV: &str = "NEMO_RELAY_TEST_OPERATION_LOCK_DIR"; const OPERATION_LOCK_HELPER_GLOBAL_DIR_ENV: &str = "NEMO_RELAY_TEST_OPERATION_LOCK_GLOBAL_DIR"; @@ -75,7 +76,8 @@ fn windows_verbatim_relay_paths_are_normalized_for_mcp_config() { #[test] fn readiness_worker_returns_a_report_and_handles_channel_disconnects() { let dir = tempdir().unwrap(); - let pending = spawn_default_host_plugin_readiness(PluginHost::Codex, dir.path().to_path_buf()); + let pending = + spawn_default_host_plugin_readiness(IntegrationHost::Codex, dir.path().to_path_buf()); let readiness = receive_host_plugin_readiness(pending, Duration::from_secs(5)); assert_eq!(readiness.host, "codex"); assert!(!readiness.checks.is_empty()); @@ -84,7 +86,7 @@ fn readiness_worker_returns_a_report_and_handles_channel_disconnects() { drop(sender); let readiness = receive_host_plugin_readiness( PendingHostPluginReadiness { - host: PluginHost::ClaudeCode, + host: IntegrationHost::ClaudeCode, state_path: dir.path().join("claude-state.json"), receiver, }, @@ -129,12 +131,12 @@ fn dry_run_cleanup_and_rollback_cover_absent_install_state() { let dir = tempdir().unwrap(); let mut dry_run = options(dir.path()); dry_run.dry_run = true; - let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); let runner = MockRunner::default(); let setup_runner = MockSetupRunner::default(); force_cleanup_existing_install( - PluginHost::ClaudeCode, + IntegrationHost::ClaudeCode, &layout, &dry_run, &runner, @@ -142,7 +144,7 @@ fn dry_run_cleanup_and_rollback_cover_absent_install_state() { ) .unwrap(); rollback_install( - PluginHost::ClaudeCode, + IntegrationHost::ClaudeCode, &layout, HostRegistrationProgress::default(), false, @@ -159,10 +161,10 @@ fn staged_marketplace_promotion_reports_the_source_and_target() { let staged_parent = dir.path().join("stage"); let target_parent = dir.path().join("target"); let staged = StagedPluginMarketplace { - layout: PluginLayout::new(PluginHost::Codex, &staged_parent), + layout: PluginLayout::new(IntegrationHost::Codex, &staged_parent), parent: staged_parent, }; - let target = PluginLayout::new(PluginHost::Codex, &target_parent); + let target = PluginLayout::new(IntegrationHost::Codex, &target_parent); let error = staged.promote(&target).unwrap_err(); @@ -184,13 +186,13 @@ fn codex_plugin_requires_version_with_complete_hook_support() { let supported = MockRunner::default() .with_executable("codex", "/bin/codex") .with_capture_output("/bin/codex --version", "codex-cli 0.143.0\n"); - validate_codex_version(&normal, &supported).unwrap(); + validate_host_version(IntegrationHost::Codex, &normal, &supported).unwrap(); let old = MockRunner::default() .with_executable("codex", "/bin/codex") .with_capture_output("/bin/codex --version", "codex-cli 0.142.9\n"); assert!( - validate_codex_version(&normal, &old) + validate_host_version(IntegrationHost::Codex, &normal, &old) .unwrap_err() .contains("requires codex-cli 0.143.0") ); @@ -199,7 +201,7 @@ fn codex_plugin_requires_version_with_complete_hook_support() { .with_executable("codex", "/bin/codex") .with_capture_output("/bin/codex --version", "codex nightly\n"); assert!( - validate_codex_version(&normal, &invalid) + validate_host_version(IntegrationHost::Codex, &normal, &invalid) .unwrap_err() .contains("could not parse") ); @@ -208,7 +210,7 @@ fn codex_plugin_requires_version_with_complete_hook_support() { .with_executable("codex", "/bin/codex") .with_capture_output("/bin/codex --version", "codex-cli 0.143.0-alpha.1\n"); assert!( - validate_codex_version(&normal, &prerelease) + validate_host_version(IntegrationHost::Codex, &normal, &prerelease) .unwrap_err() .contains("codex-cli 0.143.0-alpha.1 is unsupported") ); @@ -222,7 +224,41 @@ fn codex_plugin_requires_version_with_complete_hook_support() { .with_executable("codex", "/bin/codex") .with_capture_output("/bin/codex --version", malformed); assert!( - validate_codex_version(&normal, &runner) + validate_host_version(IntegrationHost::Codex, &normal, &runner) + .unwrap_err() + .contains("could not parse"), + "unexpectedly parsed {malformed:?}" + ); + } +} + +#[test] +fn claude_plugin_requires_version_with_always_load_support() { + let dir = tempdir().unwrap(); + let normal = options(dir.path()); + let supported = MockRunner::default() + .with_executable("claude", "/bin/claude") + .with_capture_output("/bin/claude --version", "2.1.121 (Claude Code)\n"); + validate_host_version(IntegrationHost::ClaudeCode, &normal, &supported).unwrap(); + + for unsupported in ["2.1.120 (Claude Code)\n", "2.1.121-beta (Claude Code)\n"] { + let runner = MockRunner::default() + .with_executable("claude", "/bin/claude") + .with_capture_output("/bin/claude --version", unsupported); + assert!( + validate_host_version(IntegrationHost::ClaudeCode, &normal, &runner) + .unwrap_err() + .contains("requires Claude Code 2.1.121"), + "unexpectedly accepted {unsupported:?}" + ); + } + + for malformed in ["Claude Code 2.1.121\n", "2.1\n", "warning\n2.1.121\n"] { + let runner = MockRunner::default() + .with_executable("claude", "/bin/claude") + .with_capture_output("/bin/claude --version", malformed); + assert!( + validate_host_version(IntegrationHost::ClaudeCode, &normal, &runner) .unwrap_err() .contains("could not parse"), "unexpectedly parsed {malformed:?}" @@ -549,6 +585,8 @@ impl CommandRunner for MockRunner { .unwrap_or_else(|| { if rendered.ends_with("codex --version") { CommandOutput::success("codex-cli 0.143.0\n".into()) + } else if rendered.ends_with("claude --version") { + CommandOutput::success("2.1.121 (Claude Code)\n".into()) } else if rendered.ends_with("claude plugin list --json") || rendered.ends_with("claude plugin marketplace list --json") { @@ -582,8 +620,8 @@ impl MockSetupRunner { } impl PluginSetupRunner for MockSetupRunner { - fn snapshot(&self, host: PluginHost) -> Result, String> { - self.record(format!("snapshot {}", host_arg(host)))?; + fn snapshot(&self, host: IntegrationHost) -> Result, String> { + self.record(format!("snapshot {}", host.as_arg()))?; Ok(Some(PluginSetupSnapshot::Mock)) } @@ -591,47 +629,47 @@ impl PluginSetupRunner for MockSetupRunner { self.record("restore snapshot".into()) } - fn refresh_gateway(&self, host: PluginHost) -> Result<(), String> { - self.record(format!("refresh {}", host_arg(host))) + fn refresh_gateway(&self, host: IntegrationHost) -> Result<(), String> { + self.record(format!("refresh {}", host.as_arg())) } fn setup( &self, - host: PluginHost, + host: IntegrationHost, gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { - self.record(format!("setup {} {gateway_url}", host_arg(host))) + self.record(format!("setup {} {gateway_url}", host.as_arg())) } fn uninstall( &self, - host: PluginHost, + host: IntegrationHost, gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { - self.record(format!("uninstall {} {gateway_url}", host_arg(host))) + self.record(format!("uninstall {} {gateway_url}", host.as_arg())) } fn doctor( &self, - host: PluginHost, + host: IntegrationHost, gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { - self.record(format!("doctor {} {gateway_url}", host_arg(host))) + self.record(format!("doctor {} {gateway_url}", host.as_arg())) } fn doctor_json( &self, - host: PluginHost, + host: IntegrationHost, gateway_url: &str, plugin_root: &Path, ) -> Result { self.doctor_roots .borrow_mut() .push(plugin_root.to_path_buf()); - self.record(format!("doctor-json {} {gateway_url}", host_arg(host)))?; + self.record(format!("doctor-json {} {gateway_url}", host.as_arg()))?; Ok(json!({ "ok": true, "checks": {} @@ -661,14 +699,14 @@ fn options(dir: &Path) -> PluginInstallOptions { } fn relay_validation_command() -> String { - "/bin/nemo-relay plugin-shim hook --help".into() + "/bin/nemo-relay hook-forward --help".into() } fn relay_mcp_validation_command() -> String { "/bin/nemo-relay mcp --help".into() } -fn write_installed_state(host: PluginHost, dir: &Path) { +fn write_installed_state(host: IntegrationHost, dir: &Path) { let layout = PluginLayout::new(host, dir); write_plugin_marketplace(host, &layout, Path::new("/bin/nemo-relay"), &options(dir)).unwrap(); write_state(&layout, &options(dir)).unwrap(); @@ -676,16 +714,16 @@ fn write_installed_state(host: PluginHost, dir: &Path) { } fn write_relocated_codex_install(selected_dir: &Path, relocated_dir: &Path) -> PluginLayout { - let relocated = PluginLayout::new(PluginHost::Codex, relocated_dir); + let relocated = PluginLayout::new(IntegrationHost::Codex, relocated_dir); write_plugin_marketplace( - PluginHost::Codex, + IntegrationHost::Codex, &relocated, Path::new("/bin/nemo-relay"), &options(selected_dir), ) .unwrap(); write_state_for_host( - PluginHost::Codex, + IntegrationHost::Codex, &PluginState { marketplace_root: relocated.marketplace_root.clone(), plugin_root: relocated.plugin_root.clone(), @@ -846,7 +884,7 @@ fn cross_process_lock_holder() { PathBuf::from(std::env::var_os(OPERATION_LOCK_HELPER_GLOBAL_DIR_ENV).unwrap()); _operation_lock = Some( PluginOperationLock::acquire( - PluginHost::Codex, + IntegrationHost::Codex, &global_lock_dir, Path::new(&path), Duration::from_secs(5), @@ -884,7 +922,7 @@ fn concurrent_install_install_times_out_without_mutating() { let setup_runner = MockSetupRunner::default(); let error = install_host_with_operation_timeout( - PluginHost::Codex, + IntegrationHost::Codex, &install_options, &runner, &setup_runner, @@ -913,7 +951,7 @@ fn concurrent_install_uninstall_times_out_without_mutating() { let setup_runner = MockSetupRunner::default(); let error = uninstall_host_with_operation_timeout( - PluginHost::Codex, + IntegrationHost::Codex, &install_options, &runner, &setup_runner, @@ -946,7 +984,7 @@ fn concurrent_different_install_roots_share_the_global_host_lock() { second_options.operation_lock_dir = global_lock_dir; let install_error = install_host_with_operation_timeout( - PluginHost::Codex, + IntegrationHost::Codex, &second_options, &runner, &setup_runner, @@ -1018,26 +1056,26 @@ fn default_install_dir_follows_platform_conventions() { #[test] fn plugin_manifests_and_hooks_use_path_based_relay_command() { assert_eq!( - marketplace_manifest(PluginHost::Codex)["name"], + marketplace_manifest(IntegrationHost::Codex)["name"], json!(MARKETPLACE_NAME) ); assert_eq!( - marketplace_manifest(PluginHost::ClaudeCode)["plugins"][0]["source"], + marketplace_manifest(IntegrationHost::ClaudeCode)["plugins"][0]["source"], json!("./plugins/nemo-relay-plugin") ); assert_eq!( - plugin_manifest(PluginHost::Codex)["name"], + plugin_manifest(IntegrationHost::Codex)["name"], json!(PLUGIN_NAME) ); assert_eq!( - plugin_manifest(PluginHost::Codex)["mcpServers"], + plugin_manifest(IntegrationHost::Codex)["mcpServers"], json!("./.mcp.json") ); let generation_fence = std::env::current_dir() .unwrap() .join("plugins/nemo-relay-plugin/.nemo-relay-generation"); let mcp = plugin_mcp_config( - PluginHost::Codex, + IntegrationHost::Codex, Path::new("/bin/nemo-relay"), &generation_fence, ) @@ -1061,28 +1099,33 @@ fn plugin_manifests_and_hooks_use_path_based_relay_command() { .contains(&json!("OPENAI_API_KEY")) ); let claude_mcp = plugin_mcp_config( - PluginHost::ClaudeCode, + IntegrationHost::ClaudeCode, Path::new("/bin/nemo-relay"), &generation_fence, ); let claude_server = &claude_mcp.unwrap()["mcpServers"]["nemo-relay"]; assert_eq!(claude_server["command"], json!("/bin/nemo-relay")); assert_eq!(claude_server["args"], json!(["mcp", "--agent", "claude"])); + assert_eq!(claude_server["alwaysLoad"], json!(true)); assert_eq!( claude_server["env"]["NEMO_RELAY_MCP_GENERATION_FILE"], json!(generation_fence) ); assert_eq!( - plugin_hooks(PluginHost::Codex, Path::new("/bin/nemo-relay"))["hooks"]["SessionStart"][0]["hooks"] - [0]["command"], - json!(crate::plugin_shim::codex_plugin_hook_command(Path::new( - "/bin/nemo-relay" - ))) + plugin_hooks(IntegrationHost::Codex, Path::new("/bin/nemo-relay"))["hooks"]["SessionStart"] + [0]["hooks"][0]["command"], + json!(crate::installer::persistent_hook_forward_command( + Path::new("/bin/nemo-relay"), + CodingAgent::Codex, + )) ); assert_eq!( - plugin_hooks(PluginHost::ClaudeCode, Path::new("/bin/nemo-relay"))["hooks"]["SessionStart"] + plugin_hooks(IntegrationHost::ClaudeCode, Path::new("/bin/nemo-relay"))["hooks"]["SessionStart"] [0]["hooks"][0]["command"], - json!("nemo-relay plugin-shim hook claude") + json!(crate::installer::persistent_hook_forward_command( + Path::new("/bin/nemo-relay"), + CodingAgent::ClaudeCode, + )) ); } @@ -1097,12 +1140,15 @@ fn relay_identity_uses_running_executable_when_path_points_elsewhere() { assert_eq!(relay, PathBuf::from("/opt/nemo-relay/current/nemo-relay")); assert_eq!( - plugin_hooks(PluginHost::Codex, &relay)["hooks"]["SessionStart"][0]["hooks"][0]["command"], - json!(crate::plugin_shim::codex_plugin_hook_command(&relay)) + plugin_hooks(IntegrationHost::Codex, &relay)["hooks"]["SessionStart"][0]["hooks"][0]["command"], + json!(crate::installer::persistent_hook_forward_command( + &relay, + CodingAgent::Codex, + )) ); assert_eq!( plugin_mcp_config( - PluginHost::Codex, + IntegrationHost::Codex, &relay, Path::new("/plugins/nemo-relay-plugin/.nemo-relay-generation"), ) @@ -1235,23 +1281,23 @@ fn codex_mcp_env_vars_match_and_deduplicate_names_using_platform_semantics() { #[test] fn plugin_setup_delegates_and_dry_run_skips_runner_calls() { let dir = tempdir().unwrap(); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); let setup_runner = MockSetupRunner::default(); let dry_run = PluginInstallOptions { dry_run: true, ..options(dir.path()) }; - run_plugin_setup(PluginHost::Codex, &layout, &dry_run, &setup_runner).unwrap(); + run_plugin_setup(IntegrationHost::Codex, &layout, &dry_run, &setup_runner).unwrap(); run_plugin_uninstall( - PluginHost::ClaudeCode, + IntegrationHost::ClaudeCode, &layout.plugin_root, &dry_run, &setup_runner, ) .unwrap(); run_plugin_doctor( - PluginHost::Codex, + IntegrationHost::Codex, &layout.plugin_root, &dry_run, &setup_runner, @@ -1260,23 +1306,27 @@ fn plugin_setup_delegates_and_dry_run_skips_runner_calls() { assert!(setup_runner.calls().is_empty()); let normal = options(dir.path()); - run_plugin_setup(PluginHost::Codex, &layout, &normal, &setup_runner).unwrap(); + run_plugin_setup(IntegrationHost::Codex, &layout, &normal, &setup_runner).unwrap(); run_plugin_uninstall( - PluginHost::ClaudeCode, + IntegrationHost::ClaudeCode, &layout.plugin_root, &normal, &setup_runner, ) .unwrap(); run_plugin_doctor( - PluginHost::Codex, + IntegrationHost::Codex, &layout.plugin_root, &normal, &setup_runner, ) .unwrap(); - let report = - run_plugin_doctor_json(PluginHost::ClaudeCode, &layout.plugin_root, &setup_runner).unwrap(); + let report = run_plugin_doctor_json( + IntegrationHost::ClaudeCode, + &layout.plugin_root, + &setup_runner, + ) + .unwrap(); assert_eq!( setup_runner.calls(), @@ -1298,49 +1348,65 @@ fn real_plugin_setup_runner_uses_temp_home_for_claude_paths() { let plugin_root = dir.path().join("plugin"); runner - .setup(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) + .setup( + IntegrationHost::ClaudeCode, + DEFAULT_GATEWAY_URL, + &plugin_root, + ) .unwrap(); assert!( runner - .doctor(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) + .doctor( + IntegrationHost::ClaudeCode, + DEFAULT_GATEWAY_URL, + &plugin_root + ) .is_ok() ); let claude_report = runner - .doctor_json(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) + .doctor_json( + IntegrationHost::ClaudeCode, + DEFAULT_GATEWAY_URL, + &plugin_root, + ) .unwrap(); assert_eq!( claude_report["checks"]["claude_provider_routing"], json!(true) ); runner - .uninstall(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) + .uninstall( + IntegrationHost::ClaudeCode, + DEFAULT_GATEWAY_URL, + &plugin_root, + ) .unwrap(); } #[test] fn setup_action_descriptions_cover_supported_hosts_and_actions() { assert_eq!( - setup_action_description(PluginHost::Codex, "configure"), + setup_action_description(IntegrationHost::Codex, "configure"), "configure Codex provider and trust plugin-owned hooks" ); assert_eq!( - setup_action_description(PluginHost::Codex, "restore"), + setup_action_description(IntegrationHost::Codex, "restore"), "remove Codex provider and plugin hook trust" ); assert_eq!( - setup_action_description(PluginHost::Codex, "doctor"), + setup_action_description(IntegrationHost::Codex, "doctor"), "check Codex provider and plugin-owned hooks" ); assert_eq!( - setup_action_description(PluginHost::ClaudeCode, "configure"), + setup_action_description(IntegrationHost::ClaudeCode, "configure"), "enable Claude Code provider routing through NeMo Relay" ); assert_eq!( - setup_action_description(PluginHost::ClaudeCode, "restore"), + setup_action_description(IntegrationHost::ClaudeCode, "restore"), "restore Claude Code provider routing from NeMo Relay backup" ); assert_eq!( - setup_action_description(PluginHost::ClaudeCode, "doctor"), + setup_action_description(IntegrationHost::ClaudeCode, "doctor"), "check Claude Code provider routing" ); } @@ -1358,8 +1424,9 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { require_relay(&dry_run, &runner).unwrap(), PathBuf::from(RELAY_COMMAND) ); - require_host_cli(PluginHost::Codex, &dry_run, &runner).unwrap(); - validate_relay_plugin_shim(Path::new("nemo-relay"), &dry_run, &runner).unwrap(); + require_host_cli(IntegrationHost::Codex, &dry_run, &runner).unwrap(); + validate_host_version(IntegrationHost::ClaudeCode, &dry_run, &runner).unwrap(); + validate_relay_hook_forward(Path::new("nemo-relay"), &dry_run, &runner).unwrap(); validate_relay_mcp(Path::new("nemo-relay"), &dry_run, &runner).unwrap(); run_command( "codex", @@ -1377,7 +1444,7 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { .unwrap(); let capture = run_capture_command("codex", &["plugin".into()], &dry_run, &runner).unwrap(); assert_eq!(capture.stdout, "null\n"); - let report = host_registration_report(PluginHost::Codex, &dry_run, &runner).unwrap(); + let report = host_registration_report(IntegrationHost::Codex, &dry_run, &runner).unwrap(); assert!(report.ok()); assert_eq!(report.to_json()["ok"], json!(true)); assert_eq!( @@ -1396,7 +1463,7 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { .contains("nemo-relay") ); assert!( - require_host_cli(PluginHost::Codex, &normal, &runner) + require_host_cli(IntegrationHost::Codex, &normal, &runner) .unwrap_err() .contains("codex") ); @@ -1409,11 +1476,11 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { let mut runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); - runner.failing_quiet_suffix = Some("plugin-shim hook --help".into()); + runner.failing_quiet_suffix = Some("hook-forward --help".into()); assert!( - validate_relay_plugin_shim(Path::new("/bin/nemo-relay"), &normal, &runner) + validate_relay_hook_forward(Path::new("/bin/nemo-relay"), &normal, &runner) .unwrap_err() - .contains("plugin-shim hook") + .contains("hook-forward") ); runner.failing_quiet_suffix = Some("mcp --help".into()); assert!( @@ -1463,7 +1530,7 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { .with_executable("codex", "/bin/codex") .with_capture_output("/bin/codex plugin list", "PLUGIN STATUS VERSION PATH\n") .with_capture_output("/bin/codex plugin marketplace list", "MARKETPLACE ROOT\n"); - let error = validate_host_registration(PluginHost::Codex, &normal, &runner).unwrap_err(); + let error = validate_host_registration(IntegrationHost::Codex, &normal, &runner).unwrap_err(); assert!( error.contains("host plugin") && error.contains("host marketplace"), "error was: {error}" @@ -1500,7 +1567,8 @@ fn host_registration_report_accepts_claude_and_codex_shape_variants() { "/bin/claude plugin marketplace list --json", json!([marketplace_entry]).to_string(), ); - let report = host_registration_report(PluginHost::ClaudeCode, &normal, &runner).unwrap(); + let report = + host_registration_report(IntegrationHost::ClaudeCode, &normal, &runner).unwrap(); assert!(report.ok()); assert!(report.host_plugin_registered); assert!(report.host_marketplace_registered); @@ -1516,7 +1584,7 @@ fn host_registration_report_accepts_claude_and_codex_shape_variants() { "/bin/codex plugin marketplace list", format!("{MARKETPLACE_NAME} /tmp/nemo-relay-local\n"), ); - let report = host_registration_report(PluginHost::Codex, &normal, &runner).unwrap(); + let report = host_registration_report(IntegrationHost::Codex, &normal, &runner).unwrap(); assert!(report.ok()); let runner = MockRunner::default() @@ -1529,7 +1597,7 @@ fn host_registration_report_accepts_claude_and_codex_shape_variants() { "/bin/codex plugin marketplace list", format!("{MARKETPLACE_NAME} /tmp/nemo-relay-local\n"), ); - let report = host_registration_report(PluginHost::Codex, &normal, &runner).unwrap(); + let report = host_registration_report(IntegrationHost::Codex, &normal, &runner).unwrap(); assert!(!report.host_plugin_registered); assert!(report.host_marketplace_registered); @@ -1540,7 +1608,7 @@ fn host_registration_report_accepts_claude_and_codex_shape_variants() { format!("{PLUGIN_NAME}@other installed, enabled 0.4.0 /tmp/other\n"), ) .with_capture_output("/bin/codex plugin marketplace list", "other /tmp/other\n"); - let report = host_registration_report(PluginHost::Codex, &normal, &runner).unwrap(); + let report = host_registration_report(IntegrationHost::Codex, &normal, &runner).unwrap(); assert!(!report.ok()); assert!(!report.host_plugin_registered); assert!(!report.host_marketplace_registered); @@ -1555,7 +1623,7 @@ fn host_registration_report_surfaces_capture_status_and_stderr_variants() { .with_executable("claude", "/bin/claude") .with_capture_output("/bin/claude plugin list --json", "not json"); assert!( - host_registration_report(PluginHost::ClaudeCode, &normal, &runner) + host_registration_report(IntegrationHost::ClaudeCode, &normal, &runner) .unwrap_err() .contains("failed to parse") ); @@ -1568,7 +1636,8 @@ fn host_registration_report_surfaces_capture_status_and_stderr_variants() { "ignored stdout", " noisy failure \n", ); - let error = host_registration_report(PluginHost::ClaudeCode, &normal, &runner).unwrap_err(); + let error = + host_registration_report(IntegrationHost::ClaudeCode, &normal, &runner).unwrap_err(); assert!(error.contains("exit code 4: noisy failure")); let runner = MockRunner::default() @@ -1583,7 +1652,8 @@ fn host_registration_report_surfaces_capture_status_and_stderr_variants() { "ignored stdout", "", ); - let error = host_registration_report(PluginHost::ClaudeCode, &normal, &runner).unwrap_err(); + let error = + host_registration_report(IntegrationHost::ClaudeCode, &normal, &runner).unwrap_err(); assert!(error.contains("exit code 5")); assert!(!error.contains("exit code 5:")); } @@ -1596,7 +1666,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { let _path = PathScope::set_isolated(&empty_path, &dir.path().join("home")); let install_error = install(crate::config::InstallCommand { - host: PluginHost::All, + host: IntegrationHost::All, install_dir: Some(dir.path().join("install")), force: false, dry_run: false, @@ -1605,40 +1675,44 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { .unwrap_err() .to_string(); assert!( - install_error.contains("no supported Claude Code or Codex host CLI"), + install_error.contains("no supported Claude Code, Codex, or Hermes host CLI"), "error was: {install_error}" ); let uninstall_error = uninstall(crate::config::UninstallCommand { - host: PluginHost::All, + host: IntegrationHost::All, install_dir: Some(dir.path().join("install")), dry_run: false, }) .unwrap_err() .to_string(); assert!( - uninstall_error.contains("no installed Claude Code or Codex plugin state"), + uninstall_error.contains("no installed Claude Code, Codex, or Hermes integration state"), "error was: {uninstall_error}" ); - let doctor_error = doctor(PluginHost::All, Some(dir.path().join("install")), true) + let doctor_error = doctor(IntegrationHost::All, Some(dir.path().join("install")), true) .unwrap_err() .to_string(); assert!( - doctor_error.contains("no installed Claude Code or Codex plugin state"), + doctor_error.contains("no installed Claude Code, Codex, or Hermes integration state"), "error was: {doctor_error}" ); - let doctor_human_error = doctor(PluginHost::All, Some(dir.path().join("install")), false) - .unwrap_err() - .to_string(); + let doctor_human_error = doctor( + IntegrationHost::All, + Some(dir.path().join("install")), + false, + ) + .unwrap_err() + .to_string(); assert!( - doctor_human_error.contains("no installed Claude Code or Codex plugin state"), + doctor_human_error.contains("no installed Claude Code, Codex, or Hermes integration state"), "error was: {doctor_human_error}" ); assert_eq!( install(crate::config::InstallCommand { - host: PluginHost::Codex, + host: IntegrationHost::Codex, install_dir: Some(dir.path().join("dry-run-install")), force: false, dry_run: true, @@ -1648,23 +1722,27 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { std::process::ExitCode::SUCCESS ); - let codex_doctor_error = doctor(PluginHost::Codex, Some(dir.path().join("install")), false) - .unwrap_err() - .to_string(); + let codex_doctor_error = doctor( + IntegrationHost::Codex, + Some(dir.path().join("install")), + false, + ) + .unwrap_err() + .to_string(); assert!( codex_doctor_error.contains("nemo-relay install codex --force"), "error was: {codex_doctor_error}" ); - assert_eq!(host_arg(PluginHost::All), "all"); - assert_eq!(host_label(PluginHost::All), "all"); + assert_eq!(IntegrationHost::All.as_arg(), "all"); + assert_eq!(IntegrationHost::All.label(), "all"); print_json(&json!({"ok": true})).unwrap(); assert_eq!( with_schema(json!({"ok": true})), json!({"ok": true, "schema_version": 1}) ); assert_eq!(with_schema(json!("not-an-object")), json!("not-an-object")); - assert!(std::panic::catch_unwind(|| host_cli(PluginHost::All)).is_err()); + assert_eq!(IntegrationHost::All.executable(), None); } #[test] @@ -1672,36 +1750,36 @@ fn select_all_uses_operation_specific_inputs() { let dir = tempdir().unwrap(); let runner = MockRunner::default().with_executable("codex", "/bin/codex"); let selected = select_hosts( - PluginHost::All, + IntegrationHost::All, HostSelectionMode::Install, &options(dir.path()), &runner, ) .unwrap(); - assert_eq!(selected, vec![PluginHost::Codex]); + assert_eq!(selected, vec![IntegrationHost::Codex]); std::fs::write( - state_path(PluginHost::ClaudeCode, dir.path()), + state_path(IntegrationHost::ClaudeCode, dir.path()), r#"{"marketplaceRoot":"/tmp/m","pluginRoot":"/tmp/p"}"#, ) .unwrap(); let selected = select_hosts( - PluginHost::All, + IntegrationHost::All, HostSelectionMode::Install, &options(dir.path()), &runner, ) .unwrap(); - assert_eq!(selected, vec![PluginHost::Codex]); + assert_eq!(selected, vec![IntegrationHost::Codex]); let selected = select_hosts( - PluginHost::All, + IntegrationHost::All, HostSelectionMode::InstalledState, &options(dir.path()), &runner, ) .unwrap(); - assert_eq!(selected, vec![PluginHost::ClaudeCode]); + assert_eq!(selected, vec![IntegrationHost::ClaudeCode]); } #[test] @@ -1713,19 +1791,19 @@ fn install_codex_generates_marketplace_and_runs_setup() { let setup_runner = MockSetupRunner::default(); install_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, ) .unwrap(); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); assert_eq!( serde_json::from_str::(&std::fs::read_to_string(&layout.hooks_path).unwrap()) .unwrap(), - plugin_hooks(PluginHost::Codex, Path::new("/bin/nemo-relay")) + plugin_hooks(IntegrationHost::Codex, Path::new("/bin/nemo-relay")) ); assert_eq!( runner.commands(), @@ -1745,7 +1823,7 @@ fn install_codex_generates_marketplace_and_runs_setup() { serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) .unwrap(), plugin_mcp_config( - PluginHost::Codex, + IntegrationHost::Codex, Path::new("/bin/nemo-relay"), &layout.generation_fence, ) @@ -1764,13 +1842,13 @@ fn install_prunes_stale_managed_plugin_root() { .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("claude", "/bin/claude"); let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); let stale = layout.plugin_root.join("bin").join("nemo-relay"); std::fs::create_dir_all(stale.parent().unwrap()).unwrap(); std::fs::write(&stale, "stale").unwrap(); install_host( - PluginHost::ClaudeCode, + IntegrationHost::ClaudeCode, &options(dir.path()), &runner, &setup_runner, @@ -1789,15 +1867,15 @@ fn ordinary_codex_reinstall_refuses_a_fenced_install_without_mutating_it() { .with_executable("codex", "/bin/codex") .with_codex_registration(true, true); let setup_runner = MockSetupRunner::default(); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); let sentinel = layout.plugin_root.join("existing-install"); std::fs::write(&sentinel, b"preserve").unwrap(); let state = std::fs::read(&layout.state_path).unwrap(); let generation = std::fs::read(&layout.generation_fence).unwrap(); let error = install_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -1825,13 +1903,13 @@ fn ordinary_codex_reinstall_refuses_a_legacy_install_before_staging() { .with_executable("codex", "/bin/codex") .with_codex_registration(false, false); let setup_runner = MockSetupRunner::default(); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); std::fs::remove_file(&layout.generation_fence).unwrap(); let state = std::fs::read(&layout.state_path).unwrap(); let error = install_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -1856,7 +1934,7 @@ fn ordinary_codex_reinstall_refuses_a_registration_without_local_state() { let setup_runner = MockSetupRunner::default(); let error = install_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -1877,13 +1955,13 @@ fn ordinary_codex_reinstall_refuses_a_corrupt_install_before_staging() { .with_executable("codex", "/bin/codex") .with_codex_registration(false, false); let setup_runner = MockSetupRunner::default(); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); std::fs::write(&layout.generation_fence, b"").unwrap(); let state = std::fs::read(&layout.state_path).unwrap(); let error = install_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -1911,9 +1989,9 @@ fn force_install_unregisters_existing_host_before_reinstall() { force: true, ..options(dir.path()) }; - write_installed_state(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); - install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap(); let commands = runner.commands(); let remove_index = commands @@ -1969,11 +2047,11 @@ fn force_install_retires_previous_mcp_generation() { force: true, ..options(dir.path()) }; - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); - install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap(); let error = previous.verify_current().unwrap_err(); assert!(error.contains("has been retired")); @@ -1998,11 +2076,17 @@ fn claude_force_install_retires_and_replaces_its_mcp_generation() { force: true, ..options(dir.path()) }; - write_installed_state(PluginHost::ClaudeCode, dir.path()); - let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + write_installed_state(IntegrationHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); - install_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap(); + install_host( + IntegrationHost::ClaudeCode, + &options, + &runner, + &setup_runner, + ) + .unwrap(); assert!(previous.verify_current().unwrap_err().contains("retired")); InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); @@ -2036,14 +2120,20 @@ fn claude_force_install_rollback_restores_generation_files_and_setup_snapshot() skip_doctor: false, ..options(dir.path()) }; - write_installed_state(PluginHost::ClaudeCode, dir.path()); - let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + write_installed_state(IntegrationHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); let sentinel = layout.plugin_root.join("previous-install"); std::fs::write(&sentinel, "restore-exactly").unwrap(); let original_state = std::fs::read(&layout.state_path).unwrap(); let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); - let error = install_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); + let error = install_host( + IntegrationHost::ClaudeCode, + &options, + &runner, + &setup_runner, + ) + .unwrap_err(); assert!(error.contains("doctor claude-code"), "{error}"); assert_eq!( @@ -2082,23 +2172,29 @@ fn claude_force_install_migrates_a_legacy_hook_only_plugin() { force: true, ..options(dir.path()) }; - let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); std::fs::create_dir_all(layout.plugin_manifest.parent().unwrap()).unwrap(); write_json( &layout.marketplace_manifest, - &marketplace_manifest(PluginHost::ClaudeCode), + &marketplace_manifest(IntegrationHost::ClaudeCode), ) .unwrap(); - let mut legacy_manifest = plugin_manifest(PluginHost::ClaudeCode); + let mut legacy_manifest = plugin_manifest(IntegrationHost::ClaudeCode); legacy_manifest .as_object_mut() .unwrap() .remove("mcpServers"); write_json(&layout.plugin_manifest, &legacy_manifest).unwrap(); write_state(&layout, &options).unwrap(); - mark_plugin_setup_installed(PluginHost::ClaudeCode, &layout, &options).unwrap(); + mark_plugin_setup_installed(IntegrationHost::ClaudeCode, &layout, &options).unwrap(); - install_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap(); + install_host( + IntegrationHost::ClaudeCode, + &options, + &runner, + &setup_runner, + ) + .unwrap(); InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); assert!(layout.mcp_config.is_file()); @@ -2116,12 +2212,12 @@ fn ordinary_claude_reinstall_requires_force_for_a_fenced_plugin() { .with_executable("claude", "/bin/claude") .with_claude_registration(true, true); let setup_runner = MockSetupRunner::default(); - write_installed_state(PluginHost::ClaudeCode, dir.path()); - let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + write_installed_state(IntegrationHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); let original_generation = std::fs::read(&layout.generation_fence).unwrap(); let error = install_host( - PluginHost::ClaudeCode, + IntegrationHost::ClaudeCode, &options(dir.path()), &runner, &setup_runner, @@ -2156,14 +2252,20 @@ fn force_install_replaces_a_relocated_fenced_install_without_old_residue() { ..options(&selected_dir) }; - install_host(PluginHost::Codex, &install_options, &runner, &setup_runner).unwrap(); + install_host( + IntegrationHost::Codex, + &install_options, + &runner, + &setup_runner, + ) + .unwrap(); - let current = PluginLayout::new(PluginHost::Codex, &selected_dir); + let current = PluginLayout::new(IntegrationHost::Codex, &selected_dir); assert!(current.marketplace_root.exists()); assert!(!relocated.marketplace_root.exists()); assert!(!sentinel.exists()); assert!(previous.verify_current().unwrap_err().contains("retired")); - let state = read_state(PluginHost::Codex, &selected_dir).unwrap(); + let state = read_state(IntegrationHost::Codex, &selected_dir).unwrap(); assert_eq!(state.marketplace_root, current.marketplace_root); assert_eq!(state.plugin_root, current.plugin_root); assert_no_force_replacement_residue(&selected_dir); @@ -2178,7 +2280,7 @@ fn force_install_rollback_restores_a_relocated_fenced_install_and_registration() let relocated = write_relocated_codex_install(&selected_dir, &relocated_dir); let sentinel = relocated.plugin_root.join("relocated-install"); std::fs::write(&sentinel, "restore-exactly").unwrap(); - let original_state = std::fs::read(state_path(PluginHost::Codex, &selected_dir)).unwrap(); + let original_state = std::fs::read(state_path(IntegrationHost::Codex, &selected_dir)).unwrap(); let previous = InstallGeneration::capture(relocated.generation_fence.clone()).unwrap(); let runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") @@ -2194,11 +2296,16 @@ fn force_install_rollback_restores_a_relocated_fenced_install_and_registration() ..options(&selected_dir) }; - let error = - install_host(PluginHost::Codex, &install_options, &runner, &setup_runner).unwrap_err(); + let error = install_host( + IntegrationHost::Codex, + &install_options, + &runner, + &setup_runner, + ) + .unwrap_err(); assert!(error.contains("doctor codex"), "{error}"); - let current = PluginLayout::new(PluginHost::Codex, &selected_dir); + let current = PluginLayout::new(IntegrationHost::Codex, &selected_dir); assert!(!current.marketplace_root.exists()); assert!(relocated.marketplace_root.exists()); assert_eq!( @@ -2207,7 +2314,7 @@ fn force_install_rollback_restores_a_relocated_fenced_install_and_registration() ); previous.verify_current().unwrap(); assert_eq!( - std::fs::read(state_path(PluginHost::Codex, &selected_dir)).unwrap(), + std::fs::read(state_path(IntegrationHost::Codex, &selected_dir)).unwrap(), original_state ); let marketplace_adds = runner @@ -2252,7 +2359,7 @@ fn force_install_rejects_registered_legacy_plugin_without_generation_fence() { ..options(dir.path()) }; - let error = install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + let error = install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); assert!( error.contains("MCP generation marker is missing"), @@ -2277,11 +2384,11 @@ fn force_install_rejects_unregistered_legacy_plugin_without_generation_fence() { force: true, ..options(dir.path()) }; - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); std::fs::remove_file(&layout.generation_fence).unwrap(); - let error = install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + let error = install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); assert!( error.contains("MCP generation marker is missing"), @@ -2311,11 +2418,12 @@ fn force_install_rejects_corrupt_generation_marker_without_mutating() { force: true, ..options(dir.path()) }; - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); corrupt_generation_fence(&layout.generation_fence, corruption); - let error = install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + let error = + install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); assert_actionable_generation_error(&error, "is invalid or unreadable"); assert!(error.contains(cause), "{corruption}: {error}"); @@ -2340,9 +2448,9 @@ fn force_install_allows_a_clean_first_install_without_generation_fence() { ..options(dir.path()) }; - install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap(); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); assert!(layout.generation_fence.exists()); assert!(layout.state_path.exists()); } @@ -2359,9 +2467,9 @@ fn force_install_uses_live_absent_registration_instead_of_stale_installed_state( force: true, ..options(dir.path()) }; - write_installed_state(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); - install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap(); let commands = runner.commands(); assert!( @@ -2390,10 +2498,10 @@ fn force_install_uses_live_present_registration_instead_of_stale_removed_state() force: true, ..options(dir.path()) }; - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); write_state_for_host( - PluginHost::Codex, + IntegrationHost::Codex, &PluginState { marketplace_root: layout.marketplace_root.clone(), plugin_root: layout.plugin_root.clone(), @@ -2406,7 +2514,7 @@ fn force_install_uses_live_present_registration_instead_of_stale_removed_state() ) .unwrap(); - install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap(); let commands = runner.commands(); assert!(commands.iter().any(|command| { @@ -2464,11 +2572,11 @@ fn force_install_keeps_existing_registration_when_gateway_refresh_fails() { force: true, ..options(dir.path()) }; - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); - let error = install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + let error = install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); assert!(error.contains("refresh codex failed")); assert!(layout.state_path.exists()); @@ -2507,13 +2615,13 @@ fn force_install_restores_previous_install_after_doctor_failure() { skip_doctor: false, ..options(dir.path()) }; - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); let sentinel = layout.plugin_root.join("previous-install"); std::fs::write(&sentinel, "preserve").unwrap(); let original_state = std::fs::read(&layout.state_path).unwrap(); - let error = install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + let error = install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); assert!(error.contains("doctor codex"), "{error}"); assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "preserve"); @@ -2563,13 +2671,13 @@ fn force_install_does_not_uninstall_restored_setup_after_setup_failure() { force: true, ..options(dir.path()) }; - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); let sentinel = layout.plugin_root.join("previous-install"); std::fs::write(&sentinel, "preserve").unwrap(); let original_state = std::fs::read(&layout.state_path).unwrap(); - let error = install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + let error = install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); assert!(error.contains("setup codex"), "{error}"); assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "preserve"); @@ -2597,14 +2705,14 @@ fn install_claude_enables_provider_routing() { let setup_runner = MockSetupRunner::default(); install_host( - PluginHost::ClaudeCode, + IntegrationHost::ClaudeCode, &options(dir.path()), &runner, &setup_runner, ) .unwrap(); - let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); assert_eq!( runner.commands(), vec![ @@ -2625,6 +2733,31 @@ fn install_claude_enables_provider_routing() { ); } +#[test] +fn install_claude_rejects_hosts_without_always_load_support_before_writing() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_capture_output("/bin/claude --version", "2.1.120 (Claude Code)\n"); + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + IntegrationHost::ClaudeCode, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("requires Claude Code 2.1.121"), "{error}"); + let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); +} + #[test] fn missing_relay_path_fails_before_generating_plugin() { let dir = tempdir().unwrap(); @@ -2632,7 +2765,7 @@ fn missing_relay_path_fails_before_generating_plugin() { let setup_runner = MockSetupRunner::default(); let error = install_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -2641,7 +2774,7 @@ fn missing_relay_path_fails_before_generating_plugin() { assert!(error.contains("nemo-relay")); assert!( - !PluginLayout::new(PluginHost::Codex, dir.path()) + !PluginLayout::new(IntegrationHost::Codex, dir.path()) .marketplace_root .exists() ); @@ -2653,20 +2786,20 @@ fn unsupported_relay_path_fails_before_generating_plugin() { let mut runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); - runner.failing_quiet_suffix = Some("plugin-shim hook --help".into()); + runner.failing_quiet_suffix = Some("hook-forward --help".into()); let setup_runner = MockSetupRunner::default(); let error = install_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, ) .unwrap_err(); - assert!(error.contains("plugin-shim hook")); + assert!(error.contains("hook-forward")); assert!( - !PluginLayout::new(PluginHost::Codex, dir.path()) + !PluginLayout::new(IntegrationHost::Codex, dir.path()) .marketplace_root .exists() ); @@ -2682,7 +2815,7 @@ fn relay_without_native_mcp_fails_codex_install_before_generating_plugin() { let setup_runner = MockSetupRunner::default(); let error = install_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -2691,7 +2824,7 @@ fn relay_without_native_mcp_fails_codex_install_before_generating_plugin() { assert!(error.contains("native `nemo-relay mcp` support")); assert!( - !PluginLayout::new(PluginHost::Codex, dir.path()) + !PluginLayout::new(IntegrationHost::Codex, dir.path()) .marketplace_root .exists() ); @@ -2709,7 +2842,7 @@ fn setup_failure_rolls_back_generated_files_and_registration() { }; let error = install_host( - PluginHost::ClaudeCode, + IntegrationHost::ClaudeCode, &options(dir.path()), &runner, &setup_runner, @@ -2718,7 +2851,7 @@ fn setup_failure_rolls_back_generated_files_and_registration() { assert!(error.contains("setup claude-code")); assert!( - !PluginLayout::new(PluginHost::ClaudeCode, dir.path()) + !PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()) .marketplace_root .exists() ); @@ -2751,11 +2884,17 @@ fn doctor_failure_fails_install_and_rolls_back() { ..options(dir.path()) }; - let error = install_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); + let error = install_host( + IntegrationHost::ClaudeCode, + &options, + &runner, + &setup_runner, + ) + .unwrap_err(); assert!(error.contains("doctor claude-code")); assert!( - !PluginLayout::new(PluginHost::ClaudeCode, dir.path()) + !PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()) .marketplace_root .exists() ); @@ -2772,7 +2911,7 @@ fn registration_failure_does_not_restore_plugin_setup_that_never_ran() { let install_dir = dir.path().join("failure"); let error = install_host( - PluginHost::ClaudeCode, + IntegrationHost::ClaudeCode, &options(&install_dir), &runner, &setup_runner, @@ -2785,7 +2924,7 @@ fn registration_failure_does_not_restore_plugin_setup_that_never_ran() { "setup rollback should not run before setup was attempted" ); assert!( - !PluginLayout::new(PluginHost::ClaudeCode, &install_dir) + !PluginLayout::new(IntegrationHost::ClaudeCode, &install_dir) .marketplace_root .exists() ); @@ -2799,10 +2938,10 @@ fn plugin_registration_failure_rolls_back_marketplace_without_plugin_removal() { .with_executable("codex", "/bin/codex"); runner.failing_suffix = Some("plugin add nemo-relay-plugin@nemo-relay-local".into()); let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); let error = install_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -2834,11 +2973,11 @@ fn invalid_existing_state_fails_before_generating_marketplace() { .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); std::fs::create_dir_all(&layout.state_path).unwrap(); let error = install_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -2865,7 +3004,7 @@ fn retry_after_partial_registration_rollback_does_not_restore_uninstalled_setup( let setup_runner = MockSetupRunner::default(); let error = install_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -2873,7 +3012,7 @@ fn retry_after_partial_registration_rollback_does_not_restore_uninstalled_setup( .unwrap_err(); assert!(error.contains("additionally failed to roll back install")); - let state = read_state(PluginHost::Codex, dir.path()).unwrap(); + let state = read_state(IntegrationHost::Codex, dir.path()).unwrap(); assert!(state.host_plugin_removed); assert!(!state.host_marketplace_removed); assert!(!state.plugin_setup_installed); @@ -2882,7 +3021,7 @@ fn retry_after_partial_registration_rollback_does_not_restore_uninstalled_setup( .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); uninstall_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -2909,7 +3048,7 @@ fn retry_after_failed_codex_setup_does_not_uninstall_restored_setup() { }; let error = install_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -2917,7 +3056,7 @@ fn retry_after_failed_codex_setup_does_not_uninstall_restored_setup() { .unwrap_err(); assert!(error.contains("additionally failed to roll back install")); - let state = read_state(PluginHost::Codex, dir.path()).unwrap(); + let state = read_state(IntegrationHost::Codex, dir.path()).unwrap(); assert!(state.host_plugin_removed); assert!(!state.host_marketplace_removed); assert!(!state.plugin_setup_installed); @@ -2926,7 +3065,7 @@ fn retry_after_failed_codex_setup_does_not_uninstall_restored_setup() { .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); uninstall_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -2949,17 +3088,17 @@ fn uninstall_uses_installed_state_and_removes_marketplace() { .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); install_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, ) .unwrap(); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); assert!(layout.marketplace_root.exists()); uninstall_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -2988,12 +3127,12 @@ fn uninstall_rejects_registered_legacy_plugin_without_generation_fence() { .with_executable("codex", "/bin/codex") .with_codex_registration(true, true); let setup_runner = MockSetupRunner::default(); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); std::fs::remove_file(&layout.generation_fence).unwrap(); let error = uninstall_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -3019,12 +3158,12 @@ fn uninstall_rejects_unregistered_legacy_plugin_without_generation_fence() { .with_executable("codex", "/bin/codex") .with_codex_registration(false, false); let setup_runner = MockSetupRunner::default(); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); std::fs::remove_file(&layout.generation_fence).unwrap(); let error = uninstall_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -3054,12 +3193,12 @@ fn uninstall_rejects_each_corrupt_generation_marker_actionably() { .with_executable("codex", "/bin/codex") .with_codex_registration(false, false); let setup_runner = MockSetupRunner::default(); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); corrupt_generation_fence(&layout.generation_fence, corruption); let error = uninstall_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -3080,11 +3219,11 @@ fn uninstall_continues_when_relay_is_missing() { let dir = tempdir().unwrap(); let runner = MockRunner::default().with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); uninstall_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -3118,10 +3257,10 @@ fn doctor_json_uses_quiet_plugin_report() { ); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); let report = - doctor_host_json_value(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + doctor_host_json_value(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap(); assert_eq!( setup_runner.calls(), @@ -3149,12 +3288,12 @@ fn doctor_uses_plugin_root_persisted_in_install_state() { .with_codex_registration(true, true); let setup_runner = MockSetupRunner::default(); let install_options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); let relocated_root = dir.path().join("relocated-plugin-root"); std::fs::rename(&layout.plugin_root, &relocated_root).unwrap(); write_state_for_host( - PluginHost::Codex, + IntegrationHost::Codex, &PluginState { marketplace_root: layout.marketplace_root.clone(), plugin_root: relocated_root.clone(), @@ -3167,8 +3306,12 @@ fn doctor_uses_plugin_root_persisted_in_install_state() { ) .unwrap(); - let _readiness = - collect_host_plugin_readiness(PluginHost::Codex, &install_options, &runner, &setup_runner); + let _readiness = collect_host_plugin_readiness( + IntegrationHost::Codex, + &install_options, + &runner, + &setup_runner, + ); assert_eq!(setup_runner.doctor_roots(), vec![relocated_root]); } @@ -3187,10 +3330,11 @@ fn codex_doctor_reports_upgrade_remediation_for_old_and_malformed_versions() { .with_capture_output("/bin/codex --version", version_output); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); let report = - doctor_host_json_value(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + doctor_host_json_value(IntegrationHost::Codex, &options, &runner, &setup_runner) + .unwrap(); let version_check = report["readiness_checks"] .as_array() .unwrap() @@ -3208,17 +3352,73 @@ fn codex_doctor_reports_upgrade_remediation_for_old_and_malformed_versions() { assert_eq!( report["remediation"], json!( - "upgrade Codex to codex-cli 0.143.0 or newer, then run `nemo-relay install codex --force`" + "upgrade to codex-cli 0.143.0 or newer, then run `nemo-relay install codex --force`" ) ); let text_error = - doctor_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); - assert!(text_error.contains("remediation: upgrade Codex")); + doctor_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + assert!(text_error.contains("remediation: upgrade to codex-cli")); assert!(text_error.contains("codex-cli 0.143.0 or newer")); } } +#[test] +fn claude_doctor_reports_upgrade_remediation_for_old_and_malformed_versions() { + for (version_output, expected_detail) in [ + ("2.1.120 (Claude Code)\n", "requires Claude Code 2.1.121"), + ("Claude Code nightly\n", "could not parse"), + ] { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_claude_registration(true, true) + .with_capture_output("/bin/claude --version", version_output); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(IntegrationHost::ClaudeCode, dir.path()); + + let report = doctor_host_json_value( + IntegrationHost::ClaudeCode, + &options, + &runner, + &setup_runner, + ) + .unwrap(); + let version_check = report["readiness_checks"] + .as_array() + .unwrap() + .iter() + .find(|check| check["name"] == "Claude Code version") + .unwrap(); + assert_eq!(report["ok"], json!(false)); + assert_eq!(version_check["ok"], json!(false)); + assert!( + version_check["details"] + .as_str() + .unwrap() + .contains(expected_detail) + ); + assert_eq!( + report["remediation"], + json!( + "upgrade to Claude Code 2.1.121 or newer, then run `nemo-relay install claude-code --force`" + ) + ); + + let text_error = doctor_host( + IntegrationHost::ClaudeCode, + &options, + &runner, + &setup_runner, + ) + .unwrap_err(); + assert!(text_error.contains("remediation: upgrade to Claude Code")); + assert!(text_error.contains("upgrade to Claude Code 2.1.121 or newer")); + } +} + #[test] fn readiness_report_marks_missing_generated_plugin_files_as_failed() { let dir = tempdir().unwrap(); @@ -3235,11 +3435,12 @@ fn readiness_report_marks_missing_generated_plugin_files_as_failed() { ); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); std::fs::remove_file(layout.plugin_manifest).unwrap(); - let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); + let report = + collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); assert!(!report.ok()); assert!(report.checks.iter().any(|check| { @@ -3259,11 +3460,12 @@ fn readiness_report_rejects_missing_generated_mcp_server() { .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); std::fs::remove_file(layout.mcp_config).unwrap(); - let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); + let report = + collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); assert!(!report.ok()); assert!(report.checks.iter().any(|check| { @@ -3279,11 +3481,12 @@ fn readiness_report_rejects_missing_mcp_generation_fence() { .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); std::fs::remove_file(layout.generation_fence).unwrap(); - let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); + let report = + collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); assert!(!report.ok()); assert!(report.checks.iter().any(|check| { @@ -3302,13 +3505,17 @@ fn claude_readiness_requires_its_mcp_server_and_generation_fence() { .with_claude_registration(true, true); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(PluginHost::ClaudeCode, dir.path()); - let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + write_installed_state(IntegrationHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); std::fs::remove_file(layout.mcp_config).unwrap(); std::fs::remove_file(layout.generation_fence).unwrap(); - let report = - collect_host_plugin_readiness(PluginHost::ClaudeCode, &options, &runner, &setup_runner); + let report = collect_host_plugin_readiness( + IntegrationHost::ClaudeCode, + &options, + &runner, + &setup_runner, + ); assert!(!report.ok()); for name in ["Generated MCP server", "MCP generation fence"] { @@ -3330,12 +3537,12 @@ fn readiness_report_rejects_mcp_server_for_different_binary() { .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); write_json( &layout.mcp_config, &plugin_mcp_config( - PluginHost::Codex, + IntegrationHost::Codex, Path::new("/tmp/other-relay"), &layout.generation_fence, ) @@ -3343,7 +3550,8 @@ fn readiness_report_rejects_mcp_server_for_different_binary() { ) .unwrap(); - let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); + let report = + collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); assert!(!report.ok()); assert!(report.checks.iter().any(|check| { @@ -3359,8 +3567,8 @@ fn readiness_report_names_newly_required_mcp_env_vars_and_force_remediation() { .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); let mut mcp: Value = serde_json::from_str(&std::fs::read_to_string(&layout.mcp_config).unwrap()).unwrap(); mcp["nemo-relay"]["env_vars"] @@ -3369,7 +3577,8 @@ fn readiness_report_names_newly_required_mcp_env_vars_and_force_remediation() { .retain(|name| name != "OPENAI_API_KEY"); write_json(&layout.mcp_config, &mcp).unwrap(); - let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); + let report = + collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); assert!(!report.ok()); let check = report @@ -3398,15 +3607,16 @@ fn readiness_report_rejects_invalid_generated_manifest_contents() { ); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); std::fs::write( &layout.marketplace_manifest, r#"{"name":"wrong-marketplace"}"#, ) .unwrap(); - let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); + let report = + collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); assert!(!report.ok()); assert!(report.checks.iter().any(|check| { @@ -3430,9 +3640,9 @@ fn readiness_report_accepts_generated_plugin_manifest_from_an_older_version() { ); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); - let mut manifest = plugin_manifest(PluginHost::Codex); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let mut manifest = plugin_manifest(IntegrationHost::Codex); manifest["version"] = json!("0.0.0"); std::fs::write( &layout.plugin_manifest, @@ -3440,7 +3650,8 @@ fn readiness_report_accepts_generated_plugin_manifest_from_an_older_version() { ) .unwrap(); - let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); + let report = + collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); assert!(report.ok()); assert!( @@ -3456,10 +3667,10 @@ fn doctor_json_preserves_unknown_host_registration_state() { let dir = tempdir().unwrap(); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); let report = doctor_host_json_value( - PluginHost::Codex, + IntegrationHost::Codex, &options, &MockRunner::default(), &setup_runner, @@ -3479,7 +3690,7 @@ fn timed_out_host_plugin_readiness_is_actionable() { let report = receive_host_plugin_readiness( PendingHostPluginReadiness { - host: PluginHost::Codex, + host: IntegrationHost::Codex, state_path: state_path.clone(), receiver, }, @@ -3515,7 +3726,7 @@ fn stopped_lazy_sidecar_does_not_fail_host_readiness() { append_plugin_setup_checks( &mut readiness, &json!({ - "sidecar_health": "not_running_lazy_start", + "sidecar_health": "not_running_mcp_start", "checks": { "plugin_binary": true, "sidecar_running": false, @@ -3562,9 +3773,15 @@ fn doctor_validates_claude_host_registration_before_setup_doctor() { ); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(PluginHost::ClaudeCode, dir.path()); + write_installed_state(IntegrationHost::ClaudeCode, dir.path()); - doctor_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap(); + doctor_host( + IntegrationHost::ClaudeCode, + &options, + &runner, + &setup_runner, + ) + .unwrap(); assert_eq!( setup_runner.calls(), @@ -3573,6 +3790,7 @@ fn doctor_validates_claude_host_registration_before_setup_doctor() { assert_eq!( runner.capture_commands(), vec![ + "/bin/claude --version", "/bin/claude plugin list --json", "/bin/claude plugin marketplace list --json" ] @@ -3595,9 +3813,15 @@ fn doctor_fails_when_claude_host_plugin_is_missing() { ); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(PluginHost::ClaudeCode, dir.path()); + write_installed_state(IntegrationHost::ClaudeCode, dir.path()); - let error = doctor_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); + let error = doctor_host( + IntegrationHost::ClaudeCode, + &options, + &runner, + &setup_runner, + ) + .unwrap_err(); assert!(error.contains("nemo-relay install claude-code --force")); assert_eq!( @@ -3625,9 +3849,15 @@ fn doctor_fails_when_claude_host_marketplace_is_missing() { ); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(PluginHost::ClaudeCode, dir.path()); + write_installed_state(IntegrationHost::ClaudeCode, dir.path()); - let error = doctor_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); + let error = doctor_host( + IntegrationHost::ClaudeCode, + &options, + &runner, + &setup_runner, + ) + .unwrap_err(); assert!(error.contains("nemo-relay install claude-code --force")); assert_eq!( @@ -3646,7 +3876,7 @@ fn uninstall_cleans_up_plugin_setup_before_host_removal_failure() { let setup_runner = MockSetupRunner::default(); let error = uninstall_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -3673,10 +3903,10 @@ fn uninstall_does_not_unregister_host_when_plugin_cleanup_fails() { failing_call: Some(format!("uninstall codex {DEFAULT_GATEWAY_URL}")), ..MockSetupRunner::default() }; - write_installed_state(PluginHost::Codex, dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); let error = uninstall_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -3684,7 +3914,7 @@ fn uninstall_does_not_unregister_host_when_plugin_cleanup_fails() { .unwrap_err(); assert!(error.contains("uninstall codex")); - let state = read_state(PluginHost::Codex, dir.path()).unwrap(); + let state = read_state(IntegrationHost::Codex, dir.path()).unwrap(); assert!(!state.host_plugin_removed); assert!(!state.host_marketplace_removed); assert!(state.plugin_setup_installed); @@ -3696,9 +3926,9 @@ fn uninstall_retry_skips_host_removal_after_prior_success() { let mut runner = MockRunner::default().with_executable("nemo-relay", "/bin/nemo-relay"); runner.failing_suffix = Some("plugin remove nemo-relay-plugin@nemo-relay-local".into()); let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); write_state_for_host( - PluginHost::Codex, + IntegrationHost::Codex, &PluginState { marketplace_root: layout.marketplace_root.clone(), plugin_root: layout.plugin_root.clone(), @@ -3713,7 +3943,7 @@ fn uninstall_retry_skips_host_removal_after_prior_success() { crate::install_generation::write_new_generation(&layout.generation_fence).unwrap(); uninstall_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -3743,12 +3973,12 @@ fn uninstall_retry_skips_plugin_removal_after_marketplace_failure() { .with_executable("codex", "/bin/codex"); runner.failing_suffix = Some("plugin marketplace remove nemo-relay-local".into()); let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); write_state(&layout, &options(dir.path())).unwrap(); crate::install_generation::write_new_generation(&layout.generation_fence).unwrap(); let error = uninstall_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, @@ -3756,7 +3986,7 @@ fn uninstall_retry_skips_plugin_removal_after_marketplace_failure() { .unwrap_err(); assert!(error.contains("plugin marketplace remove")); - let state = read_state(PluginHost::Codex, dir.path()).unwrap(); + let state = read_state(IntegrationHost::Codex, dir.path()).unwrap(); assert!(state.host_plugin_removed); assert!(!state.host_marketplace_removed); @@ -3764,7 +3994,7 @@ fn uninstall_retry_skips_plugin_removal_after_marketplace_failure() { .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); uninstall_host( - PluginHost::Codex, + IntegrationHost::Codex, &options(dir.path()), &runner, &setup_runner, diff --git a/docs/about-nemo-relay/release-notes/index.mdx b/docs/about-nemo-relay/release-notes/index.mdx index 48e65a6af..8a1d1eb2c 100644 --- a/docs/about-nemo-relay/release-notes/index.mdx +++ b/docs/about-nemo-relay/release-notes/index.mdx @@ -57,7 +57,7 @@ For the major 0.5 additions, start with: - [Build Plugins](/build-plugins/about) - [Plugin Configuration Files](/configure-plugins/plugin-configuration-files) - [Installation](/getting-started/installation) -- [Plugin Installation and Diagnostics](/nemo-relay-cli/plugin-installation) +- [Coding Agent Installation and Diagnostics](/nemo-relay-cli/plugin-installation) - [LLM Request Intercept Outcomes](/reference/llm-request-intercept-outcomes) - [Tool Execution Intercept Outcomes](/reference/tool-execution-intercept-outcomes) - [Instrument an LLM Call](/instrument-applications/instrument-llm-call) diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index a6e1c679f..1f505d655 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -61,16 +61,17 @@ inherit the persistent `PATH` change. Verify that the command is available: nemo-relay --version ``` -After installation, `nemo-relay` can also install persistent Claude Code and -Codex host plugins: +After installation, `nemo-relay` can also install persistent Claude Code, +Codex, and Hermes Agent integrations: ```bash nemo-relay install claude-code nemo-relay install codex +nemo-relay install hermes ``` -Refer to [Plugin Installation](/nemo-relay-cli/plugin-installation) for the host -plugin workflow. +Refer to [Coding Agent Installation](/nemo-relay-cli/plugin-installation) for +the persistent integration workflow. The installers download the published SHA-256 checksum for the selected release @@ -230,17 +231,18 @@ configuration and verification steps. ### Hermes Agent -After installing the NeMo Relay CLI, configure the Hermes user integration: +After installing the NeMo Relay CLI, install and diagnose the Hermes user +integration: ```bash -nemo-relay config hermes -nemo-relay doctor hermes +nemo-relay install hermes +nemo-relay doctor --plugin hermes ``` Relay adds a native stdio MCP entry and the exact trusted lifecycle hooks that -share one deferred user-level gateway. Refer to the -[Hermes Agent guide](/nemo-relay-cli/hermes) for provider routing, lifecycle, -and removal steps. +share one user-level gateway acquired as soon as the MCP process starts. Refer +to the [Hermes Agent guide](/nemo-relay-cli/hermes) for provider routing, +lifecycle, and removal steps. ### Python Framework Integrations diff --git a/docs/getting-started/quick-start/index.mdx b/docs/getting-started/quick-start/index.mdx index f1152f1e4..29e61b57c 100644 --- a/docs/getting-started/quick-start/index.mdx +++ b/docs/getting-started/quick-start/index.mdx @@ -26,7 +26,7 @@ not yet know which guide owns the working path. | Layer | Use When | Start Here | Success Check | |---|---|---|---| | CLI and gateway | A coding-agent harness owns invocation and provider routing | [CLI Basic Usage](/nemo-relay-cli/basic-usage) | The wrapped agent runs with Relay active and emits hook output, plus gateway-routed LLM lifecycle output when provider routing is active. | -| Persistent host-plugin installs | You want the maintained install path for Codex or Claude Code instead of the transparent wrapper | [Plugin Installation](/nemo-relay-cli/plugin-installation) | `nemo-relay doctor --plugin ` confirms the installed host plugin is registered and ready. | +| Persistent coding-agent installs | You want the maintained install path for Codex, Claude Code, or Hermes Agent instead of the transparent wrapper | [Coding Agent Installation](/nemo-relay-cli/plugin-installation) | `nemo-relay doctor --plugin ` confirms the installed integration is ready. | | Direct Python or Node.js application APIs | Your application owns the tool or LLM callback | [Python Quick Start](/getting-started/quick-start/python) or [Node.js Quick Start](/getting-started/quick-start/nodejs) | The sample prints event lines plus tool and LLM results. | | Direct Rust application APIs | Your Rust application owns the tool or LLM callback | [Rust Quick Start](/getting-started/quick-start/rust) | The sample prints scope, tool, and LLM lifecycle output plus the `initialized` mark event. | | Plugin-managed runtime setup | You need process-level exporter or plugin behavior from `plugins.toml` | [Plugin Configuration Files](/configure-plugins/plugin-configuration-files) | The selected plugin path activates and writes the expected output or behavior. | diff --git a/docs/nemo-relay-cli/about.mdx b/docs/nemo-relay-cli/about.mdx index d82985bb1..70440e403 100644 --- a/docs/nemo-relay-cli/about.mdx +++ b/docs/nemo-relay-cli/about.mdx @@ -27,8 +27,8 @@ Use these guides when you need to: - Observe Claude Code, Codex, or Hermes Agent sessions locally. - Configure coding-agent hooks for NeMo Relay lifecycle events. - Route model-provider traffic through the local NeMo Relay gateway. -- Install persistent Claude Code or Codex host plugins without wrapping the - agent command. +- Install persistent Claude Code, Codex, or Hermes Agent integrations without + wrapping the agent command. - Validate and install model pricing catalog sources for local cost estimates. - Export local sessions to Agent Trajectory Interchange Format (ATIF), Agent Trajectory Observability Format (ATOF) JSONL, OpenTelemetry, or @@ -58,8 +58,8 @@ Use these guide links to move from CLI setup into agent-specific instructions. - [Basic Usage](/nemo-relay-cli/basic-usage) explains gateway routes, transparent runs, shared configuration, hook forwarding, and runtime mapping. -- [Plugin Installation](/nemo-relay-cli/plugin-installation) covers persistent - Claude Code and Codex plugin setup through host marketplaces. +- [Coding Agent Installation](/nemo-relay-cli/plugin-installation) covers + persistent Claude Code, Codex, and Hermes Agent setup. - [Claude Code](/nemo-relay-cli/claude-code) covers transparent Claude Code runs, Anthropic gateway routing, ATIF verification, and unsupported Claude application modes. diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index 041a7f2af..eeb7d896b 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -92,26 +92,24 @@ environment, gateway URL, and final command without launching the agent. ## Persistent Host Integrations -Use persistent plugin installation when Claude Code or Codex should load NeMo -Relay through the host's plugin system instead of through a wrapper command: +Use persistent integration installation when Claude Code, Codex, or Hermes +Agent should load Relay instead of using a wrapper command: ```bash nemo-relay install claude-code nemo-relay install codex +nemo-relay install hermes nemo-relay install all ``` -Hermes exposes the same stdio MCP lifecycle but owns it through user config -instead of a marketplace plugin: - -```bash -nemo-relay config hermes -``` +Claude Code and Codex use marketplace plugins. Hermes exposes the same stdio +MCP lifecycle through user configuration. Hermes, Claude Code, and Codex MCP clients can share the native gateway on -`127.0.0.1:47632`. Hermes and Claude Code start MCP asynchronously, so their -canonical hook command also closes the cold-start race and retries the original -payload once. +`127.0.0.1:47632`. Every `nemo-relay mcp` process acquires that gateway as soon +as it launches. Claude Code uses `alwaysLoad` to wait for the MCP connection; +Hermes starts discovery asynchronously, so its canonical hook command also +closes the remaining cold-start race and retries the original payload once. Plugin installation writes a local marketplace, installs the generated `nemo-relay-plugin`, and configures the host-specific hook and provider routing @@ -125,8 +123,8 @@ nemo-relay doctor --plugin codex nemo-relay uninstall codex ``` -Refer to [Plugin Installation](/nemo-relay-cli/plugin-installation) for install -directories, host-specific behavior, and the shared-sidecar lifecycle. +Refer to [Coding Agent Installation](/nemo-relay-cli/plugin-installation) for +install directories, host-specific behavior, and the shared-sidecar lifecycle. ## Shared Configuration @@ -333,11 +331,12 @@ Transparent Claude Code and Codex hooks invoke `nemo-relay hook-forward ` with the canonical payload on standard input. The wrapper injects `NEMO_RELAY_GATEWAY_URL` for its ephemeral gateway. -Persistent Claude Code, Codex, and Hermes hooks use -`nemo-relay plugin-shim hook `. That path starts or reuses the shared -native sidecar before forwarding the unchanged payload. Hermes setup stores the -canonical absolute command and trusts its exact event pairs; it does not enable -global hook auto-acceptance. +Persistent Claude Code, Codex, and Hermes hooks also use +`nemo-relay hook-forward `. A persistent generated command identifies +the fixed gateway explicitly, preflights its Relay identity, and performs one +coordinated start or recovery before forwarding the unchanged payload. Hermes +setup stores the canonical absolute command and trusts its exact event pairs; +it does not enable global hook auto-acceptance. `hook-forward` reads the canonical hook payload from standard input, sends it to the matching endpoint, and prints the endpoint response. It fails open by @@ -357,7 +356,7 @@ application-mode caveats. - [Claude Code](/nemo-relay-cli/claude-code) - [Codex](/nemo-relay-cli/codex) -- [Plugin Installation](/nemo-relay-cli/plugin-installation) +- [Coding Agent Installation](/nemo-relay-cli/plugin-installation) - [Hermes Agent](/nemo-relay-cli/hermes) Each guide covers transparent run setup, gateway routing, hook smoke tests, diff --git a/docs/nemo-relay-cli/claude-code.mdx b/docs/nemo-relay-cli/claude-code.mdx index 40908d735..5802af847 100644 --- a/docs/nemo-relay-cli/claude-code.mdx +++ b/docs/nemo-relay-cli/claude-code.mdx @@ -55,18 +55,17 @@ provider routing through the local NeMo Relay sidecar. It uses the existing `nemo-relay` binary on `PATH`; it does not install a plugin-local Relay binary. The plugin starts `nemo-relay mcp`, a lightweight Rust lifecycle client that -starts or reuses the shared gateway on `127.0.0.1:47632`. The client verifies -the gateway identity and effective persistent configuration, heartbeats it -while MCP stdio remains open, and performs one coordinated restart if the -gateway becomes unhealthy. Claude Code, Codex, and Hermes MCP clients share a -compatible gateway, and the gateway exits after the final client's idle -timeout. The MCP server advertises no tools. - -Claude Code starts plugin MCP servers asynchronously, so `SessionStart` can run -before the MCP connection is ready. The installed command hook handles this -race by starting or reusing the same gateway and retrying the original payload -once. MCP owns the normal session-long lifecycle; hook recovery preserves the -earliest event. +starts or reuses the shared gateway on `127.0.0.1:47632` immediately when the +MCP process launches. The client verifies the gateway identity and effective +persistent configuration, heartbeats it while MCP stdio remains open, and +performs one coordinated restart if the gateway becomes unhealthy. Claude +Code, Codex, and Hermes MCP clients share a compatible gateway, and the gateway +exits after the final client's idle timeout. The MCP server advertises no tools. + +The generated entry sets `alwaysLoad: true`, so Claude Code 2.1.121 or newer +waits for the MCP connection before session startup. The installed command +hook retains the same-gateway retry path for an unexpected outage after +startup. MCP owns the normal session-long lifecycle. Persistent plugin mode loads system and user Relay configuration only and uses the user configuration directory as its working directory. Use the transparent @@ -81,8 +80,8 @@ nemo-relay doctor --plugin claude-code nemo-relay uninstall claude-code ``` -Refer to [Plugin Installation](/nemo-relay-cli/plugin-installation) for install -directories, rollback behavior, and source marketplace notes. +Refer to [Coding Agent Installation](/nemo-relay-cli/plugin-installation) for +install directories, rollback behavior, and source marketplace notes. ## Shared Config diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index 1450ee23a..044afcab9 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -88,11 +88,12 @@ The installer creates a local marketplace, installs existing `nemo-relay` binary on `PATH` but does not install a plugin-local Relay binary. -The required plugin MCP process is a lightweight lifecycle client. It starts or -reuses a detached Rust `nemo-relay --bind 127.0.0.1:47632` sidecar and completes -MCP initialization only after `/healthz` confirms the Relay identity, version, -bootstrap protocol, and effective persistent configuration. The compatibility -check uses a one-way fingerprint that includes relevant environment values; it +The required plugin MCP process is a lightweight lifecycle client. As soon as +it launches, it starts or reuses a detached Rust +`nemo-relay --bind 127.0.0.1:47632` sidecar. It completes MCP initialization +only after `/healthz` confirms the Relay identity, version, bootstrap protocol, +and effective persistent configuration. The compatibility check uses a one-way +fingerprint that includes relevant environment values; it does not return credentials or configuration secrets. Dynamic plugin manifests, artifacts, and signatures included in this identity must be regular files. The complete activation snapshot, including adjacent runtime files and a copied @@ -170,8 +171,8 @@ To remove the plugin, enter: nemo-relay uninstall codex ``` -Refer to [Plugin Installation](/nemo-relay-cli/plugin-installation) for install -directories, shared-sidecar behavior, rollback behavior, and source +Refer to [Coding Agent Installation](/nemo-relay-cli/plugin-installation) for +install directories, shared-sidecar behavior, rollback behavior, and source marketplace notes. ## Configure Transparent Runs diff --git a/docs/nemo-relay-cli/hermes.mdx b/docs/nemo-relay-cli/hermes.mdx index 686924896..3ca244c0e 100644 --- a/docs/nemo-relay-cli/hermes.mdx +++ b/docs/nemo-relay-cli/hermes.mdx @@ -21,21 +21,24 @@ session, subagent, tool, and model lifecycle payloads. You do not need a separate service manager, wrapper process, Node.js runtime, or Python bootstrap component. -## Configure Persistent Capture +## Install Persistent Capture -Run the scoped setup flow: +Install the user-level integration: ```bash -nemo-relay config hermes +nemo-relay install hermes ``` +Installation requires Hermes Agent 0.18.2 or newer. Relay validates the Hermes +CLI before modifying configuration. + Relay preserves unrelated Hermes settings and updates the Relay-owned portions of the user configuration. Hermes reads this configuration from `$HERMES_HOME/config.yaml`, or `~/.hermes/config.yaml` when `HERMES_HOME` is unset. This location is user-owned even when you choose project-scoped Relay configuration. -Setup performs the following changes as one verified transaction: +Installation performs the following changes as one verified transaction: - Adds `mcp_servers.nemo-relay` with the canonical absolute `nemo-relay` executable and `mcp --agent hermes` arguments. @@ -52,30 +55,33 @@ If any write or post-write verification fails, Relay restores the original Hermes config, allowlist, and generation file. Concurrent setup operations are serialized so they cannot overwrite one another. -Run doctor to verify the MCP entry, all hook definitions, every trust record, -the generation fence, the Relay executable, and forwarded environment names: +Run integration doctor to verify the Hermes version, MCP entry, all hook +definitions, every trust record, the generation fence, the Relay executable, +and forwarded environment names: ```bash -nemo-relay doctor hermes +nemo-relay doctor --plugin hermes ``` If you add a Relay, OpenTelemetry, AWS, proxy, certificate, or -config-referenced credential variable later, rerun `nemo-relay config hermes` -to add its name to the Hermes MCP environment. Generated config stores -`${VARIABLE_NAME}` references, never secret values. +config-referenced credential variable later, run +`nemo-relay install hermes --force` to add its name to the Hermes MCP +environment. Generated config stores `${VARIABLE_NAME}` references, never +secret values. ## Gateway Lifecycle Hermes launches `nemo-relay mcp --agent hermes` as a long-lived stdio MCP -server. The MCP process does not expose tools. It waits for a valid MCP -`initialize` request before starting or acquiring the gateway, then completes -initialization only after Relay identity, version, bootstrap protocol, and -effective persistent configuration are verified. +server. The MCP process does not expose tools. It starts or acquires the gateway +immediately when the process launches and completes initialization only after +Relay identity, version, bootstrap protocol, and effective persistent +configuration are verified. Hermes starts MCP discovery in the background, so an early lifecycle hook can -arrive before MCP initialization completes. The installed hook command handles -that race: it attempts delivery, starts or reuses the same gateway under the -same startup lock after a connection failure, and retries the original payload +arrive before the MCP process finishes acquiring the gateway. The installed +hook command handles that race: it starts or reuses the same gateway under the +same startup lock before delivery. If delivery then fails before connecting, +the command performs one coordinated recovery and retries the original payload once. The payload is not reconstructed or replaced. Concurrent Hermes, Claude Code, and Codex MCP clients can share one compatible @@ -153,14 +159,15 @@ nemo-relay run --agent hermes --dry-run --print -- hermes ## Remove the Integration -Remove the Relay agent block and the Relay-owned Hermes MCP, hooks, trust, and -generation state with: +Remove the Relay-owned Hermes MCP, hooks, trust, and generation state with: ```bash -nemo-relay config --reset hermes +nemo-relay uninstall hermes ``` -Unrelated Hermes configuration remains unchanged. +Unrelated Hermes configuration remains unchanged. The interactive +`nemo-relay config hermes` and `nemo-relay config --reset hermes` paths remain +available when configuring Relay through the setup wizard. ## Validate End-to-End Capture @@ -179,8 +186,9 @@ It is intentionally outside mandatory Rust CI. ## Troubleshoot If Hermes reports that the MCP server failed to start, run -`nemo-relay doctor hermes` and inspect the sidecar log path named in the error. -A foreign listener on `127.0.0.1:47632` is rejected rather than adopted. +`nemo-relay doctor --plugin hermes` and inspect the sidecar log path named in +the error. A foreign listener on `127.0.0.1:47632` is rejected rather than +adopted. If lifecycle events appear but direct model spans do not, check whether the Hermes provider base URL points at Relay. If model spans attach to the parent diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index ff240aa9e..52b699233 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -1,43 +1,54 @@ --- -title: "Plugin Installation" -description: "Install and manage NeMo Relay host plugins for Claude Code and Codex." +title: "Coding Agent Installation" +description: "Install and manage persistent NeMo Relay integrations for Claude Code, Codex, and Hermes Agent." position: 3 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} -Install a host plugin when you want Claude Code or Codex to load NeMo Relay -through its normal plugin system instead of through a `nemo-relay` wrapper -command. +Install a persistent integration when you want Claude Code, Codex, or Hermes +Agent to load NeMo Relay without a `nemo-relay` wrapper command. Claude Code +and Codex use their normal plugin systems. Hermes uses the same native MCP +lifecycle through its user configuration without a marketplace plugin. -Hermes uses the same native MCP lifecycle without a marketplace plugin. Run -`nemo-relay config hermes` to merge the Relay MCP server, canonical hooks, and -exact hook trust into Hermes's user configuration. Refer to the -[Hermes Agent guide](/nemo-relay-cli/hermes). - -The installed plugin emits agent, subagent, tool, prompt, compaction, and stop -lifecycle signals. Model-provider routing sends LLM traffic through the local -NeMo Relay gateway. Hooks alone cannot capture complete LLM request and +The persistent integration emits agent, subagent, tool, prompt, compaction, and +stop lifecycle signals. Model-provider routing sends LLM traffic through the +local NeMo Relay gateway. Hooks alone cannot capture complete LLM request and response spans. +All three integrations use the same runtime shape: + +```text +Codex / Claude Code / Hermes Agent + -> Relay MCP lifecycle client + -> shared Relay Gateway + -> model provider +``` + +Each agent starts its own lightweight MCP stdio process. Those processes share +one compatible gateway. Generated lifecycle hooks use `hook-forward` to deliver +their canonical payloads to that same gateway. + ## Requirements Install `nemo-relay` and ensure that it is available on `PATH` or `%PATH%`. -The plugin installer does not download a second Relay binary, install a daemon, -or require a plugin-local executable. +The installer does not download a second Relay binary, install a daemon, or +require an integration-local executable. -The selected host CLI must also be available: +The selected coding-agent CLI must also be available at a supported version: -- `claude` for Claude Code plugin installation. -- `codex` for Codex plugin installation. +- Claude Code 2.1.121 or newer through `claude`. +- `codex-cli` 0.143.0 or newer through `codex`. +- Hermes Agent 0.18.2 or newer through `hermes`. -Codex plugin installation requires `codex-cli >= 0.143.0`. The installer checks -the version before modifying plugin or Codex configuration. +Relay checks the selected CLI version before modifying agent configuration. +Prerelease, malformed, and older versions are rejected consistently by install, +doctor, and transparent launch paths. -## Install Host Plugin +## Install a Persistent Integration -Run the command for the host plugin that you want to install: +Run the command for the coding-agent integration that you want to install: ```bash nemo-relay install claude-code @@ -47,14 +58,18 @@ nemo-relay install claude-code nemo-relay install codex ``` +```bash +nemo-relay install hermes +``` + Install every supported host detected on the machine: ```bash nemo-relay install all ``` -`install all` selects only hosts whose CLI is present. It fails if neither -Claude Code nor Codex is detected. +`install all` selects only supported agents whose CLI is present. It fails if +Claude Code, Codex, and Hermes Agent are all absent. Use `--dry-run` to inspect the generated marketplace paths and host commands without writing files or changing host configuration: @@ -63,8 +78,9 @@ without writing files or changing host configuration: nemo-relay install codex --dry-run ``` -Use `--install-dir` when you need a non-default marketplace location. The -default directory is platform-specific: +For Claude Code and Codex, use `--install-dir` when you need a non-default +marketplace location. Hermes always updates its user configuration. The default +plugin directory is platform-specific: | Platform | Default Plugin Install Directory | | --- | --- | @@ -79,8 +95,9 @@ actionable timeout instead of changing host-global plugin state concurrently. ## What Install Changes -`nemo-relay install` writes a local marketplace named `nemo-relay-local`, then -registers the generated `nemo-relay-plugin` package with the selected host. +For Claude Code and Codex, `nemo-relay install` writes a local marketplace named +`nemo-relay-local`, then registers the generated `nemo-relay-plugin` package +with the selected host. For Claude Code, install registers the local Claude marketplace, installs `nemo-relay-plugin@nemo-relay-local` at user scope, and enables provider routing @@ -105,12 +122,18 @@ undiscoverable `PostToolUseFailure`, `Notification`, or `SessionEnd` handlers. Upgrade removes legacy Relay groups from `~/.codex/hooks.json` while preserving unrelated hooks. +For Hermes, install transactionally merges the Relay MCP server, canonical +hooks, exact hook trust, and environment references into +`$HERMES_HOME/config.yaml`, or `~/.hermes/config.yaml` when `HERMES_HOME` is +unset. It preserves unrelated Hermes configuration. Refer to the +[Hermes Agent guide](/nemo-relay-cli/hermes). + Start a new Codex CLI process after installation. Restart the Codex desktop app if it was already running so it reloads the provider and hook configuration. Claude Code, Codex, and configured Hermes processes use the same -`nemo-relay mcp` lifecycle client. It starts or reuses a detached native -sidecar on `127.0.0.1:47632`, +`nemo-relay mcp` lifecycle client. It immediately starts or reuses a detached +native sidecar on `127.0.0.1:47632`, before reading MCP protocol frames, verifies Relay identity, version, protocol readiness, and effective user-level configuration, and heartbeats it every 30 seconds. Concurrent processes from any host share the gateway. After the final MCP client closes, the sidecar @@ -120,11 +143,12 @@ not succeed. A foreign process on the configured port is rejected with an actionable conflict instead of being accepted because it returned HTTP 200. Codex marks the MCP server as required, so the captured turn waits for verified -gateway readiness. Claude Code and Hermes start MCP servers asynchronously, so -their first lifecycle hook can run first. The hook shim closes that race by -starting or reusing the same gateway under the same startup lock and retrying -the original canonical payload once. The MCP server advertises no tools in any -host. +gateway readiness. Claude Code 2.1.121 or newer uses `alwaysLoad`, which blocks +session startup until the MCP connection is ready. Hermes starts MCP discovery +asynchronously, so its first lifecycle hook can still run first. The installed +`hook-forward` command closes that race by starting or reusing the same gateway +under the same startup lock and retrying the original canonical payload once. +The MCP server advertises no tools in any host. On Windows, Relay requests Job Object breakaway only when the host job permits it. If the host applies a restrictive job but permits nested jobs, Relay keeps @@ -136,10 +160,11 @@ guarantees. The compatibility fingerprint covers resolved provider, exporter, dynamic plugin, and relevant environment settings without exposing their values in the -health response. `nemo-relay install --force` generation-fences the -replacement MCP process and uses a private ownership token to stop a sidecar -owned by the replaced host when necessary; it does not terminate an unrelated -listener. +health response. For Claude Code and Codex, +`nemo-relay install --force` generation-fences the replacement MCP +process and uses a private ownership token to stop a sidecar owned by the +replaced host when necessary. It does not terminate an unrelated listener. +Hermes installation transactionally rotates its MCP generation fence. Persistent mode resolves only system and user Relay configuration and starts in the user configuration directory. Project `.nemo-relay` layers remain available @@ -151,11 +176,11 @@ observability configuration. Claude Code supplies its normal environment to plugin MCP processes. Hermes config stores `${VARIABLE_NAME}` references for the provider credentials, currently defined approved variables, and explicit credential names referenced by user observability configuration. Rerun -`nemo-relay config hermes` after adding one of those variables. +`nemo-relay install hermes --force` after adding one of those variables. ## Diagnose -Run the command for the installed host that you want to diagnose: +Run the command for the installed integration that you want to diagnose: ```bash nemo-relay doctor --plugin claude-code @@ -165,25 +190,33 @@ nemo-relay doctor --plugin claude-code nemo-relay doctor --plugin codex ``` +```bash +nemo-relay doctor --plugin hermes +``` + ```bash nemo-relay doctor --plugin all ``` -`nemo-relay doctor` includes every persistent host-plugin installation found in -the default platform install directory. It checks the generated marketplace and -plugin files, Relay binary and hook support, host registration, provider -routing, hooks, and sidecar readiness assumptions. For Codex, doctor also asks the -Codex app-server whether each exact Relay-generated hook is trusted and -enabled. JSON output includes `checks.codex_hooks_trusted` and a +`nemo-relay doctor` includes every persistent integration it finds. It checks +generated marketplace and plugin files where applicable, the Relay binary and +hook support, agent version, registration, provider routing, hooks, and sidecar +readiness assumptions. For Codex, doctor also asks the Codex app-server whether +each exact Relay-generated hook is trusted and enabled. JSON output includes +`checks.codex_hooks_trusted` and a `codex_hook_trust` object that groups trusted, untrusted, modified, disabled, missing, or duplicated required hooks. A stopped Codex sidecar is informational; the required plugin MCP starts it before the captured turn. Doctor also reports newly required forwarded environment names and recommends `nemo-relay install codex --force`. -For Claude Code, doctor also validates the generated MCP server and generation -marker. A stopped sidecar remains informational because the next MCP initialize -or hook retry starts it. +For Claude Code, doctor also validates version 2.1.121 or newer, the generated +`alwaysLoad` MCP server, and its generation marker. A stopped sidecar remains +informational because the next MCP process start or hook retry starts it. + +For Hermes, doctor validates Hermes Agent 0.18.2 or newer plus the generated MCP +entry, every hook and trust pair, its generation marker, and environment +references. Use the focused plugin doctor when diagnosing one host or an installation that uses a custom directory: @@ -192,14 +225,14 @@ uses a custom directory: nemo-relay doctor --plugin codex --install-dir /path/to/plugins ``` -If an installed host plugin is incomplete, doctor reports the failed check and -suggests `nemo-relay install --force`. Hosts without a persistent plugin -installation remain informational, so transparent-run setup does not require a -host plugin. +If an installed integration is incomplete, doctor reports the failed check and +suggests `nemo-relay install --force`. Agents without a persistent +installation remain informational, so transparent-run setup does not require +one. ## Uninstall -Run the command for the installed host plugin that you want to remove: +Run the command for the installed integration that you want to remove: ```bash nemo-relay uninstall claude-code @@ -209,6 +242,10 @@ nemo-relay uninstall claude-code nemo-relay uninstall codex ``` +```bash +nemo-relay uninstall hermes +``` + ```bash nemo-relay uninstall all ``` @@ -217,7 +254,23 @@ Uninstall removes Codex provider configuration and trust for the exact plugin hooks before unregistering the plugin, while Codex can still report its hook metadata. It then removes the host registration and marketplace. Claude Code provider routing is restored from the Relay backup. Unrelated user hooks and -configuration remain unchanged. +configuration remain unchanged. Hermes uninstall removes only Relay-owned MCP, +hook, trust, and generation state. + +## Migration from the Hidden Shim Command + +This release removes the internal `nemo-relay plugin-shim` command. Existing +generated installations should be refreshed with +`nemo-relay install --force`. Custom integrations must invoke +`nemo-relay hook-forward ` and pass the canonical lifecycle payload on +standard input. Persistent generated commands include the fixed gateway URL; +transparent wrappers provide `NEMO_RELAY_GATEWAY_URL` for their dynamic +gateway. + +Hermes setup is now available through the same public install, doctor, and +uninstall commands as Claude Code and Codex. `nemo-relay config hermes` remains +an interactive configuration path, but automation should use +`nemo-relay install hermes`. ## Source Marketplace Discovery @@ -228,7 +281,8 @@ validation: - `.agents/plugins/marketplace.json` Those manifests are useful when validating host plugin metadata from a source -checkout. For end-user setup, use `nemo-relay install `. It generates the -local marketplace, registers the host plugin, and performs the required -provider and hook setup. Source manifests do not provide Codex's complete -provider, environment-forwarding, and verified-trust workflow. +checkout. For end-user setup, use `nemo-relay install `. For Claude Code +and Codex, the command generates the local marketplace, registers the host +plugin, and performs the required provider and hook setup. Source manifests do +not provide Codex's complete provider, environment-forwarding, and +verified-trust workflow. diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 08758080f..3afb86cef 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -34,7 +34,7 @@ environment variables, or shared TOML config. host share one Rust gateway, subject to the Windows Job Object lifetime caveat below, with no wrapper, login item, launchd agent, systemd user service, scheduled task, or persistent supervisor. -- Hermes does not require a static marketplace bundle. `nemo-relay config +- Hermes does not require a static marketplace bundle. `nemo-relay install hermes` transactionally merges a native MCP lifecycle client, canonical hooks, and exact per-event trust into the user-owned Hermes config. @@ -69,47 +69,55 @@ nemo-relay doctor hermes --json The command is read-only: it reports missing ATIF directories, hook files, and agent commands instead of creating or patching them. -## Plugin Installation +## Persistent Integration Installation The Claude Code and Codex plugins are installed by the `nemo-relay` CLI. The CLI must already be installed and discoverable on `$PATH` or `%PATH%`; no separate npm installer, release bundle download, or plugin-local Relay binary is required. +Persistent installation and transparent launch require Claude Code 2.1.121 or +newer, `codex-cli` 0.143.0 or newer, or Hermes Agent 0.18.2 or newer for the +selected agent. + Each plugin MCP entry—and the equivalent Hermes `mcp_servers` entry—starts `nemo-relay mcp`, a lightweight client that starts or reuses a native -`nemo-relay --bind 127.0.0.1:47632` sidecar. Relay -detaches the sidecar when host policy permits. A restrictive Windows Job Object -keeps the sidecar scoped to that host job instead of failing bootstrap. -MCP initialization waits for Relay identity, version, and bootstrap-protocol -readiness. Concurrent Codex, Claude Code, and Hermes processes share the -gateway and heartbeat it while their MCP stdio connections remain open; the -gateway exits after the final client's idle timeout. Codex requires MCP -initialization before the captured turn. Claude Code and Hermes start MCP -servers asynchronously, so their command hooks also start or reuse the same -gateway and retry the original payload when they win the startup race. The MCP -client advertises no tools. - -Persistent plugin mode loads system and user Relay configuration only and starts -the sidecar from the user configuration directory. Relative exporter paths are +`nemo-relay --bind 127.0.0.1:47632` sidecar. Relay detaches the sidecar when +host policy permits. A restrictive Windows Job Object +keeps the sidecar scoped to that host job instead of failing bootstrap. The MCP +process acquires the gateway immediately, before reading protocol frames, and +returns its initialization response only after Relay identity, version, and +bootstrap-protocol readiness are verified. Concurrent Codex, Claude Code, and +Hermes processes share the gateway and heartbeat it while their MCP stdio +connections remain open; the gateway exits after the final client's idle +timeout. Codex requires MCP initialization before the captured turn. Claude +Code marks Relay MCP as `alwaysLoad`, so it also waits for the connection before +session startup. Hermes starts MCP discovery asynchronously, so its command +hook retains the same-gateway recovery path for an early hook. The MCP client +advertises no tools. + +Persistent mode loads system and user Relay configuration only and starts the +sidecar from the user configuration directory. Relative exporter paths are therefore stable across projects. Codex's generated MCP manifest forwards approved provider, Relay, OpenTelemetry, AWS, proxy, certificate, and config-referenced credential environment names without storing their values; Claude Code supplies its normal MCP process environment. Use transparent `nemo-relay run` for project-specific configuration. -Install the local host marketplaces with: +Install the persistent integrations with: ```bash nemo-relay install claude-code nemo-relay install codex +nemo-relay install hermes nemo-relay install all ``` -`nemo-relay install` writes local marketplace files, registers the selected host -plugin, and performs the required host provider and hook setup. Use -`nemo-relay uninstall ` to roll back and `nemo-relay doctor --plugin -` to check an installed plugin. +For Claude Code and Codex, `nemo-relay install` writes local marketplace files, +registers the selected host plugin, and performs the required provider and hook +setup. For Hermes, it updates only Relay-owned user configuration. Use +`nemo-relay uninstall ` to roll back and +`nemo-relay doctor --plugin ` to check an installed integration. If you are using Codex, add this repository as a marketplace for source/dev discovery: @@ -120,7 +128,7 @@ codex plugin add nemo-relay-plugin@nemo-relay ``` That path relies on `nemo-relay` being available on `PATH`; source plugin hooks -invoke `nemo-relay plugin-shim hook codex` directly. +invoke `nemo-relay hook-forward codex` directly. Use the source marketplace path for discovery or manifest validation. Use `nemo-relay install codex` for complete provider routing, environment @@ -135,13 +143,13 @@ claude plugin install nemo-relay-plugin@nemo-relay --scope user ``` That path reads `.claude-plugin/marketplace.json` from the repository. Source -plugin hooks invoke `nemo-relay plugin-shim hook claude` directly. Use +plugin hooks invoke `nemo-relay hook-forward claude` directly. Use `nemo-relay install claude-code` for the complete provider-routing setup. -Hermes persistent setup is user-level: +Hermes persistent installation is user-level: ```bash -nemo-relay config hermes +nemo-relay install hermes ``` It writes the MCP server and trusted hooks to `$HERMES_HOME/config.yaml` or @@ -154,7 +162,7 @@ project `.nemo-relay/config.toml`, then `$XDG_CONFIG_HOME/nemo-relay/config.toml` or `~/.config/nemo-relay/config.toml`. -That layering applies to transparent runs. Persistent plugin mode skips the +That layering applies to transparent runs. Persistent mode skips the project layer and merges only system and user configuration. ```toml @@ -201,24 +209,23 @@ input. The wrapper injects `NEMO_RELAY_GATEWAY_URL` so the command reaches the ephemeral per-run gateway. Persistent Claude Code and Codex hooks, and Hermes hooks in both modes, call -`nemo-relay plugin-shim hook `. During a transparent Hermes run, the -same canonical command prefers the wrapper's dynamic gateway URL. Otherwise, -it starts or reuses the fixed shared gateway. +`nemo-relay hook-forward `. During a transparent Hermes run, the same +canonical command prefers the wrapper's dynamic gateway URL. Otherwise, it +preflights, starts, or recovers the fixed shared gateway. + For Codex, the installed plugin file is the sole persistent Relay hook source; -installation does not add Relay groups to `~/.codex/hooks.json`. The shim -forwards each canonical payload to the verified shared sidecar. +installation does not add Relay groups to `~/.codex/hooks.json`. Since hook forwarding fails open by default, gateway or sidecar outages do not block the coding agent. The hook command exits successfully after logging the forwarding problem, so the host agent can continue even though that hook payload may be missing from telemetry. For wrapper-generated `hook-forward` -commands, add -`--fail-closed` when policy requires hook delivery to block the agent. For -plugin shim hooks, set `NEMO_RELAY_FAIL_CLOSED=1` in the hook execution -environment. In that mode, forwarding failures return a non-zero hook command -status to the host. +commands, add `--fail-closed` when policy requires hook delivery to block the +agent. For generated persistent hooks, set `NEMO_RELAY_FAIL_CLOSED=1` in the hook +execution environment. In that mode, forwarding failures return a non-zero +hook command status to the host. -Useful wrapper options: +Useful `hook-forward` options: - `--session-metadata ''` adds structured metadata to the agent begin event. diff --git a/integrations/coding-agents/claude-code/.mcp.json b/integrations/coding-agents/claude-code/.mcp.json index fbfd83abf..36c8e4ef4 100644 --- a/integrations/coding-agents/claude-code/.mcp.json +++ b/integrations/coding-agents/claude-code/.mcp.json @@ -9,7 +9,8 @@ ], "env": { "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632" - } + }, + "alwaysLoad": true } } } diff --git a/integrations/coding-agents/claude-code/README.md b/integrations/coding-agents/claude-code/README.md index 02ccdf8ce..36dfeaf98 100644 --- a/integrations/coding-agents/claude-code/README.md +++ b/integrations/coding-agents/claude-code/README.md @@ -18,7 +18,7 @@ same local hook and gateway controls as Claude Code. - `.claude-plugin/plugin.json` describes the Claude Code hook package. - `.mcp.json` starts the native `nemo-relay mcp` lifecycle client. - `hooks/hooks.json` contains hook entries that run - `nemo-relay plugin-shim hook claude`. + `nemo-relay hook-forward claude`. ## Captured Events @@ -28,10 +28,11 @@ The bundle forwards `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `PreCompact`, and `PostCompact` as scope, tool, mark, or private LLM correlation events. -The bundle requires Claude Code 2.1.116 or newer. Older versions do not have -`UserPromptExpansion` in their hook-event whitelist and reject the entire -plugin hook configuration, so no relay hooks load. `nemo-relay doctor` reports -this condition. +The bundle requires Claude Code 2.1.121 or newer. That version provides the +`alwaysLoad` MCP startup barrier used to make Relay ready before session hooks. +Older versions before 2.1.116 also reject `UserPromptExpansion` in the plugin +hook configuration. Installation fails before changing host state when the +version is too old, and `nemo-relay doctor` reports the required upgrade. Claude Code observability is turn-oriented. A multi-turn session can produce one root `claude-code-turn` span or ATIF trajectory per user turn. That is expected @@ -170,16 +171,16 @@ nemo-relay install claude-code `nemo-relay install claude-code` writes a local Claude Code marketplace, installs `nemo-relay-plugin` at user scope, and enables Claude Code provider -routing through NeMo Relay. Its plugin MCP process starts or reuses the shared -native gateway on `127.0.0.1:47632` and heartbeats it while MCP stdio remains -open. Codex, Claude Code, and configured Hermes MCP clients can share that -gateway. +routing through NeMo Relay. Its plugin MCP process immediately starts or reuses +the shared native gateway on `127.0.0.1:47632` and heartbeats it while MCP stdio +remains open. Codex, Claude Code, and configured Hermes MCP clients can share +that gateway. -Claude Code starts plugin MCP servers asynchronously. The `SessionStart` hook -can therefore run first. The command hook handles that race by starting or -reusing the same gateway under the same startup lock and retrying the original -canonical payload once. The MCP server advertises no tools and does not add -tool definitions to Claude's context. +The generated MCP entry sets `alwaysLoad: true`. Claude Code therefore waits +for the MCP connection during startup, while `nemo-relay mcp` starts or reuses +the gateway immediately when its process launches. The command hook retains a +same-gateway recovery path for an unexpected runtime outage. The MCP server +advertises no tools and does not add tool definitions to Claude's context. No separate provider-routing command is required when installing through `nemo-relay install`. @@ -211,7 +212,7 @@ claude plugin install nemo-relay-plugin@nemo-relay --scope user That path reads `.claude-plugin/marketplace.json` from the repository and installs this Claude Code plugin from `integrations/coding-agents/claude-code`. The source plugin starts `nemo-relay mcp` and its hooks invoke -`nemo-relay plugin-shim hook claude` directly. Use `nemo-relay install +`nemo-relay hook-forward claude` directly. Use `nemo-relay install claude-code` for complete provider routing and generation-fenced upgrades. Create a local Claude Code marketplace and copy the plugin under that @@ -263,7 +264,7 @@ claude --plugin-dir "$PLUGIN_ROOT" ``` Hook commands in the source `hooks/hooks.json` template use -`nemo-relay plugin-shim hook claude`, so source marketplace installs rely on +`nemo-relay hook-forward claude`, so source marketplace installs rely on the same `nemo-relay` executable available on `PATH`. If you set up the marketplace manually for development, use the top-level @@ -286,8 +287,9 @@ Start a normal Claude Code session: claude ``` -The installed MCP client normally starts the Relay sidecar. The hook shim is the -startup-race fallback, and provider traffic is routed through +The installed MCP client starts the Relay sidecar before Claude Code proceeds +with session startup. The hook forwarding command retains one coordinated +outage-recovery attempt, and provider traffic is routed through `ANTHROPIC_BASE_URL=http://127.0.0.1:47632`. To upgrade manually, replace the plugin directory contents with the new package, diff --git a/integrations/coding-agents/claude-code/hooks/hooks.json b/integrations/coding-agents/claude-code/hooks/hooks.json index b43125111..9eb5f6ef7 100644 --- a/integrations/coding-agents/claude-code/hooks/hooks.json +++ b/integrations/coding-agents/claude-code/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -27,7 +27,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -39,7 +39,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -51,7 +51,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -63,7 +63,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -75,7 +75,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -86,7 +86,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -97,7 +97,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -108,7 +108,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -119,7 +119,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -130,7 +130,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -141,7 +141,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -152,7 +152,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index 1102cd56c..d0fd9b9d7 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -23,7 +23,7 @@ used by the installer. - `.mcp.json` starts the native `nemo-relay mcp` lifecycle client and requires successful gateway initialization. - `hooks/hooks.json` contains Codex hook entries that run - `nemo-relay plugin-shim hook codex`. + `nemo-relay hook-forward codex`. - `nemo-relay install codex` creates the local marketplace, installs the plugin, and persists Codex provider and exact plugin-hook trust using `nemo-relay` from `PATH`. @@ -51,7 +51,7 @@ the sole persistent Relay hook source. It does not add Relay groups to Persistent installation opens the stable Codex app-server interface and selects only hooks whose source is `plugin`, plugin ID is `nemo-relay-plugin@nemo-relay-local`, and command exactly matches the generated -canonical Relay shim command. It requires exactly one handler for each event +canonical Relay forwarding command. It requires exactly one handler for each event in the complete 10-event supported set listed above. Unrelated user, project, and plugin hooks are never trusted. If installation @@ -293,7 +293,7 @@ codex plugin add nemo-relay-plugin@nemo-relay That path reads `.agents/plugins/marketplace.json` from the repository and installs this Codex plugin from `integrations/coding-agents/codex`. Source hooks -invoke `nemo-relay plugin-shim hook codex` directly. +invoke `nemo-relay hook-forward codex` directly. Treat the source marketplace path as discovery or manifest validation. Use `nemo-relay install codex` for the complete provider, environment-forwarding, diff --git a/integrations/coding-agents/codex/hooks/hooks.json b/integrations/coding-agents/codex/hooks/hooks.json index d4048c37a..61a5ea9b3 100644 --- a/integrations/coding-agents/codex/hooks/hooks.json +++ b/integrations/coding-agents/codex/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -29,7 +29,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -41,7 +41,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -53,7 +53,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -64,7 +64,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -75,7 +75,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -86,7 +86,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -97,7 +97,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] @@ -108,7 +108,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", "timeout": 30 } ] diff --git a/justfile b/justfile index 2a351d600..4bdca900a 100644 --- a/justfile +++ b/justfile @@ -1056,15 +1056,15 @@ clean: examples/rust-native-plugin/target \ target -# Opt-in: requires codex-cli 0.143+ and is intentionally outside test-rust/CI. +# Opt-in: requires a supported Codex installation and is intentionally outside test-rust/CI. test-codex-plugin-e2e: ./scripts/test-codex-plugin-e2e.sh -# Opt-in: requires Claude Code 2.1.116+ and is intentionally outside test-rust/CI. +# Opt-in: requires a supported Claude Code installation and is intentionally outside test-rust/CI. test-claude-plugin-e2e: ./scripts/test-claude-plugin-e2e.sh -# Opt-in: requires Hermes Agent with shell hooks and stdio MCP support; outside test-rust/CI. +# Opt-in: requires a supported Hermes Agent installation and is intentionally outside test-rust/CI. test-hermes-mcp-e2e: ./scripts/test-hermes-mcp-e2e.sh diff --git a/scripts/test-claude-plugin-e2e.sh b/scripts/test-claude-plugin-e2e.sh index 5c17f68ac..89c5ae9ac 100755 --- a/scripts/test-claude-plugin-e2e.sh +++ b/scripts/test-claude-plugin-e2e.sh @@ -7,20 +7,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" if ! command -v claude >/dev/null 2>&1; then - echo "SKIP: claude is not installed; Claude Code plugin E2E requires 2.1.116+" - exit 0 -fi - -claude_version="$(claude --version 2>/dev/null || true)" -if ! python3 - "$claude_version" <<'PY' -import re -import sys - -match = re.search(r"(\d+)\.(\d+)\.(\d+)", sys.argv[1]) -raise SystemExit(0 if match and tuple(map(int, match.groups())) >= (2, 1, 116) else 1) -PY -then - echo "SKIP: Claude Code plugin E2E requires 2.1.116+; found: ${claude_version:-unknown}" + echo "SKIP: claude is not installed" exit 0 fi @@ -132,7 +119,7 @@ assert server["env"]["NEMO_RELAY_GATEWAY_BIND"] == "127.0.0.1:47632", server generation = Path(server["env"]["NEMO_RELAY_MCP_GENERATION_FILE"]) assert generation == plugin_root / ".nemo-relay-generation", generation assert generation.is_file(), generation -assert "alwaysLoad" not in server, server +assert server["alwaysLoad"] is True, server PY wait_for_relay_port_release() { diff --git a/scripts/test-codex-plugin-e2e.sh b/scripts/test-codex-plugin-e2e.sh index bcd64b228..f39a0aef5 100755 --- a/scripts/test-codex-plugin-e2e.sh +++ b/scripts/test-codex-plugin-e2e.sh @@ -7,20 +7,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" if ! command -v codex >/dev/null 2>&1; then - echo "SKIP: codex is not installed; Codex plugin E2E requires codex-cli 0.143+" - exit 0 -fi - -codex_version="$(codex --version 2>/dev/null || true)" -if ! python3 - "$codex_version" <<'PY' -import re -import sys - -match = re.search(r"(\d+)\.(\d+)\.(\d+)", sys.argv[1]) -raise SystemExit(0 if match and tuple(map(int, match.groups())) >= (0, 143, 0) else 1) -PY -then - echo "SKIP: Codex plugin E2E requires codex-cli 0.143+; found: ${codex_version:-unknown}" + echo "SKIP: codex is not installed" exit 0 fi diff --git a/scripts/test-hermes-mcp-e2e.sh b/scripts/test-hermes-mcp-e2e.sh index 5811b64b6..e27d6e88c 100755 --- a/scripts/test-hermes-mcp-e2e.sh +++ b/scripts/test-hermes-mcp-e2e.sh @@ -12,11 +12,6 @@ if ! command -v hermes >/dev/null 2>&1; then echo "SKIP: hermes is not installed" exit 0 fi -if ! hermes mcp --help >/dev/null 2>&1 || ! hermes hooks --help >/dev/null 2>&1; then - echo "SKIP: installed Hermes does not provide stdio MCP and shell-hook configuration" - exit 0 -fi - cargo build -p nemo-relay-cli --bin nemo-relay work="$(mktemp -d)" @@ -135,8 +130,8 @@ filename = "events.jsonl" mode = "append" EOF -nemo-relay plugin-shim install hermes -nemo-relay doctor hermes --json >"$work/doctor.json" +nemo-relay install hermes +nemo-relay doctor --plugin hermes --json >"$work/doctor.json" python3 - "$HERMES_HOME" "$work/doctor.json" "$repo_root/target/debug/nemo-relay" <<'PY' import json @@ -159,14 +154,14 @@ allowlist = json.loads((home / "shell-hooks-allowlist.json").read_text()) commands = { entry["command"] for entry in allowlist["approvals"] - if "plugin-shim hook hermes" in entry.get("command", "") + if "hook-forward hermes" in entry.get("command", "") } assert len(commands) == 1, commands command = commands.pop() approvals = [entry for entry in allowlist["approvals"] if entry.get("command") == command] assert len(approvals) == 13, approvals assert len({entry["event"] for entry in approvals}) == 13, approvals -assert config.count("plugin-shim hook hermes") == 13, config +assert config.count("hook-forward hermes") == 13, config doctor = json.loads(doctor_path.read_text()) hermes = next(agent for agent in doctor["agents"] if agent["name"] == "hermes") From 2ccaac338681a3d250df63820487c9634b71f6bb Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sat, 11 Jul 2026 09:26:34 -0400 Subject: [PATCH 007/102] fix(cli): harden persistent agent bootstrap Signed-off-by: Will Killian --- crates/cli/src/doctor.rs | 7 +- crates/cli/src/hermes.rs | 131 +++++++++++----- crates/cli/src/hermes/config.rs | 13 +- crates/cli/src/installer.rs | 28 ++-- crates/cli/src/launcher.rs | 116 ++++++++++++-- crates/cli/src/plugin_install/mod.rs | 148 ++++++++++++++---- crates/cli/tests/cli_tests.rs | 75 ++++++++- crates/cli/tests/coverage/doctor_tests.rs | 6 +- crates/cli/tests/coverage/hermes_tests.rs | 118 +++++++++++++- crates/cli/tests/coverage/installer_tests.rs | 18 +-- crates/cli/tests/coverage/launcher_tests.rs | 61 ++++++++ .../tests/coverage/plugin_install_tests.rs | 90 +++++++++++ docs/nemo-relay-cli/hermes.mdx | 4 + docs/nemo-relay-cli/plugin-installation.mdx | 34 +++- scripts/test-claude-plugin-e2e.sh | 1 + scripts/test-codex-plugin-e2e.sh | 2 +- scripts/test-hermes-mcp-e2e.sh | 1 + 17 files changed, 721 insertions(+), 132 deletions(-) diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index 8447934e5..3b9a3903f 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -627,6 +627,9 @@ async fn probe_version(binary: &Path) -> Option { .await .ok()? .ok()?; + if !output.status.success() { + return None; + } let stdout = String::from_utf8_lossy(&output.stdout); let first_line = stdout.lines().next()?.trim(); if first_line.is_empty() { @@ -1499,9 +1502,9 @@ fn format_human_agents(out: &mut String, report: &DoctorReport) { } fn format_human_host_plugins(out: &mut String, report: &DoctorReport) { - out.push_str(" Host plugins\n"); + out.push_str(" Persistent integrations\n"); if report.host_plugins.is_empty() { - out.push_str(" · none installed; run `nemo-relay install ` to enable persistent host plugins\n"); + out.push_str(" · none installed; run `nemo-relay install ` to enable one\n"); } else { for plugin in &report.host_plugins { out.push_str(&format!( diff --git a/crates/cli/src/hermes.rs b/crates/cli/src/hermes.rs index 87c6b190e..600dc6724 100644 --- a/crates/cli/src/hermes.rs +++ b/crates/cli/src/hermes.rs @@ -71,39 +71,78 @@ pub(crate) fn install_persistent(config: &Path, relay: &Path) -> Result bool { PersistentPaths::for_config(config.to_path_buf()) - .is_ok_and(|paths| paths.all().iter().any(|path| path.exists())) + .ok() + .and_then(|paths| persistent_paths_have_managed_state(&paths).ok()) + .unwrap_or(false) } pub(crate) fn uninstall_persistent(config: &Path) -> Result, CliError> { let paths = PersistentPaths::for_config(config.to_path_buf())?; - if paths.all().iter().all(|path| !path.exists()) { + if !persistent_paths_have_managed_state(&paths)? { return Ok(Vec::new()); } let _lock = acquire_install_lock(&paths.config, INSTALL_LOCK_TIMEOUT).map_err(CliError::Install)?; let _allowlist_lock = acquire_allowlist_lock(&paths.allowlist, INSTALL_LOCK_TIMEOUT) .map_err(CliError::Install)?; + if !persistent_paths_have_managed_state(&paths)? { + return Ok(Vec::new()); + } uninstall_persistent_with(paths, atomic_write) } +fn persistent_paths_have_managed_state(paths: &PersistentPaths) -> Result { + if paths.generation.exists() { + return Ok(true); + } + if let Some(raw) = read_optional_utf8(&paths.config)? { + let config = parse_yaml_object(Some(&raw), "Hermes config")?; + if config_has_managed_state(&config) { + return Ok(true); + } + } + if let Some(raw) = read_optional_utf8(&paths.allowlist)? { + let allowlist = parse_json_object(Some(&raw), "Hermes shell-hook allowlist")?; + if allowlist_has_managed_state(&allowlist) { + return Ok(true); + } + } + Ok(false) +} + +fn config_has_managed_state(config: &Value) -> bool { + config + .get("mcp_servers") + .and_then(|servers| servers.get(MCP_SERVER_NAME)) + .is_some_and(is_managed_mcp_server) + || config + .get("hooks") + .and_then(Value::as_object) + .into_iter() + .flat_map(Map::values) + .filter_map(Value::as_array) + .flatten() + .filter_map(|entry| entry.get("command").and_then(Value::as_str)) + .any(is_managed_hook_command) +} + +fn allowlist_has_managed_state(allowlist: &Value) -> bool { + allowlist + .get("approvals") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|entry| entry.get("command").and_then(Value::as_str)) + .any(is_managed_hook_command) +} + pub(crate) fn diagnose_persistent(config_path: &Path) -> Result { let paths = PersistentPaths::for_config(config_path.to_path_buf()) .map_err(|error| error.to_string())?; let raw = fs::read_to_string(&paths.config) .map_err(|error| format!("failed to read {}: {error}", paths.config.display()))?; let config = parse_yaml_object(Some(&raw), "Hermes config").map_err(|e| e.to_string())?; - let server = config - .pointer("/mcp_servers/nemo-relay") - .ok_or_else(|| "Hermes MCP server `nemo-relay` is missing".to_string())?; - if !is_managed_mcp_server(server) { - return Err("Hermes MCP server `nemo-relay` is not a managed Relay MCP client".into()); - } - let relay = PathBuf::from( - server - .get("command") - .and_then(Value::as_str) - .expect("managed MCP server has a string command"), - ); + let relay = relay_executable_from_config(&config)?; if !relay_is_executable(&relay) { return Err(format!( "configured nemo-relay executable is missing or not executable at {}", @@ -114,7 +153,7 @@ pub(crate) fn diagnose_persistent(config_path: &Path) -> Result verify_hook_definitions(&config, &command)?; verify_trust(&paths.allowlist, &command)?; - let mcp_env = server + let mcp_env = config["mcp_servers"][MCP_SERVER_NAME] .get("env") .and_then(Value::as_object) .ok_or_else(|| "Hermes Relay MCP environment is missing".to_string())?; @@ -157,6 +196,39 @@ pub(crate) fn diagnose_persistent(config_path: &Path) -> Result )) } +/// Returns the exact Relay binary configured for Hermes's managed MCP client. +/// +/// Doctor uses this path instead of the currently running binary so it verifies the executable +/// that Hermes will actually launch. +pub(crate) fn configured_relay_executable(config_path: &Path) -> Result { + let raw = fs::read_to_string(config_path) + .map_err(|error| format!("failed to read {}: {error}", config_path.display()))?; + let config = parse_yaml_object(Some(&raw), "Hermes config").map_err(|e| e.to_string())?; + let relay = relay_executable_from_config(&config)?; + if !relay_is_executable(&relay) { + return Err(format!( + "configured nemo-relay executable is missing or not executable at {}", + relay.display() + )); + } + Ok(relay) +} + +fn relay_executable_from_config(config: &Value) -> Result { + let server = config + .pointer("/mcp_servers/nemo-relay") + .ok_or_else(|| "Hermes MCP server `nemo-relay` is missing".to_string())?; + if !is_managed_mcp_server(server) { + return Err("Hermes MCP server `nemo-relay` is not a managed Relay MCP client".into()); + } + Ok(PathBuf::from( + server + .get("command") + .and_then(Value::as_str) + .expect("managed MCP server has a string command"), + )) +} + fn install_persistent_with( paths: PersistentPaths, relay: &Path, @@ -371,37 +443,14 @@ fn verify_uninstall(paths: &PersistentPaths) -> Result<(), String> { } if let Some(raw) = read_optional_utf8(&paths.config).map_err(|error| error.to_string())? { let config = parse_yaml_object(Some(&raw), "Hermes config").map_err(|e| e.to_string())?; - if config - .get("mcp_servers") - .and_then(|servers| servers.get(MCP_SERVER_NAME)) - .is_some_and(is_managed_mcp_server) - { - return Err("managed Hermes Relay MCP server still exists".into()); - } - if config - .get("hooks") - .and_then(Value::as_object) - .into_iter() - .flat_map(Map::values) - .filter_map(Value::as_array) - .flatten() - .filter_map(|entry| entry.get("command").and_then(Value::as_str)) - .any(is_managed_hook_command) - { - return Err("managed Hermes Relay hook still exists".into()); + if config_has_managed_state(&config) { + return Err("managed Hermes Relay config still exists".into()); } } if let Some(raw) = read_optional_utf8(&paths.allowlist).map_err(|error| error.to_string())? { let allowlist = parse_json_object(Some(&raw), "Hermes shell-hook allowlist") .map_err(|e| e.to_string())?; - if allowlist - .get("approvals") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|entry| entry.get("command").and_then(Value::as_str)) - .any(is_managed_hook_command) - { + if allowlist_has_managed_state(&allowlist) { return Err("managed Hermes Relay trust approval still exists".into()); } } diff --git a/crates/cli/src/hermes/config.rs b/crates/cli/src/hermes/config.rs index eaa139f4f..ec3fcd980 100644 --- a/crates/cli/src/hermes/config.rs +++ b/crates/cli/src/hermes/config.rs @@ -34,7 +34,11 @@ pub(crate) fn transparent_config(existing: &str, relay: &Path) -> Result Result { let mut root = parse_yaml_object(existing, "Hermes config")?; + if let Some(server) = root.pointer(&format!("/mcp_servers/{MCP_SERVER_NAME}")) + && !is_managed_mcp_server(server) + { + return Err(CliError::Install(format!( + "Hermes MCP server `{MCP_SERVER_NAME}` already exists and is not managed by Relay; rename or remove it before installing the Relay integration" + ))); + } strip_managed_hooks(&mut root)?; root = merge_hooks(root, hermes_hooks(command))?; let servers = object_field_mut(&mut root, "mcp_servers", "mcp_servers")?; diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 4ba3d1735..0fc430001 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -108,11 +108,7 @@ pub(crate) async fn hook_forward(command: HookForwardCommand) -> Result<(), CliE return handle_hook_error(error, fail_closed); } let mut response = send_hook_forward_request(&command, &url, input.clone()).await?; - if response - .as_ref() - .is_err_and(|error| error.is_connect() || error.is_timeout()) - && destination.recover - { + if response.as_ref().is_err_and(reqwest::Error::is_connect) && destination.recover { let launch = recovery .as_ref() .expect("recoverable destinations have a recovery plan"); @@ -175,25 +171,15 @@ struct HookDestination { // replace it with a persistent sidecar. fn hook_destination(command: &HookForwardCommand) -> HookDestination { resolve_hook_destination( - command.agent, command.gateway_url.clone(), std::env::var("NEMO_RELAY_GATEWAY_URL").ok(), ) } fn resolve_hook_destination( - agent: CodingAgent, command_url: Option, environment_url: Option, ) -> HookDestination { - if agent == CodingAgent::Hermes - && let Some(gateway_url) = environment_url - { - return HookDestination { - gateway_url, - recover: false, - }; - } if let Some(gateway_url) = command_url { return HookDestination { gateway_url, @@ -236,6 +222,8 @@ async fn send_hook_forward_request( input: String, ) -> Result, CliError> { Ok(reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) .timeout(HOOK_FORWARD_TIMEOUT) .build()? .post(url) @@ -351,6 +339,16 @@ pub(crate) fn persistent_hook_forward_command(relay: &Path, agent: CodingAgent) persistent_hook_forward_command_for_platform(relay, agent, cfg!(windows)) } +/// Canonical transparent hook command. The launched agent receives its dynamic gateway through +/// `NEMO_RELAY_GATEWAY_URL`, so the command must not persist a fixed endpoint. +pub(crate) fn transparent_hook_forward_command(relay: &Path, agent: CodingAgent) -> String { + format!( + "{} hook-forward {}", + crate::plugin_host::shell_quote_for_platform(relay, cfg!(windows)), + agent.as_arg() + ) +} + pub(crate) fn persistent_hook_forward_command_for_platform( relay: &Path, agent: CodingAgent, diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index 8baf76177..85af8fd08 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -112,8 +112,8 @@ impl TransparentRun { crate::plugins::lifecycle::active_dynamic_plugin_components(explicit_config, &resolved)? }; let (agent, argv) = resolve_agent_and_argv(&command, &resolved.agents)?; - if !dry_run { - validate_agent_version(agent, &argv[0]).await?; + if !dry_run && let Some(probe) = version_probe_argv(agent, &argv) { + validate_agent_version(agent, &probe).await?; } let listener = TcpListener::bind("127.0.0.1:0").await?; let address = listener.local_addr()?; @@ -234,10 +234,21 @@ const fn default_command_for(agent: CodingAgent) -> &'static str { agent.executable() } -async fn validate_agent_version(agent: CodingAgent, executable: &str) -> Result<(), CliError> { - let mut command = Command::new(executable); +/// Builds a version probe that preserves wrappers such as `npx codex` or `mise exec -- codex`. +/// Opaque wrappers remain supported: when the configured argv never names the selected host, +/// installation and doctor retain version enforcement while transparent launch skips this probe. +fn version_probe_argv(agent: CodingAgent, argv: &[String]) -> Option> { + let agent_index = argv + .iter() + .position(|argument| CodingAgent::infer(argument) == Some(agent))?; + let mut probe = argv[..=agent_index].to_vec(); + probe.push("--version".into()); + Some(probe) +} + +async fn validate_agent_version(agent: CodingAgent, probe: &[String]) -> Result<(), CliError> { + let mut command = command_from_argv(probe); command - .arg("--version") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -246,7 +257,8 @@ async fn validate_agent_version(agent: CodingAgent, executable: &str) -> Result< .await .map_err(|_| { CliError::Launch(format!( - "timed out while checking {}; NeMo Relay requires {}", + "timed out while running version probe {:?} for {}; NeMo Relay requires {}", + probe, agent.label(), agent.version_requirement() )) @@ -254,7 +266,8 @@ async fn validate_agent_version(agent: CodingAgent, executable: &str) -> Result< if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(CliError::Launch(format!( - "`{executable} --version` failed with {}{}", + "version probe {:?} failed with {}{}", + probe, output.status, if stderr.trim().is_empty() { String::new() @@ -270,6 +283,92 @@ async fn validate_agent_version(agent: CodingAgent, executable: &str) -> Result< .map_err(CliError::Launch) } +/// Creates an async child command from the resolved argv. Windows command scripts require an +/// explicit `cmd.exe` invocation; using the same builder for probes and the real launch keeps npm +/// shims such as `codex.cmd` and `claude.cmd` consistent. +fn command_from_argv(argv: &[String]) -> Command { + debug_assert!(!argv.is_empty()); + #[cfg(windows)] + { + let program = resolve_windows_program(&argv[0]); + if is_windows_command_script(&program) { + let mut command = Command::new( + std::env::var_os("COMSPEC").unwrap_or_else(|| std::ffi::OsString::from("cmd.exe")), + ); + command + .args(["/d", "/s", "/c"]) + .arg(windows_command_line(&program, &argv[1..])); + return command; + } + let mut command = Command::new(program); + command.args(&argv[1..]); + return command; + } + #[cfg(not(windows))] + { + let mut command = Command::new(&argv[0]); + command.args(&argv[1..]); + command + } +} + +#[cfg(windows)] +fn resolve_windows_program(program: &str) -> PathBuf { + let path = Path::new(program); + let directories = if path.components().count() > 1 || path.is_absolute() { + vec![PathBuf::new()] + } else { + std::env::var_os("PATH") + .as_deref() + .map(std::env::split_paths) + .into_iter() + .flatten() + .collect() + }; + let extensions = if path.extension().is_none() { + std::env::var("PATHEXT") + .unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".into()) + .split(';') + .map(str::to_string) + .collect::>() + } else { + vec![String::new()] + }; + for directory in directories { + for extension in &extensions { + let candidate = directory.join(format!("{program}{extension}")); + if candidate.is_file() { + return candidate; + } + } + } + path.to_path_buf() +} + +#[cfg(windows)] +fn is_windows_command_script(program: &Path) -> bool { + program + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat") + }) +} + +#[cfg(any(windows, test))] +fn windows_command_line(program: &Path, args: &[String]) -> String { + std::iter::once(crate::plugin_host::shell_quote_arg_for_platform( + &program.display().to_string(), + true, + )) + .chain( + args.iter() + .map(|argument| crate::plugin_host::shell_quote_arg_for_platform(argument, true)), + ) + .collect::>() + .join(" ") +} + // Uses an explicit `--agent` when present and otherwise infers the agent from argv[0]. Inference is // intentionally late so configured commands and direct CLI commands share the same validation path. fn resolved_agent(command: &RunCommand, argv: &[String]) -> Result { @@ -525,8 +624,7 @@ 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 { - let mut command = Command::new(&self.argv[0]); - command.args(&self.argv[1..]); + let mut command = command_from_argv(&self.argv); for (name, value) in &self.env { command.env(name, value); } diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/plugin_install/mod.rs index 0a15c64ff..599c56b71 100644 --- a/crates/cli/src/plugin_install/mod.rs +++ b/crates/cli/src/plugin_install/mod.rs @@ -72,7 +72,7 @@ pub(crate) struct HostPluginReadinessCheck { pub(crate) details: String, } -/// Readiness state for one persisted host-plugin installation. +/// Readiness state for one persistent coding-agent integration. #[derive(Debug, Clone, Serialize)] pub(crate) struct HostPluginReadiness { pub(crate) host: String, @@ -118,16 +118,22 @@ struct PendingHostPluginReadiness { receiver: Receiver, } -/// Collects default-location host-plugin readiness without printing or mutating state. +/// Collects default-location persistent-integration readiness without printing or mutating state. /// /// Only hosts with a persisted install-state record are included. This keeps ordinary /// transparent-run users from failing the top-level doctor merely because they have not opted -/// into the persistent host-plugin workflow. +/// into a persistent coding-agent integration. pub(crate) fn collect_default_host_plugin_readiness() -> Vec { let install_dir = default_install_dir().canonicalize_or_self(); - let pending = [IntegrationHost::Codex, IntegrationHost::ClaudeCode] + let mut hosts = [IntegrationHost::Codex, IntegrationHost::ClaudeCode] .into_iter() .filter(|host| state_path(*host, &install_dir).exists()) + .collect::>(); + if hermes_config_path().is_ok_and(|path| crate::hermes::persistent_state_exists(&path)) { + hosts.push(IntegrationHost::Hermes); + } + let pending = hosts + .into_iter() .map(|host| spawn_default_host_plugin_readiness(host, install_dir.clone())) .collect::>(); let deadline = Instant::now() + DEFAULT_HOST_PLUGIN_READINESS_TIMEOUT; @@ -146,7 +152,12 @@ fn spawn_default_host_plugin_readiness( host: IntegrationHost, install_dir: PathBuf, ) -> PendingHostPluginReadiness { - let state_path = state_path(host, &install_dir); + let state_path = match host { + IntegrationHost::Hermes => hermes_config_path() + .unwrap_or_else(|_| state_path(IntegrationHost::Hermes, &install_dir)), + _ => state_path(host, &install_dir), + }; + let worker_state_path = state_path.clone(); let (sender, receiver) = mpsc::sync_channel(1); std::thread::spawn(move || { let options = PluginInstallOptions { @@ -158,7 +169,12 @@ fn spawn_default_host_plugin_readiness( }; let runner = RealCommandRunner; let setup_runner = RealPluginSetupRunner; - let readiness = collect_host_plugin_readiness(host, &options, &runner, &setup_runner); + let readiness = match host { + IntegrationHost::Hermes => { + collect_hermes_host_readiness(&worker_state_path, &options, &runner) + } + _ => collect_host_plugin_readiness(host, &options, &runner, &setup_runner), + }; let _ = sender.send(readiness); }); PendingHostPluginReadiness { @@ -177,12 +193,12 @@ fn receive_host_plugin_readiness( Err(mpsc::RecvTimeoutError::Timeout) => failed_host_plugin_readiness( pending.host, pending.state_path, - "timed out while collecting host-plugin readiness", + "timed out while collecting persistent-integration readiness", ), Err(mpsc::RecvTimeoutError::Disconnected) => failed_host_plugin_readiness( pending.host, pending.state_path, - "host-plugin readiness collector stopped unexpectedly", + "persistent-integration readiness collector stopped unexpectedly", ), } } @@ -192,13 +208,20 @@ fn failed_host_plugin_readiness( state_path: PathBuf, details: impl Into, ) -> HostPluginReadiness { - let layout = PluginLayout::new(host, state_path.parent().unwrap_or_else(|| Path::new("."))); + let (marketplace, plugin) = match host { + IntegrationHost::Hermes => (None, None), + _ => { + let layout = + PluginLayout::new(host, state_path.parent().unwrap_or_else(|| Path::new("."))); + (Some(layout.marketplace_root), Some(layout.plugin_root)) + } + }; let mut readiness = HostPluginReadiness { host: host.as_arg().to_string(), remediation: format!("nemo-relay install {} --force", host.as_arg()), state_path, - marketplace: Some(layout.marketplace_root), - plugin: Some(layout.plugin_root), + marketplace, + plugin, checks: Vec::new(), relay: None, host_plugin_registered: None, @@ -481,31 +504,94 @@ fn doctor_hermes_json_value( runner: &dyn CommandRunner, ) -> Result { let config = hermes_config_path()?; - let version = host::validate_host_version(IntegrationHost::Hermes, options, runner); - let integration = crate::hermes::diagnose_persistent(&config); - let ok = version.is_ok() && integration.is_ok(); + let readiness = collect_hermes_host_readiness(&config, options, runner); Ok(json!({ - "ok": ok, - "host": IntegrationHost::Hermes.as_arg(), - "remediation": format!("nemo-relay install {} --force", IntegrationHost::Hermes.as_arg()), + "ok": readiness.ok(), + "host": readiness.host, + "remediation": readiness.remediation, "config": config, - "readiness_checks": [ - { - "name": "Hermes Agent version", - "ok": version.is_ok(), - "details": version - .map(|_| format!("{} is installed", CodingAgent::Hermes.version_requirement())) - .unwrap_or_else(|error| error), - }, - { - "name": "Hermes MCP and hooks", - "ok": integration.is_ok(), - "details": integration.unwrap_or_else(|error| error), - } - ] + "readiness_checks": readiness.checks })) } +fn collect_hermes_host_readiness( + config: &Path, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> HostPluginReadiness { + let mut readiness = HostPluginReadiness { + host: IntegrationHost::Hermes.as_arg().into(), + remediation: format!( + "nemo-relay install {} --force", + IntegrationHost::Hermes.as_arg() + ), + state_path: config.to_path_buf(), + marketplace: None, + plugin: None, + checks: Vec::new(), + relay: None, + host_plugin_registered: None, + host_marketplace_registered: None, + plugin_setup: None, + }; + + let host_cli = require_host_cli(IntegrationHost::Hermes, options, runner); + readiness.push( + "Host CLI", + host_cli + .as_ref() + .map(|_| "hermes is available".into()) + .map_err(Clone::clone), + ); + let version = host::validate_host_version(IntegrationHost::Hermes, options, runner); + if version.is_err() { + readiness.remediation = format!( + "upgrade to {}, then run `nemo-relay install {} --force`", + CodingAgent::Hermes.version_requirement(), + IntegrationHost::Hermes.as_arg() + ); + } + readiness.push( + "Hermes Agent version", + version.map(|_| format!("{} is installed", CodingAgent::Hermes.version_requirement())), + ); + + let relay = crate::hermes::configured_relay_executable(config); + readiness.push( + "Configured Relay binary", + relay + .as_ref() + .map(|path| format!("found at {}", path.display())) + .map_err(Clone::clone), + ); + match relay { + Ok(relay) => { + readiness.relay = Some(relay.clone()); + readiness.push( + "Relay hook support", + validate_relay_hook_forward(&relay, options, runner) + .map(|_| "hook-forward is supported".into()), + ); + readiness.push( + "Relay MCP support", + validate_relay_mcp(&relay, options, runner) + .map(|_| "native mcp subcommand is supported".into()), + ); + } + Err(error) => { + let unavailable = || format!("cannot verify configured Relay capabilities: {error}"); + readiness.push("Relay hook support", Err(unavailable())); + readiness.push("Relay MCP support", Err(unavailable())); + } + } + + readiness.push( + "Hermes MCP, hooks, and trust", + crate::hermes::diagnose_persistent(config), + ); + readiness +} + fn install_host( host: IntegrationHost, options: &PluginInstallOptions, diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 290bbbb0a..9213fd1a8 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -511,6 +511,13 @@ fn fake_bootstrap_proof(key: &[u8], fingerprint: &str, nonce: &str) -> String { } fn run_fake_bootstrap_listener(proof: FakeBootstrapProof) -> (Output, Vec) { + run_fake_bootstrap_listener_with_hook_delay(proof, None) +} + +fn run_fake_bootstrap_listener_with_hook_delay( + proof: FakeBootstrapProof, + hook_delay: Option, +) -> (Output, Vec) { let temp = tempfile::tempdir().unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); @@ -574,16 +581,17 @@ fn run_fake_bootstrap_listener(proof: FakeBootstrapProof) -> (Output, Vec PathBuf { let path = root.join("NeMo Relay's bin").join("nemo-relay"); @@ -85,6 +86,10 @@ fn hook_command_round_trips_paths_and_recognizes_owned_legacy_spellings() { ), r#""C:\Program Files\NeMo 100%%\bin\nemo-relay.exe" hook-forward hermes --gateway-url http://127.0.0.1:47632"# ); + assert_eq!( + crate::installer::transparent_hook_forward_command(relay, CodingAgent::Hermes), + "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward hermes" + ); for command in [ "nemo-relay hook-forward hermes", "/old/path/nemo-relay plugin-shim hook hermes", @@ -213,6 +218,50 @@ hooks: } } +#[test] +fn persistent_config_rejects_a_foreign_server_with_the_reserved_name() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let generation = temp.path().join(GENERATION_FILE_NAME); + let command = persistent_hook_command(&relay); + let existing = r#" +model: keep-me +mcp_servers: + nemo-relay: + command: foreign-mcp + args: [serve] +"#; + + let error = persistent_config(Some(existing), &relay, &command, &generation, &[]) + .unwrap_err() + .to_string(); + + assert!(error.contains("not managed by Relay"), "{error}"); + assert!(error.contains("rename or remove"), "{error}"); +} + +#[test] +fn foreign_reserved_server_aborts_install_before_any_file_changes() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + let config = + b"# preserve\nmcp_servers:\n nemo-relay:\n command: foreign-mcp\n args: [serve]\n"; + let allowlist = b"{\"approvals\":[{\"event\":\"custom\",\"command\":\"custom-hook\"}]}\n"; + std::fs::write(&paths.config, config).unwrap(); + std::fs::write(&paths.allowlist, allowlist).unwrap(); + + let error = install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write) + .unwrap_err() + .to_string(); + + assert!(error.contains("not managed by Relay"), "{error}"); + assert_eq!(std::fs::read(&paths.config).unwrap(), config); + assert_eq!(std::fs::read(&paths.allowlist).unwrap(), allowlist); + assert!(!paths.generation.exists()); +} + #[test] fn trusted_hooks_migrates_only_relay_approvals_and_records_every_event() { let temp = tempfile::tempdir().unwrap(); @@ -479,11 +528,78 @@ fn uninstall_noops_without_creating_a_hermes_home() { assert!(!home.exists()); } +#[test] +fn unrelated_hermes_files_are_not_owned_or_rewritten_by_uninstall() { + let temp = tempfile::tempdir().unwrap(); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + let config = b"# preserve this exact formatting\nmodel: custom\nmcp_servers:\n nemo-relay:\n command: foreign-mcp\n args: [serve]\n"; + let allowlist = b"{ \"approvals\": [{\"event\":\"custom\",\"command\":\"custom-hook\"}] }\n"; + std::fs::write(&paths.config, config).unwrap(); + std::fs::write(&paths.allowlist, allowlist).unwrap(); + + assert!(!persistent_state_exists(&paths.config)); + assert!(uninstall_persistent(&paths.config).unwrap().is_empty()); + assert_eq!(std::fs::read(&paths.config).unwrap(), config); + assert_eq!(std::fs::read(&paths.allowlist).unwrap(), allowlist); + assert!(!paths.generation.exists()); +} + +#[test] +fn persistent_state_detection_recognizes_each_relay_owned_surface() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let roots = ["generation", "mcp", "hook", "approval"].map(|name| { + let paths = paths(&temp.path().join(name)); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + paths + }); + + std::fs::write(&roots[0].generation, "active\n").unwrap(); + std::fs::write( + &roots[1].config, + serde_yaml::to_string(&json!({ + "mcp_servers": {MCP_SERVER_NAME: expected_mcp_server(&relay, &roots[1].generation, &[])} + })) + .unwrap(), + ) + .unwrap(); + std::fs::write( + &roots[2].config, + serde_yaml::to_string(&json!({ + "hooks": { + "on_session_start": [{"command": persistent_hook_command(&relay)}] + } + })) + .unwrap(), + ) + .unwrap(); + std::fs::write( + &roots[3].allowlist, + serde_json::to_vec(&json!({ + "approvals": [{ + "event": "on_session_start", + "command": persistent_hook_command(&relay) + }] + })) + .unwrap(), + ) + .unwrap(); + + for paths in roots { + assert!( + persistent_state_exists(&paths.config), + "managed state at {} was not detected", + paths.config.display() + ); + } +} + #[test] fn transparent_config_suppresses_only_the_managed_mcp_and_uses_one_relay_hook() { let temp = tempfile::tempdir().unwrap(); let relay = relay_binary(temp.path()); - let command = persistent_hook_command(&relay); + let command = crate::installer::transparent_hook_forward_command(&relay, CodingAgent::Hermes); let existing = format!( r#" mcp_servers: diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs index 51874ceb7..36607d5f3 100644 --- a/crates/cli/tests/coverage/installer_tests.rs +++ b/crates/cli/tests/coverage/installer_tests.rs @@ -75,27 +75,17 @@ fn hermes_config_merge_rejects_invalid_yaml() { } #[test] -fn hermes_hook_forward_prefers_dynamic_env_url() { +fn explicit_persistent_destinations_ignore_ambient_urls() { let destination = resolve_hook_destination( - CodingAgent::Hermes, Some("http://installed".into()), Some("http://dynamic".into()), ); - assert_eq!(destination.gateway_url, "http://dynamic"); - assert!(!destination.recover); - - let destination = - resolve_hook_destination(CodingAgent::Hermes, Some("http://installed".into()), None); assert_eq!(destination.gateway_url, "http://installed"); assert!(destination.recover); - let destination = resolve_hook_destination( - CodingAgent::Codex, - Some("http://installed".into()), - Some("http://dynamic".into()), - ); - assert_eq!(destination.gateway_url, "http://installed"); - assert!(destination.recover); + let destination = resolve_hook_destination(None, Some("http://dynamic".into())); + assert_eq!(destination.gateway_url, "http://dynamic"); + assert!(!destination.recover); } #[test] diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index 8cbeb424c..3e68fb568 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -507,6 +507,67 @@ fn insert_after_agent_uses_last_matching_agent_or_first_word_fallback() { assert_eq!(wrapped, vec!["agent-wrapper", "--hook", "run"]); } +#[test] +fn version_probe_preserves_known_wrappers_and_skips_opaque_ones() { + assert_eq!( + version_probe_argv(CodingAgent::Codex, &["codex".into(), "exec".into()]), + Some(vec!["codex".into(), "--version".into()]) + ); + assert_eq!( + version_probe_argv( + CodingAgent::Codex, + &["npx".into(), "--yes".into(), "codex".into(), "exec".into(),], + ), + Some(vec![ + "npx".into(), + "--yes".into(), + "codex".into(), + "--version".into(), + ]) + ); + assert_eq!( + version_probe_argv( + CodingAgent::Hermes, + &["company-agent-wrapper".into(), "chat".into()], + ), + None + ); +} + +#[test] +fn windows_agent_command_line_quotes_paths_and_metacharacters() { + let line = windows_command_line( + Path::new(r"C:\Program Files\Codex&Tools\npx.cmd"), + &["codex".into(), "--version".into(), "100%".into()], + ); + + assert!(line.contains(r#""C:\Program Files\Codex^&Tools\npx.cmd""#)); + assert!(line.contains("codex --version")); + assert!(line.contains(r#""100%%""#)); +} + +#[cfg(unix)] +#[tokio::test] +async fn wrapped_agent_version_probe_runs_through_the_wrapper() { + let temp = tempfile::tempdir().unwrap(); + let wrapper = temp.path().join("npx"); + std::fs::write( + &wrapper, + "#!/bin/sh\n[ \"$1\" = codex ] && [ \"$2\" = --version ] || exit 9\necho 'codex-cli 0.143.0'\n", + ) + .unwrap(); + make_executable(&wrapper); + let probe = version_probe_argv( + CodingAgent::Codex, + &[wrapper.display().to_string(), "codex".into(), "exec".into()], + ) + .unwrap(); + + validate_agent_version(CodingAgent::Codex, &probe) + .await + .unwrap(); +} + #[test] fn prepares_claude_dry_run_without_writing_plugin() { let resolved = ResolvedConfig { diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index a9543108c..8884816c9 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -271,6 +271,7 @@ struct HomeScope<'a> { prev_home: Option, prev_userprofile: Option, prev_codex_home: Option, + prev_hermes_home: Option, } impl<'a> HomeScope<'a> { @@ -281,17 +282,20 @@ impl<'a> HomeScope<'a> { let prev_home = std::env::var_os("HOME"); let prev_userprofile = std::env::var_os("USERPROFILE"); let prev_codex_home = std::env::var_os("CODEX_HOME"); + let prev_hermes_home = std::env::var_os("HERMES_HOME"); // SAFETY: This test holds a process-wide mutex for the lifetime of the env override. unsafe { std::env::set_var("HOME", path); std::env::remove_var("USERPROFILE"); std::env::remove_var("CODEX_HOME"); + std::env::remove_var("HERMES_HOME"); } Self { _guard: guard, prev_home, prev_userprofile, prev_codex_home, + prev_hermes_home, } } } @@ -312,6 +316,10 @@ impl Drop for HomeScope<'_> { Some(value) => std::env::set_var("CODEX_HOME", value), None => std::env::remove_var("CODEX_HOME"), } + match self.prev_hermes_home.take() { + Some(value) => std::env::set_var("HERMES_HOME", value), + None => std::env::remove_var("HERMES_HOME"), + } } } } @@ -321,6 +329,7 @@ struct PathScope<'a> { previous: Option, previous_home: Option, previous_codex_home: Option, + previous_hermes_home: Option, } impl<'a> PathScope<'a> { @@ -331,17 +340,20 @@ impl<'a> PathScope<'a> { let previous = std::env::var_os("PATH"); let previous_home = std::env::var_os("HOME"); let previous_codex_home = std::env::var_os("CODEX_HOME"); + let previous_hermes_home = std::env::var_os("HERMES_HOME"); // SAFETY: This test holds the process-wide environment mutex for the override lifetime. unsafe { std::env::set_var("PATH", path); std::env::set_var("HOME", home); std::env::remove_var("CODEX_HOME"); + std::env::remove_var("HERMES_HOME"); } Self { _guard: guard, previous, previous_home, previous_codex_home, + previous_hermes_home, } } } @@ -362,6 +374,10 @@ impl Drop for PathScope<'_> { Some(value) => std::env::set_var("CODEX_HOME", value), None => std::env::remove_var("CODEX_HOME"), } + match self.previous_hermes_home.take() { + Some(value) => std::env::set_var("HERMES_HOME", value), + None => std::env::remove_var("HERMES_HOME"), + } } } } @@ -1748,6 +1764,9 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { #[test] fn select_all_uses_operation_specific_inputs() { let dir = tempdir().unwrap(); + let home = dir.path().join("home"); + std::fs::create_dir_all(home.join(".hermes")).unwrap(); + let _home = HomeScope::enter(&home); let runner = MockRunner::default().with_executable("codex", "/bin/codex"); let selected = select_hosts( IntegrationHost::All, @@ -1780,6 +1799,77 @@ fn select_all_uses_operation_specific_inputs() { ) .unwrap(); assert_eq!(selected, vec![IntegrationHost::ClaudeCode]); + + let unrelated_hermes_config = b"# user-owned formatting\nmodel: custom\n"; + let hermes_config = home.join(".hermes/config.yaml"); + std::fs::write(&hermes_config, unrelated_hermes_config).unwrap(); + let selected = select_hosts( + IntegrationHost::All, + HostSelectionMode::InstalledState, + &options(dir.path()), + &runner, + ) + .unwrap(); + assert_eq!(selected, vec![IntegrationHost::ClaudeCode]); + assert_eq!( + std::fs::read(&hermes_config).unwrap(), + unrelated_hermes_config + ); +} + +#[test] +fn hermes_doctor_probes_the_configured_relay_and_top_level_doctor_discovers_it() { + let dir = tempdir().unwrap(); + let home = dir.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let _home = HomeScope::enter(&home); + let config = hermes_config_path().unwrap(); + let relay = home + .join("bin") + .join(format!("nemo-relay{}", std::env::consts::EXE_SUFFIX)); + std::fs::create_dir_all(relay.parent().unwrap()).unwrap(); + std::fs::copy(std::env::current_exe().unwrap(), &relay).unwrap(); + crate::hermes::install_persistent(&config, &relay).unwrap(); + let configured_relay = crate::hermes::configured_relay_executable(&config).unwrap(); + let runner = MockRunner::default() + .with_executable("hermes", "/bin/hermes") + .with_capture_output("/bin/hermes --version", "Hermes Agent v0.18.2 (test)\n"); + + let report = doctor_hermes_json_value(&options(dir.path()), &runner).unwrap(); + + assert_eq!(report["ok"], json!(true)); + assert_eq!( + runner.quiet_commands(), + vec![ + format!("{} hook-forward --help", configured_relay.display()), + format!("{} mcp --help", configured_relay.display()), + ] + ); + let checks = report["readiness_checks"].as_array().unwrap(); + for expected in [ + "Host CLI", + "Hermes Agent version", + "Configured Relay binary", + "Relay hook support", + "Relay MCP support", + "Hermes MCP, hooks, and trust", + ] { + assert!( + checks + .iter() + .any(|check| check["name"] == expected && check["ok"] == json!(true)), + "missing successful {expected} check: {checks:?}" + ); + } + + let readiness = collect_default_host_plugin_readiness(); + let hermes = readiness + .iter() + .find(|readiness| readiness.host == "hermes") + .expect("top-level doctor should discover install-only Hermes state"); + assert_eq!(hermes.state_path, config); + assert!(hermes.marketplace.is_none()); + assert!(hermes.plugin.is_none()); } #[test] diff --git a/docs/nemo-relay-cli/hermes.mdx b/docs/nemo-relay-cli/hermes.mdx index 3ca244c0e..c874f4de0 100644 --- a/docs/nemo-relay-cli/hermes.mdx +++ b/docs/nemo-relay-cli/hermes.mdx @@ -38,6 +38,10 @@ of the user configuration. Hermes reads this configuration from unset. This location is user-owned even when you choose project-scoped Relay configuration. +The MCP server name `nemo-relay` is reserved for the Relay-managed entry. If +that name already belongs to another command, installation stops without +rewriting the config. Rename or remove the conflicting entry, then retry. + Installation performs the following changes as one verified transaction: - Adds `mcp_servers.nemo-relay` with the canonical absolute `nemo-relay` diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index 52b699233..7c3288fe0 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -214,9 +214,12 @@ For Claude Code, doctor also validates version 2.1.121 or newer, the generated `alwaysLoad` MCP server, and its generation marker. A stopped sidecar remains informational because the next MCP process start or hook retry starts it. -For Hermes, doctor validates Hermes Agent 0.18.2 or newer plus the generated MCP -entry, every hook and trust pair, its generation marker, and environment -references. +For Hermes, doctor validates Hermes Agent 0.18.2 or newer. It reads the Relay +executable from the managed Hermes MCP entry, verifies that exact executable +supports both `mcp` and `hook-forward`, and then checks every hook and trust +pair, the generation marker, and environment references. Top-level +`nemo-relay doctor` discovers this managed state directly from the Hermes user +configuration; an `[agents.hermes]` Relay configuration block is not required. Use the focused plugin doctor when diagnosing one host or an installation that uses a custom directory: @@ -261,11 +264,26 @@ hook, trust, and generation state. This release removes the internal `nemo-relay plugin-shim` command. Existing generated installations should be refreshed with -`nemo-relay install --force`. Custom integrations must invoke -`nemo-relay hook-forward ` and pass the canonical lifecycle payload on -standard input. Persistent generated commands include the fixed gateway URL; -transparent wrappers provide `NEMO_RELAY_GATEWAY_URL` for their dynamic -gateway. +`nemo-relay install --force`. Use the following public replacements for +custom automation: + +| Removed internal command | Public replacement | +| --- | --- | +| `plugin-shim hook ` | `hook-forward ` with the canonical lifecycle payload on standard input | +| `plugin-shim serve` | `mcp --agent ` for host-managed lifecycle, or `--bind
` to run a gateway directly | +| `plugin-shim install ` | `install ` | +| `plugin-shim uninstall ` | `uninstall ` | +| `plugin-shim provider claude enable` | `install claude-code` | +| `plugin-shim provider claude restore` | `uninstall claude-code` | +| `plugin-shim provider claude status` | `doctor --plugin claude-code` | +| `plugin-shim doctor ` | `doctor --plugin ` | + +Persistent generated hook commands include the fixed gateway URL. Transparent +wrappers provide `NEMO_RELAY_GATEWAY_URL` for their dynamic gateway. When a +transparent run uses a recognizable wrapper command, such as `npx codex`, Relay +checks the host version through that wrapper. Opaque custom wrappers remain +supported and are responsible for selecting a compatible host version; install +and doctor still enforce the minimum version for directly managed hosts. Hermes setup is now available through the same public install, doctor, and uninstall commands as Claude Code and Codex. `nemo-relay config hermes` remains diff --git a/scripts/test-claude-plugin-e2e.sh b/scripts/test-claude-plugin-e2e.sh index 89c5ae9ac..107467ef3 100755 --- a/scripts/test-claude-plugin-e2e.sh +++ b/scripts/test-claude-plugin-e2e.sh @@ -49,6 +49,7 @@ export PATH="$repo_root/target/debug:$PATH" export ANTHROPIC_API_KEY="relay-claude-e2e-key" export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 export DISABLE_AUTOUPDATER=1 +export NEMO_RELAY_GATEWAY_URL="http://127.0.0.1:1" export NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS=1 mkdir -p \ diff --git a/scripts/test-codex-plugin-e2e.sh b/scripts/test-codex-plugin-e2e.sh index f39a0aef5..a1592428f 100755 --- a/scripts/test-codex-plugin-e2e.sh +++ b/scripts/test-codex-plugin-e2e.sh @@ -88,7 +88,7 @@ fi # Keep a conflicting hook target set so the persistent plugin must preserve its # explicitly installed hook endpoint instead of inheriting an ambient target. -export NEMO_RELAY_PLUGIN_GATEWAY_URL="http://127.0.0.1:1" +export NEMO_RELAY_GATEWAY_URL="http://127.0.0.1:1" export HOME="$work/home" export CODEX_HOME="$work/codex-home" diff --git a/scripts/test-hermes-mcp-e2e.sh b/scripts/test-hermes-mcp-e2e.sh index e27d6e88c..9ef6685c4 100755 --- a/scripts/test-hermes-mcp-e2e.sh +++ b/scripts/test-hermes-mcp-e2e.sh @@ -72,6 +72,7 @@ export TMPDIR="$work/tmp" export PATH="$repo_root/target/debug:$PATH" export OPENAI_API_KEY="relay-hermes-e2e-key" export OPENAI_BASE_URL="http://127.0.0.1:47632/v1" +export NEMO_RELAY_GATEWAY_URL="http://127.0.0.1:1" # Hermes drains some shell hooks after the foreground CLI has exited. Keep a short grace period so # one lifecycle cannot be split across two gateway generations; production retains the gateway for # 300 seconds. From 1ec287efa67a6600d1f2e2523a2b1ba6211ffbae Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sat, 11 Jul 2026 10:49:16 -0400 Subject: [PATCH 008/102] refactor(cli): unify coding-agent bootstrap lifecycle Signed-off-by: Will Killian --- README.md | 2 +- crates/cli/Cargo.toml | 1 + crates/cli/src/agent_process.rs | 166 +++++++++ crates/cli/src/coding_agent.rs | 44 +-- crates/cli/src/config.rs | 27 +- crates/cli/src/doctor.rs | 112 +----- crates/cli/src/hermes.rs | 25 ++ crates/cli/src/hermes/config.rs | 3 +- crates/cli/src/hermes/trust.rs | 17 + crates/cli/src/installer.rs | 34 +- crates/cli/src/launcher.rs | 326 ++++++------------ crates/cli/src/main.rs | 1 + crates/cli/src/mcp/gateway.rs | 154 ++++++--- .../cli/src/plugin_host/codex_app_server.rs | 14 +- crates/cli/src/plugin_host/mod.rs | 4 +- crates/cli/src/plugin_install/host.rs | 121 +------ crates/cli/src/plugin_install/marketplace.rs | 4 +- crates/cli/src/plugin_install/mod.rs | 12 +- crates/cli/src/plugin_install/setup.rs | 14 +- crates/cli/src/server.rs | 13 +- crates/cli/src/session.rs | 2 +- crates/cli/src/setup.rs | 23 +- crates/cli/src/setup/model.rs | 64 +--- crates/cli/src/sidecar.rs | 142 +++----- crates/cli/src/sidecar/health.rs | 67 ++-- crates/cli/src/sidecar/state.rs | 110 +++--- crates/cli/tests/cli_tests.rs | 23 +- .../cli/tests/coverage/agent_process_tests.rs | 69 ++++ .../cli/tests/coverage/coding_agent_tests.rs | 7 + crates/cli/tests/coverage/doctor_tests.rs | 133 +++---- crates/cli/tests/coverage/gateway_tests.rs | 2 + crates/cli/tests/coverage/hermes_tests.rs | 23 ++ crates/cli/tests/coverage/installer_tests.rs | 67 ++-- crates/cli/tests/coverage/launcher_tests.rs | 210 ++++++----- .../cli/tests/coverage/mcp_gateway_tests.rs | 135 ++++++-- crates/cli/tests/coverage/mcp_tests.rs | 69 ++-- .../cli/tests/coverage/plugin_host_tests.rs | 42 +-- .../coverage/plugin_install_setup_tests.rs | 3 +- .../tests/coverage/plugin_install_tests.rs | 40 ++- crates/cli/tests/coverage/server_tests.rs | 18 +- crates/cli/tests/coverage/setup_tests.rs | 33 -- .../cli/tests/coverage/sidecar_state_tests.rs | 138 ++++---- crates/cli/tests/coverage/sidecar_tests.rs | 25 +- docs/nemo-relay-cli/basic-usage.mdx | 11 +- docs/nemo-relay-cli/codex.mdx | 2 +- docs/nemo-relay-cli/hermes.mdx | 15 +- docs/nemo-relay-cli/plugin-installation.mdx | 42 ++- integrations/coding-agents/README.md | 10 +- .../coding-agents/claude-code/README.md | 8 +- integrations/coding-agents/codex/README.md | 2 +- scripts/test-codex-plugin-e2e.sh | 12 +- scripts/test-hermes-mcp-e2e.sh | 2 +- 52 files changed, 1378 insertions(+), 1265 deletions(-) create mode 100644 crates/cli/src/agent_process.rs create mode 100644 crates/cli/tests/coverage/agent_process_tests.rs diff --git a/README.md b/README.md index fb1f9fa2e..738c94dde 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ coverage. | Agent | Observability | Security | Optimization | Notes | |:--|:--:|:--:|:--:|:--| | Claude Code | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed LLM observability are supported. | -| Codex | Yes | Yes | Partial | Persistent install verifies the exact plugin hooks. Each `Stop` finalizes a turn snapshot; `SessionEnd` availability remains Codex-version-dependent. | +| Codex | Yes | Yes | Partial | Persistent install verifies the exact plugin hooks. Each `Stop` finalizes a turn snapshot; the supported generated schema does not install `SessionEnd`. | | Hermes Agent | Yes | Yes | Partial | User config installs the shared native MCP gateway lifecycle plus exact trusted hooks; gateway-routed or hook-backed LLM observability is supported. | ### Public API Integrations diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 2a90b16aa..9a778bd1f 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -70,4 +70,5 @@ windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Securit opentelemetry = { workspace = true, features = ["trace"] } opentelemetry_sdk = { workspace = true, features = ["trace", "testing"] } tempfile = "3" +tokio = { version = "1", features = ["test-util"] } tower = { version = "0.5", features = ["util"] } diff --git a/crates/cli/src/agent_process.rs b/crates/cli/src/agent_process.rs new file mode 100644 index 000000000..8d332b87b --- /dev/null +++ b/crates/cli/src/agent_process.rs @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared coding-agent command parsing, discovery, and process construction. + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::config::CodingAgent; + +/// Parses the intentionally simple command strings accepted by `[agents.*].command`. +/// +/// Complex shell expressions belong after `nemo-relay run --`; configuration values are argv +/// prefixes and therefore use whitespace separation consistently in launch and diagnostics. +pub(crate) fn command_argv(command: &str) -> Vec { + command.split_whitespace().map(ToOwned::to_owned).collect() +} + +/// Builds the host version probe while preserving a configured wrapper prefix. +/// +/// The last recognizable host token wins so package selectors such as +/// `npm exec --package @openai/codex -- codex` do not truncate the probe at the package name. +/// Opaque wrappers must expose the selected host's version when passed `--version`. +pub(crate) fn version_probe_argv(agent: CodingAgent, argv: &[String]) -> Vec { + let mut probe = argv + .iter() + .rposition(|argument| CodingAgent::infer(argument) == Some(agent)) + .map_or_else(|| argv.to_vec(), |index| argv[..=index].to_vec()); + if probe.is_empty() { + probe.push(agent.executable().into()); + } + probe.push("--version".into()); + probe +} + +/// Resolves a command using the current platform's executable conventions. +pub(crate) fn resolve_executable(command: &str) -> Option { + resolve_executable_for_platform( + command, + std::env::var_os("PATH").as_deref(), + std::env::var_os("PATHEXT").as_deref(), + cfg!(windows), + ) +} + +/// Resolves a command against an explicit PATH. This keeps setup detection deterministic in tests. +pub(crate) fn resolve_executable_in_path(command: &str, path: Option<&OsStr>) -> Option { + resolve_executable_for_platform( + command, + path, + std::env::var_os("PATHEXT").as_deref(), + cfg!(windows), + ) +} + +pub(crate) fn resolve_executable_for_platform( + command: &str, + path: Option<&OsStr>, + path_ext: Option<&OsStr>, + windows: bool, +) -> Option { + if command.is_empty() { + return None; + } + let command_path = Path::new(command); + let extensions = executable_extensions(command_path, path_ext, windows); + if command_path.is_absolute() || command_path.components().count() > 1 { + return resolve_candidate(command_path, &extensions); + } + path.into_iter() + .flat_map(std::env::split_paths) + .find_map(|directory| resolve_candidate(&directory.join(command), &extensions)) +} + +fn executable_extensions(command: &Path, path_ext: Option<&OsStr>, windows: bool) -> Vec { + if !windows || command.extension().is_some() { + return vec![OsString::new()]; + } + path_ext + .and_then(OsStr::to_str) + .filter(|value| !value.is_empty()) + .unwrap_or(".EXE;.CMD;.BAT;.COM") + .split(';') + .filter(|extension| !extension.is_empty()) + .map(OsString::from) + .collect() +} + +fn resolve_candidate(base: &Path, extensions: &[OsString]) -> Option { + extensions.iter().find_map(|extension| { + let candidate = if extension.is_empty() { + base.to_path_buf() + } else { + let mut value = base.as_os_str().to_os_string(); + value.push(extension); + PathBuf::from(value) + }; + candidate.is_file().then_some(candidate) + }) +} + +/// Creates a synchronous command, including the `cmd.exe` bridge required by Windows shims. +pub(crate) fn std_command(argv: &[String]) -> Command { + debug_assert!(!argv.is_empty()); + let program = resolve_executable(&argv[0]).unwrap_or_else(|| PathBuf::from(&argv[0])); + #[cfg(windows)] + if is_windows_command_script(&program) { + let mut command = + Command::new(std::env::var_os("COMSPEC").unwrap_or_else(|| OsString::from("cmd.exe"))); + command + .args(["/d", "/s", "/c"]) + .arg(windows_command_line(&program, &argv[1..])); + return command; + } + let mut command = Command::new(program); + command.args(&argv[1..]); + command +} + +/// Creates an asynchronous command with the same platform behavior as [`std_command`]. +pub(crate) fn tokio_command(argv: &[String]) -> tokio::process::Command { + debug_assert!(!argv.is_empty()); + let program = resolve_executable(&argv[0]).unwrap_or_else(|| PathBuf::from(&argv[0])); + #[cfg(windows)] + if is_windows_command_script(&program) { + let mut command = tokio::process::Command::new( + std::env::var_os("COMSPEC").unwrap_or_else(|| OsString::from("cmd.exe")), + ); + command + .args(["/d", "/s", "/c"]) + .arg(windows_command_line(&program, &argv[1..])); + return command; + } + let mut command = tokio::process::Command::new(program); + command.args(&argv[1..]); + command +} + +#[cfg(any(windows, test))] +pub(crate) fn is_windows_command_script(program: &Path) -> bool { + program + .extension() + .and_then(OsStr::to_str) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat") + }) +} + +#[cfg(any(windows, test))] +pub(crate) fn windows_command_line(program: &Path, args: &[String]) -> String { + std::iter::once(crate::plugin_host::shell_quote_arg_for_platform( + &program.display().to_string(), + true, + )) + .chain( + args.iter() + .map(|argument| crate::plugin_host::shell_quote_arg_for_platform(argument, true)), + ) + .collect::>() + .join(" ") +} + +#[cfg(test)] +#[path = "../tests/coverage/agent_process_tests.rs"] +mod tests; diff --git a/crates/cli/src/coding_agent.rs b/crates/cli/src/coding_agent.rs index d479af073..46b6e667a 100644 --- a/crates/cli/src/coding_agent.rs +++ b/crates/cli/src/coding_agent.rs @@ -5,7 +5,6 @@ use clap::ValueEnum; use semver::Version; -use std::path::Path; /// Coding-agent hosts supported by the CLI. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] @@ -132,35 +131,11 @@ impl CodingAgent { Ok(version) } - /// Runs the canonical version probe for a concrete host executable. - pub(crate) fn validate_executable(self, executable: &Path) -> Result { - let output = std::process::Command::new(executable) - .arg("--version") - .stdin(std::process::Stdio::null()) - .output() - .map_err(|error| { - format!( - "failed to run `{} --version`: {error}; NeMo Relay requires {}", - executable.display(), - self.version_requirement() - ) - })?; - if !output.status.success() { - return Err(format!( - "`{} --version` failed with {}; NeMo Relay requires {}", - executable.display(), - output.status, - self.version_requirement() - )); - } - self.validate_version_output(&String::from_utf8_lossy(&output.stdout)) - } - fn parse_version(self, raw: &str) -> Option { let descriptor = self.descriptor(); let token = match descriptor.version_format { VersionFormat::Codex => raw.strip_prefix("codex-cli ")?, - VersionFormat::ClaudeCode => raw.split_whitespace().next()?, + VersionFormat::ClaudeCode => raw.strip_suffix(" (Claude Code)")?, VersionFormat::Hermes => raw .strip_prefix("Hermes Agent v")? .split_whitespace() @@ -171,10 +146,19 @@ impl CodingAgent { /// Infers a host from an executable basename. pub(crate) fn infer(command: &str) -> Option { - let name = std::path::Path::new(command) - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(command); + let command = command.trim_matches(['"', '\'']); + if command.starts_with('@') { + return None; + } + let name = command + .rsplit(['/', '\\']) + .next() + .unwrap_or(command) + .to_ascii_lowercase(); + let name = [".exe", ".cmd", ".bat", ".com"] + .into_iter() + .find_map(|suffix| name.strip_suffix(suffix)) + .unwrap_or(&name); match name { "claude" | "claude-code" => Some(Self::ClaudeCode), "codex" => Some(Self::Codex), diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index e75fa1f10..349b40e33 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -80,12 +80,11 @@ pub(crate) enum Command { Codex(EasyPathCommand), /// Run Hermes with observability (setup on first use) #[command( - long_about = "Run Hermes Agent under an ephemeral NeMo Relay gateway. Persistent setup \ - configures Hermes's user-level `mcp_servers` and shell hooks so bare Hermes \ - processes can share the native Relay gateway on 127.0.0.1:47632. This \ - wrapper temporarily suppresses that fixed MCP entry and uses a dynamic \ - gateway for project-specific Relay configuration. Run \ - `nemo-relay install hermes --force` to refresh the persistent integration.", + long_about = "Run Hermes Agent under an ephemeral NeMo Relay gateway. The wrapper uses a \ + process-private HERMES_HOME overlay for dynamic hooks, without rewriting \ + the user's Hermes configuration. Use `nemo-relay install hermes` when bare \ + Hermes processes should load the shared native Relay gateway on \ + 127.0.0.1:47632 through MCP.", after_help = "Examples:\n \ nemo-relay hermes\n \ nemo-relay hermes -- chat --provider custom" @@ -688,11 +687,21 @@ pub(crate) struct AgentConfigs { #[derive(Debug, Clone, Default)] pub(crate) struct AgentCommandConfig { pub(crate) command: Option, - /// Recorded by `nemo-relay config` when it installs hermes shell hooks. Other agents leave - /// this empty; the launcher reads it only to print a "hooks live here" pointer for hermes. + /// Legacy Hermes config-path override retained for existing Relay configuration files. + /// New setup flows do not write it; persistent Hermes state belongs to `install hermes`. pub(crate) hooks_path: Option, } +impl AgentConfigs { + pub(crate) const fn get(&self, agent: CodingAgent) -> &AgentCommandConfig { + match agent { + CodingAgent::ClaudeCode => &self.claude, + CodingAgent::Codex => &self.codex, + CodingAgent::Hermes => &self.hermes, + } + } +} + // TOML file shape grouped by user intent. Sections map 1:1 onto fields already present on // `GatewayConfig` / `AgentConfigs`; plugin configuration lives in `plugins.toml`. #[derive(Debug, Clone, Default, Deserialize)] @@ -860,7 +869,7 @@ fn persistent_bootstrap_fingerprint( .map_err(CliError::Config)? .as_secs(); let document = serde_json::json!({ - "bootstrap_protocol": 1, + "bootstrap_protocol": crate::sidecar::BOOTSTRAP_PROTOCOL_VERSION, "relay_version": env!("CARGO_PKG_VERSION"), "openai_base_url": gateway.openai_base_url, "anthropic_base_url": gateway.anthropic_base_url, diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index 3b9a3903f..5455d83be 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -10,7 +10,6 @@ //! - `format_human(&report)` / `format_json(&report)` render the report. use std::path::{Path, PathBuf}; -use std::process::Stdio; use std::time::Duration; use futures_util::SinkExt; @@ -401,15 +400,18 @@ async fn collect_agents( let configured = agent_configured(agent, &resolved.agents); let target_requested = target_agent == Some(agent); let command = agent_command(agent, &resolved.agents); - let exec = command_executable(&command); - let path = which_command(exec); + let argv = crate::agent_process::command_argv(&command); + let exec = argv.first().map(String::as_str).unwrap_or_default(); + let path = crate::agent_process::resolve_executable(exec); let version = match &path { - Some(p) => probe_version(p).await, + Some(_) => { + let probe = crate::agent_process::version_probe_argv(agent, &argv); + probe_version(&probe).await + } None => None, }; let mut status = agent_command_status(path.as_deref(), configured, target_requested); - let (hook_status, hook_details) = - hook_status(agent, &resolved.agents, configured || target_requested); + let (hook_status, hook_details) = hook_status(agent, &resolved.agents); status = combine_status(status, hook_status, configured || target_requested); let mut details = Vec::new(); details.push(if configured { @@ -471,25 +473,6 @@ async fn collect_agents( out } -fn which_on_path(exec: &str) -> Option { - let path_var = std::env::var_os("PATH")?; - std::env::split_paths(&path_var) - .map(|dir| dir.join(exec)) - .find(|candidate| candidate.is_file()) -} - -fn which_command(exec: &str) -> Option { - let candidate = Path::new(exec); - if candidate.components().count() > 1 || candidate.is_absolute() { - return candidate.is_file().then(|| candidate.to_path_buf()); - } - which_on_path(exec) -} - -fn command_executable(command: &str) -> &str { - command.split_whitespace().next().unwrap_or(command) -} - fn agent_command(agent: CodingAgent, agents: &AgentConfigs) -> String { configured_agent_command(agent, agents) .cloned() @@ -497,11 +480,7 @@ fn agent_command(agent: CodingAgent, agents: &AgentConfigs) -> String { } fn configured_agent_command(agent: CodingAgent, agents: &AgentConfigs) -> Option<&String> { - match agent { - CodingAgent::ClaudeCode => agents.claude.command.as_ref(), - CodingAgent::Codex => agents.codex.command.as_ref(), - CodingAgent::Hermes => agents.hermes.command.as_ref(), - } + agents.get(agent).command.as_ref() } fn agent_configured(agent: CodingAgent, agents: &AgentConfigs) -> bool { @@ -535,11 +514,7 @@ fn combine_status(base: Status, hook: Status, readiness_required: bool) -> Statu base } -fn hook_status( - agent: CodingAgent, - agents: &AgentConfigs, - readiness_required: bool, -) -> (Status, String) { +fn hook_status(agent: CodingAgent, agents: &AgentConfigs) -> (Status, String) { match agent { CodingAgent::ClaudeCode | CodingAgent::Codex => { (Status::Pass, "hooks: injected during run".into()) @@ -554,70 +529,21 @@ fn hook_status( ), ), }, - None if readiness_required => ( - Status::Fail, - "hooks: not installed; run `nemo-relay install hermes`".into(), + None => ( + Status::Pass, + "hooks: injected through an isolated HERMES_HOME during run".into(), ), - None => (Status::Info, "hooks: not configured".into()), }, } } -#[cfg(test)] -fn hook_file_status( - path: Result, - agent: CodingAgent, - readiness_required: bool, - label: &str, -) -> (Status, String) { - let path = match path { - Ok(path) => path, - Err(err) => { - return ( - Status::Fail, - format!("{label}: could not resolve path: {err}"), - ); - } - }; - match std::fs::read_to_string(&path) { - Ok(raw) - if raw.contains(&format!("hook-forward {}", agent.as_arg())) - || raw.contains(&format!("plugin-shim hook {}", agent.as_arg())) => - { - ( - Status::Pass, - format!("{label}: installed at {}", path.display()), - ) - } - Ok(_) if readiness_required => ( - Status::Fail, - format!("{label}: missing NeMo Relay hook in {}", path.display()), - ), - Ok(_) => ( - Status::Info, - format!("{label}: no NeMo Relay hook in {}", path.display()), - ), - Err(error) if error.kind() == std::io::ErrorKind::NotFound && readiness_required => { - (Status::Fail, format!("{label}: missing {}", path.display())) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - (Status::Info, format!("{label}: missing {}", path.display())) - } - Err(error) => ( - Status::Fail, - format!("{label}: could not read {}: {error}", path.display()), - ), - } -} - -async fn probe_version(binary: &Path) -> Option { - // Spawn ` --version` and read the first line of stdout. Bounded by the network +async fn probe_version(argv: &[String]) -> Option { + // Run the shared wrapper-preserving probe and read the first line of stdout. Bounded by the network // timeout (re-used as a generic short timeout) so a misbehaving binary doesn't hang doctor. - let mut cmd = tokio::process::Command::new(binary); - cmd.arg("--version") - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .stdin(Stdio::null()) + let mut cmd = crate::agent_process::tokio_command(argv); + cmd.stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .stdin(std::process::Stdio::null()) // Ensure the child gets killed if our future is dropped on timeout. Without this a // misbehaving agent binary that exceeds NETWORK_TIMEOUT would leak as an orphan // process for the lifetime of the doctor invocation (and beyond). diff --git a/crates/cli/src/hermes.rs b/crates/cli/src/hermes.rs index 600dc6724..abb6d16b2 100644 --- a/crates/cli/src/hermes.rs +++ b/crates/cli/src/hermes.rs @@ -91,6 +91,10 @@ pub(crate) fn uninstall_persistent(config: &Path) -> Result, CliErr uninstall_persistent_with(paths, atomic_write) } +pub(crate) fn retire_persistent_gateway() -> Result<(), CliError> { + crate::plugin_host::stop_plugin_gateway().map_err(CliError::Install) +} + fn persistent_paths_have_managed_state(paths: &PersistentPaths) -> Result { if paths.generation.exists() { return Ok(true); @@ -434,6 +438,27 @@ fn verify_hook_definitions(config: &Value, command: &str) -> Result<(), String> )); } } + let mut managed = config + .get("hooks") + .and_then(Value::as_object) + .into_iter() + .flat_map(|hooks| hooks.iter()) + .flat_map(|(event, groups)| { + groups.as_array().into_iter().flatten().filter_map(|group| { + let candidate = group.get("command").and_then(Value::as_str)?; + is_managed_hook_command(candidate).then_some((event.as_str(), candidate)) + }) + }) + .collect::>(); + managed.sort_unstable(); + let mut expected = HERMES_HOOK_EVENTS + .iter() + .map(|event| (*event, command)) + .collect::>(); + expected.sort_unstable(); + if managed != expected { + return Err("Hermes config contains an unexpected Relay hook handler".into()); + } Ok(()) } diff --git a/crates/cli/src/hermes/config.rs b/crates/cli/src/hermes/config.rs index ec3fcd980..8279c7389 100644 --- a/crates/cli/src/hermes/config.rs +++ b/crates/cli/src/hermes/config.rs @@ -4,7 +4,6 @@ //! Pure Hermes YAML generation, migration, and ownership recognition. use std::collections::BTreeSet; -use std::fs; use std::path::{Path, PathBuf}; use serde_json::{Map, Value, json}; @@ -222,7 +221,7 @@ pub(super) fn relay_is_executable(path: &Path) -> bool { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - fs::metadata(path) + std::fs::metadata(path) .map(|metadata| metadata.permissions().mode() & 0o111 != 0) .unwrap_or(false) } diff --git a/crates/cli/src/hermes/trust.rs b/crates/cli/src/hermes/trust.rs index 0aaafabef..f486312e7 100644 --- a/crates/cli/src/hermes/trust.rs +++ b/crates/cli/src/hermes/trust.rs @@ -79,6 +79,23 @@ pub(super) fn verify_trust(allowlist_path: &Path, command: &str) -> Result<(), S )); } } + let mut managed = approvals + .iter() + .filter_map(|entry| { + let candidate = entry.get("command").and_then(Value::as_str)?; + let event = entry.get("event").and_then(Value::as_str)?; + is_managed_hook_command(candidate).then_some((event, candidate)) + }) + .collect::>(); + managed.sort_unstable(); + let mut expected = HERMES_HOOK_EVENTS + .iter() + .map(|event| (*event, command)) + .collect::>(); + expected.sort_unstable(); + if managed != expected { + return Err("Hermes allowlist contains an unexpected Relay hook approval".into()); + } Ok(()) } diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 0fc430001..2d16a71b2 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -324,16 +324,6 @@ pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value { } } -// Returns the shell command a hook should run to forward an event to the gateway. Callers must -// pass the executable they want hooks to invoke. Transparent-run callers should pass the absolute -// path of the currently running gateway binary so spawned hook subprocesses do not depend on the -// user's `PATH` (which Codex/Claude inherit but which typically does not include -// `target/debug` or other dev locations); persistent-install callers can pass the bare name -// `"nemo-relay"` because the user is expected to have the binary on `PATH` after install. -pub(crate) fn hook_forward_command(executable: &str, agent: CodingAgent) -> String { - format!("{executable} hook-forward {}", agent.as_arg()) -} - /// Canonical persistent hook command used by every supported host. pub(crate) fn persistent_hook_forward_command(relay: &Path, agent: CodingAgent) -> String { persistent_hook_forward_command_for_platform(relay, agent, cfg!(windows)) @@ -342,9 +332,17 @@ pub(crate) fn persistent_hook_forward_command(relay: &Path, agent: CodingAgent) /// Canonical transparent hook command. The launched agent receives its dynamic gateway through /// `NEMO_RELAY_GATEWAY_URL`, so the command must not persist a fixed endpoint. pub(crate) fn transparent_hook_forward_command(relay: &Path, agent: CodingAgent) -> String { + transparent_hook_forward_command_for_platform(relay, agent, cfg!(windows)) +} + +pub(crate) fn transparent_hook_forward_command_for_platform( + relay: &Path, + agent: CodingAgent, + windows: bool, +) -> String { format!( "{} hook-forward {}", - crate::plugin_host::shell_quote_for_platform(relay, cfg!(windows)), + crate::plugin_host::shell_quote_for_platform(relay, windows), agent.as_arg() ) } @@ -494,20 +492,6 @@ fn merge_event_hook_groups( Ok(()) } -/// Parses Hermes YAML, merges generated hooks through the shared JSON hook merger, and serializes -/// back to YAML. Empty input is treated as no existing configuration. -#[cfg(test)] -pub(crate) fn merge_hermes_config(existing: &str, generated: Value) -> Result { - let existing = if existing.trim().is_empty() { - Value::Null - } else { - serde_yaml::from_str(existing) - .map_err(|error| CliError::Install(format!("invalid YAML in Hermes config: {error}")))? - }; - let merged = merge_hooks(existing, generated)?; - serde_yaml::to_string(&merged).map_err(|error| CliError::Install(error.to_string())) -} - // Validates optional JSON strings before they are embedded into hook-forward headers. Catches // quoting/config mistakes at hook-fire time rather than after the request reaches the gateway. fn validate_optional_json(name: &str, value: Option<&str>) -> Result<(), CliError> { diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index 85af8fd08..de15aeadf 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use nemo_relay::observability::plugin_component::{ AtifStorageConfig, OBSERVABILITY_PLUGIN_KIND, ObservabilityConfig, @@ -12,7 +12,6 @@ use nemo_relay::plugin::PluginConfig; use reqwest::Client; use serde_json::{Value, json}; use tokio::net::TcpListener; -use tokio::process::Command; use tokio::sync::oneshot; use tokio::task::JoinHandle; @@ -21,14 +20,14 @@ use crate::config::{ ServerArgs, any_config_file_exists, resolve_run_config, }; use crate::error::CliError; -use crate::installer::{generated_hooks, hook_forward_command}; +use crate::installer::{generated_hooks, transparent_hook_forward_command}; use crate::plugins::lifecycle::ActiveDynamicPluginComponent; use crate::server; /// Runs a child coding-agent command behind an ephemeral local gateway. /// /// The gateway binds to an OS-assigned loopback port, prepares agent-specific hook/gateway wiring, -/// waits for health before spawning the child, and restores temporary files after the child and +/// waits for health before spawning the child, and removes temporary state after the child and /// server shut down. The child's exit status is preserved when it fits in `ExitCode`; otherwise the /// launcher reports generic failure. pub(crate) async fn run( @@ -112,7 +111,8 @@ impl TransparentRun { crate::plugins::lifecycle::active_dynamic_plugin_components(explicit_config, &resolved)? }; let (agent, argv) = resolve_agent_and_argv(&command, &resolved.agents)?; - if !dry_run && let Some(probe) = version_probe_argv(agent, &argv) { + if !dry_run { + let probe = crate::agent_process::version_probe_argv(agent, &argv); validate_agent_version(agent, &probe).await?; } let listener = TcpListener::bind("127.0.0.1:0").await?; @@ -235,19 +235,9 @@ const fn default_command_for(agent: CodingAgent) -> &'static str { } /// Builds a version probe that preserves wrappers such as `npx codex` or `mise exec -- codex`. -/// Opaque wrappers remain supported: when the configured argv never names the selected host, -/// installation and doctor retain version enforcement while transparent launch skips this probe. -fn version_probe_argv(agent: CodingAgent, argv: &[String]) -> Option> { - let agent_index = argv - .iter() - .position(|argument| CodingAgent::infer(argument) == Some(agent))?; - let mut probe = argv[..=agent_index].to_vec(); - probe.push("--version".into()); - Some(probe) -} - +/// Opaque wrappers remain supported when their `--version` output identifies the selected host. async fn validate_agent_version(agent: CodingAgent, probe: &[String]) -> Result<(), CliError> { - let mut command = command_from_argv(probe); + let mut command = crate::agent_process::tokio_command(probe); command .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) @@ -283,92 +273,6 @@ async fn validate_agent_version(agent: CodingAgent, probe: &[String]) -> Result< .map_err(CliError::Launch) } -/// Creates an async child command from the resolved argv. Windows command scripts require an -/// explicit `cmd.exe` invocation; using the same builder for probes and the real launch keeps npm -/// shims such as `codex.cmd` and `claude.cmd` consistent. -fn command_from_argv(argv: &[String]) -> Command { - debug_assert!(!argv.is_empty()); - #[cfg(windows)] - { - let program = resolve_windows_program(&argv[0]); - if is_windows_command_script(&program) { - let mut command = Command::new( - std::env::var_os("COMSPEC").unwrap_or_else(|| std::ffi::OsString::from("cmd.exe")), - ); - command - .args(["/d", "/s", "/c"]) - .arg(windows_command_line(&program, &argv[1..])); - return command; - } - let mut command = Command::new(program); - command.args(&argv[1..]); - return command; - } - #[cfg(not(windows))] - { - let mut command = Command::new(&argv[0]); - command.args(&argv[1..]); - command - } -} - -#[cfg(windows)] -fn resolve_windows_program(program: &str) -> PathBuf { - let path = Path::new(program); - let directories = if path.components().count() > 1 || path.is_absolute() { - vec![PathBuf::new()] - } else { - std::env::var_os("PATH") - .as_deref() - .map(std::env::split_paths) - .into_iter() - .flatten() - .collect() - }; - let extensions = if path.extension().is_none() { - std::env::var("PATHEXT") - .unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".into()) - .split(';') - .map(str::to_string) - .collect::>() - } else { - vec![String::new()] - }; - for directory in directories { - for extension in &extensions { - let candidate = directory.join(format!("{program}{extension}")); - if candidate.is_file() { - return candidate; - } - } - } - path.to_path_buf() -} - -#[cfg(windows)] -fn is_windows_command_script(program: &Path) -> bool { - program - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| { - extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat") - }) -} - -#[cfg(any(windows, test))] -fn windows_command_line(program: &Path, args: &[String]) -> String { - std::iter::once(crate::plugin_host::shell_quote_arg_for_platform( - &program.display().to_string(), - true, - )) - .chain( - args.iter() - .map(|argument| crate::plugin_host::shell_quote_arg_for_platform(argument, true)), - ) - .collect::>() - .join(" ") -} - // Uses an explicit `--agent` when present and otherwise infers the agent from argv[0]. Inference is // intentionally late so configured commands and direct CLI commands share the same validation path. fn resolved_agent(command: &RunCommand, argv: &[String]) -> Result { @@ -387,12 +291,8 @@ fn resolved_agent(command: &RunCommand, argv: &[String]) -> Result Option> { - let command = match agent { - CodingAgent::ClaudeCode => agents.claude.command.as_ref(), - CodingAgent::Codex => agents.codex.command.as_ref(), - CodingAgent::Hermes => agents.hermes.command.as_ref(), - }?; - let argv: Vec<_> = command.split_whitespace().map(ToOwned::to_owned).collect(); + let command = agents.get(agent).command.as_ref()?; + let argv = crate::agent_process::command_argv(command); (!argv.is_empty()).then_some(argv) } @@ -400,16 +300,9 @@ struct PreparedRun { argv: Vec, env: Vec<(String, String)>, temp_dirs: Vec, - hermes_restore: Option, notes: Vec, } -struct HermesRestore { - path: PathBuf, - backup_path: Option, - had_original: bool, -} - struct RunningGateway { shutdown_tx: oneshot::Sender<()>, task: JoinHandle>, @@ -461,7 +354,6 @@ impl PreparedRun { argv, env: vec![("NEMO_RELAY_GATEWAY_URL".into(), gateway_url.into())], temp_dirs: Vec::new(), - hermes_restore: None, notes: Vec::new(), }; if let Some(path) = path_with_transparent_hook_dir() { @@ -523,7 +415,10 @@ impl PreparedRun { &root.join("hooks/hooks.json"), generated_hooks( CodingAgent::ClaudeCode, - &hook_forward_command(&transparent_hook_executable(), CodingAgent::ClaudeCode), + &transparent_hook_forward_command( + &transparent_hook_executable(), + CodingAgent::ClaudeCode, + ), ), )?; insert_after_agent( @@ -569,7 +464,8 @@ impl PreparedRun { or pass `--openai-base-url` to an upstream that needs no key." ); } - let hook_command = hook_forward_command(&transparent_hook_executable(), CodingAgent::Codex); + let hook_command = + transparent_hook_forward_command(&transparent_hook_executable(), CodingAgent::Codex); let mut args = vec![ "--config".to_string(), "features.hooks=true".to_string(), @@ -589,23 +485,25 @@ impl PreparedRun { insert_after_agent(&mut self.argv, CodingAgent::Codex, args); } - // Hermes discovers hooks from `.hermes/config.yaml` instead of command-line flags. For - // transparent runs, temporarily merge gateway hook-forward entries into the configured Hermes - // hook file, then restore it after the child exits. + // Hermes discovers hooks from `.hermes/config.yaml` instead of command-line flags. A + // process-private HERMES_HOME exposes dynamic hooks without rewriting user configuration. fn prepare_hermes(&mut self, hooks_path: Option<&std::path::Path>) -> Result<(), CliError> { - let path = hermes_hooks_path(hooks_path)?; - let (had_original, backup_path) = backup_existing_hermes_hooks(&path)?; - write_merged_hermes_hooks(&path)?; + let source_config = hermes_hooks_path(hooks_path)?; + let source_home = source_config.parent().ok_or_else(|| { + CliError::Launch(format!( + "Hermes config path {} has no parent directory", + source_config.display() + )) + })?; + let overlay_home = create_hermes_overlay(source_home, &source_config)?; self.env.push(("HERMES_ACCEPT_HOOKS".into(), "1".into())); + self.env + .push(("HERMES_HOME".into(), overlay_home.display().to_string())); self.notes.push(format!( - "temporarily merged NeMo Relay hooks into {}", - path.display() + "using an isolated Hermes config overlay for {}", + source_config.display() )); - self.hermes_restore = Some(HermesRestore { - path, - backup_path, - had_original, - }); + self.temp_dirs.push(overlay_home); Ok(()) } @@ -615,7 +513,7 @@ impl PreparedRun { let path = hermes_hooks_path(hooks_path)?; self.env.push(("HERMES_ACCEPT_HOOKS".into(), "1".into())); self.notes.push(format!( - "would temporarily merge NeMo Relay hooks into {}", + "would create an isolated Hermes config overlay for {}", path.display() )); Ok(()) @@ -624,7 +522,7 @@ 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 { - let mut command = command_from_argv(&self.argv); + let mut command = crate::agent_process::tokio_command(&self.argv); for (name, value) in &self.env { command.env(name, value); } @@ -632,21 +530,16 @@ impl PreparedRun { child.wait().await.map_err(CliError::from) } - // Removes temporary directories and restores patched hook files after the child exits. Restore - // errors are surfaced after the child status is collected so cleanup problems are not hidden. + // Removes process-private plugin and configuration directories after the child exits. fn restore(&self) -> Result<(), CliError> { for dir in &self.temp_dirs { - let _ = std::fs::remove_dir_all(dir); + match std::fs::remove_dir_all(dir) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(CliError::Io(error)), + } } - if let Some(hermes) = &self.hermes_restore { - restore_hook_file( - &hermes.path, - hermes.backup_path.as_deref(), - hermes.had_original, - "Hermes", - )?; - } Ok(()) } @@ -937,22 +830,11 @@ fn push_status_border( // `PATH`, which would cause hooks to exit with status 127 (command not found). Falls back // to the bare name when `current_exe` is unavailable so behavior degrades to the previous // install-style assumption rather than failing to launch. -fn transparent_hook_executable() -> String { +fn transparent_hook_executable() -> PathBuf { std::env::current_exe() - .ok() - .and_then(|path| { - path.to_str().map(|s| { - #[cfg(windows)] - { - s.replace('\\', "/") - } - #[cfg(not(windows))] - { - s.to_owned() - } - }) - }) - .unwrap_or_else(|| "nemo-relay".to_string()) + .map(|path| path.canonicalize().unwrap_or(path)) + .map(crate::plugin_host::portable_executable_path) + .unwrap_or_else(|_| PathBuf::from("nemo-relay")) } // Appends the running gateway binary's directory to the child agent PATH. Transparent hooks use @@ -1004,24 +886,46 @@ fn write_hooks(path: &Path, hooks: Value) -> Result<(), CliError> { Ok(()) } -// Backs up an existing Hermes hook config before run-mode patching. -fn backup_existing_hermes_hooks(path: &Path) -> Result<(bool, Option), CliError> { - let had_original = path.exists(); - if !had_original { - return Ok((false, None)); +// Creates a per-process Hermes home whose user state points at the original profile while the +// config and hook approval files remain private to this transparent run. Hermes has no standalone +// config-file override, so `HERMES_HOME` is its supported process-scoped configuration boundary. +fn create_hermes_overlay(source_home: &Path, source_config: &Path) -> Result { + let overlay = temp_dir("nemo-relay-hermes-home")?; + if let Err(error) = populate_hermes_overlay(&overlay, source_home, source_config) { + let _ = std::fs::remove_dir_all(&overlay); + return Err(error); } - let backup = path.with_extension(format!("yaml.nemo-relay-run.bak.{}", timestamp()?)); - std::fs::copy(path, &backup)?; - Ok((true, Some(backup))) + Ok(overlay) } -// Creates the Hermes config parent directory when needed, merges generated gateway hooks with any -// existing YAML config, and writes the patched YAML used for this transparent run. -fn write_merged_hermes_hooks(path: &Path) -> Result<(), CliError> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; +fn populate_hermes_overlay( + overlay: &Path, + source_home: &Path, + source_config: &Path, +) -> Result<(), CliError> { + let absolute_overlay = overlay + .canonicalize() + .unwrap_or_else(|_| overlay.to_path_buf()); + match std::fs::read_dir(source_home) { + Ok(entries) => { + for entry in entries { + let entry = entry?; + let name = entry.file_name(); + if name == "config.yaml" || name == "shell-hooks-allowlist.json" { + continue; + } + let source = entry.path(); + let absolute_source = source.canonicalize().unwrap_or_else(|_| source.clone()); + if absolute_overlay.starts_with(absolute_source) { + continue; + } + link_hermes_state(&source, &overlay.join(name), entry.file_type()?.is_dir())?; + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(CliError::Io(error)), } - let existing = match std::fs::read_to_string(path) { + let existing = match std::fs::read_to_string(source_config) { Ok(raw) => raw, Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), Err(error) => return Err(CliError::Io(error)), @@ -1031,12 +935,36 @@ fn write_merged_hermes_hooks(path: &Path) -> Result<(), CliError> { .map(crate::plugin_host::portable_executable_path) .unwrap_or_else(|_| PathBuf::from("nemo-relay")); let contents = crate::hermes::transparent_config(&existing, &relay)?; - std::fs::write(path, contents)?; + std::fs::write(overlay.join("config.yaml"), contents)?; Ok(()) } -// Chooses the Hermes hook file that transparent run should patch. If setup recorded a specific -// path, reuse it; otherwise fall back to the Hermes home config file that Hermes itself reads. +fn link_hermes_state(source: &Path, destination: &Path, directory: bool) -> Result<(), CliError> { + #[cfg(unix)] + { + let _ = directory; + std::os::unix::fs::symlink(source, destination)?; + Ok(()) + } + #[cfg(windows)] + { + if directory { + std::os::windows::fs::symlink_dir(source, destination)?; + } else { + std::fs::hard_link(source, destination)?; + } + Ok(()) + } + #[cfg(not(any(unix, windows)))] + { + let _ = directory; + std::fs::copy(source, destination)?; + Ok(()) + } +} + +// Chooses the Hermes config used as the source for a transparent-run overlay. If setup recorded a +// specific path, reuse it; otherwise fall back to the active Hermes home. fn hermes_hooks_path(configured: Option<&Path>) -> Result { if let Some(path) = configured { return Ok(path.to_path_buf()); @@ -1052,35 +980,6 @@ fn hermes_hooks_path(configured: Option<&Path>) -> Result { Ok(PathBuf::from(home).join(".hermes").join("config.yaml")) } -fn restore_hook_file( - path: &Path, - backup_path: Option<&Path>, - had_original: bool, - label: &str, -) -> Result<(), CliError> { - match (backup_path, had_original) { - (Some(backup), true) => { - std::fs::copy(backup, path).map_err(|error| { - CliError::Launch(format!( - "failed to restore {label} hooks from {}: {error}", - backup.display() - )) - })?; - let _ = std::fs::remove_file(backup); - } - (_, false) if path.exists() => { - std::fs::remove_file(path).map_err(|error| { - CliError::Launch(format!( - "failed to remove temporary {label} hooks {}: {error}", - path.display() - )) - })?; - } - _ => {} - } - Ok(()) -} - // Converts JSON hook groups into inline TOML arrays for Codex `--config` flags. The function // preserves matchers when present and assumes generated hook groups contain one command hook. fn hook_groups_toml(value: &Value) -> String { @@ -1106,23 +1005,14 @@ fn toml_string(value: &str) -> String { format!("\"{escaped}\"") } -// Creates a timestamped directory under the OS temp directory. The timestamp suffix avoids -// collisions between concurrent transparent runs without keeping persistent state. +// Creates a uniquely named directory under the OS temp directory. UUIDv7 avoids collisions +// between concurrent transparent runs without keeping persistent coordination state. fn temp_dir(prefix: &str) -> Result { - let path = std::env::temp_dir().join(format!("{prefix}-{}", timestamp()?)); + let path = std::env::temp_dir().join(format!("{prefix}-{}", uuid::Uuid::now_v7())); std::fs::create_dir_all(&path)?; Ok(path) } -// Returns a monotonic-enough wall-clock nanosecond stamp for temp and backup names. System time -// errors become launcher errors because paths cannot be safely generated without a timestamp. -fn timestamp() -> Result { - Ok(SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|error| CliError::Launch(error.to_string()))? - .as_nanos()) -} - #[cfg(test)] #[path = "../tests/coverage/launcher_tests.rs"] mod tests; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 97bdb43b6..c6fe9ac67 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -4,6 +4,7 @@ //! NeMo Relay coding-agent gateway CLI. mod adapters; +mod agent_process; mod alignment; mod banner; mod coding_agent; diff --git a/crates/cli/src/mcp/gateway.rs b/crates/cli/src/mcp/gateway.rs index d8bfefa37..5db6a8a56 100644 --- a/crates/cli/src/mcp/gateway.rs +++ b/crates/cli/src/mcp/gateway.rs @@ -12,9 +12,10 @@ use crate::config::CodingAgent; use crate::config::ServerArgs; use crate::error::CliError; use crate::install_generation::InstallGeneration; -use crate::sidecar::{GatewayBootstrap, GatewaySpec}; +use crate::sidecar::{GatewayEndpoint, GatewaySpec}; -const UNHEALTHY_CHECKS_BEFORE_RESTART: u8 = 3; +const UNHEALTHY_CONFIRMATIONS: u8 = 3; +const UNHEALTHY_CONFIRMATION_INTERVAL: Duration = Duration::from_millis(50); pub(super) struct GatewayPlan { spec: GatewaySpec, @@ -45,24 +46,24 @@ impl GatewayPlan { } pub(super) async fn acquire(self) -> Result { - let bootstrap = ensure_gateway(self.spec.clone(), self.generation.clone()).await?; - let monitor = tokio::spawn(async move { self.monitor(bootstrap.endpoint.url).await }); + let endpoint = ensure_gateway(self.spec.clone(), self.generation.clone()).await?; + let monitor = tokio::spawn(async move { self.monitor(endpoint).await }); Ok(GatewayLease { monitor }) } - async fn monitor(self, gateway_url: String) -> Result<(), CliError> { + async fn monitor(self, endpoint: crate::sidecar::GatewayEndpoint) -> Result<(), CliError> { let health_spec = self.spec.clone(); let restart_spec = self.spec.clone(); let restart_generation = self.generation.clone(); let verify_generation = self.generation; - maintain_gateway_with_generation( + maintain_gateway_instances_with_generation( self.spec.bind(), - gateway_url, + endpoint, self.heartbeat_interval, - move |url| { + move |url, _expected_instance| { let spec = health_spec.clone(); async move { - tokio::task::spawn_blocking(move || spec.is_healthy(&url)) + tokio::task::spawn_blocking(move || spec.healthy_instance(&url)) .await .map_err(|error| { CliError::Launch(format!("gateway heartbeat task failed: {error}")) @@ -93,7 +94,16 @@ impl GatewayPlan { heartbeat_interval, generation: None, }; - let monitor = tokio::spawn(async move { plan.monitor(gateway_url).await }); + let instance_id = plan + .spec + .healthy_instance(&gateway_url) + .unwrap_or_else(|| "test-initial-instance".into()); + let endpoint = crate::sidecar::GatewayEndpoint { + address: bind, + url: gateway_url, + instance_id, + }; + let monitor = tokio::spawn(async move { plan.monitor(endpoint).await }); GatewayLease { monitor } } } @@ -120,16 +130,16 @@ impl Drop for GatewayLease { async fn ensure_gateway( spec: GatewaySpec, generation: Option, -) -> Result { +) -> Result { tokio::task::spawn_blocking(move || { if let Some(generation) = generation.as_ref() { generation.verify_current()?; } - let bootstrap = spec.ensure()?; + let endpoint = spec.ensure()?; if let Some(generation) = generation.as_ref() { verify_bootstrap_generation(generation)?; } - Ok(bootstrap) + Ok(endpoint) }) .await .map_err(|error| CliError::Launch(format!("gateway bootstrap task failed: {error}")))? @@ -166,7 +176,7 @@ where H: FnMut(String) -> HFuture, HFuture: std::future::Future>, R: FnMut(SocketAddr) -> RFuture, - RFuture: std::future::Future>, + RFuture: std::future::Future>, { maintain_gateway_with_generation( bind, @@ -179,71 +189,131 @@ where .await } +#[cfg(test)] pub(super) async fn maintain_gateway_with_generation( bind: SocketAddr, - mut gateway_url: String, + gateway_url: String, heartbeat_interval: Duration, mut healthy: H, - mut restart: R, - mut verify_generation: G, + restart: R, + verify_generation: G, ) -> Result<(), CliError> where H: FnMut(String) -> HFuture, HFuture: std::future::Future>, R: FnMut(SocketAddr) -> RFuture, - RFuture: std::future::Future>, + RFuture: std::future::Future>, + G: FnMut() -> GFuture, + GFuture: std::future::Future>, +{ + maintain_gateway_instances_with_generation( + bind, + crate::sidecar::GatewayEndpoint { + address: bind, + url: gateway_url, + instance_id: "test-initial-instance".into(), + }, + heartbeat_interval, + move |url, expected_instance| { + let probe = healthy(url); + async move { + probe + .await + .map(|is_healthy| is_healthy.then_some(expected_instance)) + } + }, + restart, + verify_generation, + ) + .await +} + +async fn maintain_gateway_instances_with_generation( + bind: SocketAddr, + mut endpoint: crate::sidecar::GatewayEndpoint, + heartbeat_interval: Duration, + mut healthy: H, + mut restart: R, + mut verify_generation: G, +) -> Result<(), CliError> +where + H: FnMut(String, String) -> HFuture, + HFuture: std::future::Future, CliError>>, + R: FnMut(SocketAddr) -> RFuture, + RFuture: std::future::Future>, G: FnMut() -> GFuture, GFuture: std::future::Future>, { let mut heartbeat = tokio::time::interval(heartbeat_interval); - let mut recovery = RecoveryState::default(); + let mut recovery = RecoveryState::new(endpoint.instance_id.clone()); heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); heartbeat.tick().await; loop { heartbeat.tick().await; verify_generation().await?; - if healthy(gateway_url.clone()).await? { - recovery.record_healthy(); - continue; + let mut observed_instance = None; + for confirmation in 0..UNHEALTHY_CONFIRMATIONS { + if confirmation > 0 { + tokio::time::sleep(UNHEALTHY_CONFIRMATION_INTERVAL).await; + verify_generation().await?; + } + observed_instance = + healthy(endpoint.url.clone(), recovery.instance_id().into()).await?; + if observed_instance.is_some() { + break; + } } - if !recovery.record_failure()? { + if let Some(instance_id) = observed_instance { + recovery.observe(instance_id)?; continue; } + recovery.require_restart()?; verify_generation().await?; - let bootstrap = restart(bind).await?; - gateway_url = bootstrap.endpoint.url; - recovery.record_recovery(bootstrap.started); + let recovered = restart(bind).await?; + recovery.observe(recovered.instance_id.clone())?; + endpoint = recovered; } } -#[derive(Default)] struct RecoveryState { - consecutive_failures: u8, - restarted: bool, + instance_id: String, + recovered: bool, } impl RecoveryState { - fn record_healthy(&mut self) { - self.consecutive_failures = 0; + fn new(instance_id: String) -> Self { + Self { + instance_id, + recovered: false, + } + } + + fn instance_id(&self) -> &str { + &self.instance_id } - /// Returns true when the caller should coordinate recovery. - fn record_failure(&mut self) -> Result { - self.consecutive_failures = self.consecutive_failures.saturating_add(1); - if self.consecutive_failures < UNHEALTHY_CHECKS_BEFORE_RESTART { - return Ok(false); + fn observe(&mut self, instance_id: String) -> Result<(), CliError> { + if instance_id == self.instance_id { + return Ok(()); } - if self.restarted { + if self.recovered { return Err(CliError::Launch( - "shared Relay gateway became unhealthy after its coordinated restart".into(), + "shared Relay gateway was replaced again after its coordinated restart".into(), )); } - Ok(true) + self.instance_id = instance_id; + self.recovered = true; + Ok(()) } - fn record_recovery(&mut self, started: bool) { - self.consecutive_failures = 0; - self.restarted |= started; + fn require_restart(&self) -> Result<(), CliError> { + if self.recovered { + Err(CliError::Launch( + "shared Relay gateway became unhealthy after its coordinated restart".into(), + )) + } else { + Ok(()) + } } } diff --git a/crates/cli/src/plugin_host/codex_app_server.rs b/crates/cli/src/plugin_host/codex_app_server.rs index 3c72e043a..5cf91934d 100644 --- a/crates/cli/src/plugin_host/codex_app_server.rs +++ b/crates/cli/src/plugin_host/codex_app_server.rs @@ -225,18 +225,6 @@ pub(super) fn hook_state_key_path(key: &str) -> String { format!("hooks.state.{quoted}") } -#[cfg(not(windows))] fn codex_app_server_command() -> Command { - let mut command = Command::new("codex"); - command.arg("app-server"); - command -} - -#[cfg(windows)] -fn codex_app_server_command() -> Command { - let mut command = Command::new( - std::env::var_os("COMSPEC").unwrap_or_else(|| std::ffi::OsString::from("cmd.exe")), - ); - command.args(["/d", "/s", "/c", "codex app-server"]); - command + crate::agent_process::std_command(&["codex".into(), "app-server".into()]) } diff --git a/crates/cli/src/plugin_host/mod.rs b/crates/cli/src/plugin_host/mod.rs index fe013aa4d..91e0b25dd 100644 --- a/crates/cli/src/plugin_host/mod.rs +++ b/crates/cli/src/plugin_host/mod.rs @@ -34,8 +34,8 @@ pub(crate) fn install_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Res install_codex(gateway_url, &plugin_root.join("hooks").join("hooks.json")).map(|_| ()) } -pub(crate) fn stop_plugin_gateway(agent: CodingAgent) -> Result<(), String> { - crate::sidecar::stop_owned_sidecar(agent) +pub(crate) fn stop_plugin_gateway() -> Result<(), String> { + crate::sidecar::stop_owned_sidecar(crate::sidecar::DEFAULT_URL) } pub(crate) fn uninstall_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Result<(), String> { diff --git a/crates/cli/src/plugin_install/host.rs b/crates/cli/src/plugin_install/host.rs index 23318896a..1b527e994 100644 --- a/crates/cli/src/plugin_install/host.rs +++ b/crates/cli/src/plugin_install/host.rs @@ -5,7 +5,6 @@ use std::env; use std::path::{Path, PathBuf}; -use std::process::Command; use serde_json::Value; @@ -450,30 +449,12 @@ pub(super) fn format_command(program: &str, args: &[String]) -> String { "$ {}", parts .iter() - .map(|part| shell_quote(part)) + .map(|part| { crate::plugin_host::shell_quote_arg_for_platform(part, cfg!(windows)) }) .collect::>() .join(" ") ) } -fn shell_quote(raw: &str) -> String { - if raw.chars().all(|ch| { - ch.is_ascii_alphanumeric() - || matches!(ch, '/' | '\\' | ':' | '.' | '_' | '-' | '=' | '@' | '+') - }) { - raw.into() - } else { - let mut escaped = String::new(); - for ch in raw.chars() { - if matches!(ch, '"' | '\\' | '$' | '`') { - escaped.push('\\'); - } - escaped.push(ch); - } - format!("\"{escaped}\"") - } -} - #[derive(Debug, Clone)] pub(super) struct CommandOutput { pub(super) status: i32, @@ -508,43 +489,18 @@ impl CommandRunner for RealCommandRunner { } fn resolve_executable(&self, command: &str) -> Result, String> { - Ok(find_executable(command)) + Ok(crate::agent_process::resolve_executable(command)) } fn run(&self, program: &Path, args: &[String]) -> Result { - #[cfg(windows)] - if is_windows_command_script(program) { - let status = Command::new(env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())) - .args(["/d", "/s", "/c"]) - .arg(windows_command_line(program, args)) - .status() - .map_err(|error| format!("failed to run {}: {error}", program.display()))?; - return Ok(status.code().unwrap_or(1)); - } - - let status = Command::new(program) - .args(args) + let status = crate::agent_process::std_command(&command_argv(program, args)) .status() .map_err(|error| format!("failed to run {}: {error}", program.display()))?; Ok(status.code().unwrap_or(1)) } fn run_quiet(&self, program: &Path, args: &[String]) -> Result { - #[cfg(windows)] - if is_windows_command_script(program) { - let status = Command::new(env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())) - .args(["/d", "/s", "/c"]) - .arg(windows_command_line(program, args)) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map_err(|error| format!("failed to run {}: {error}", program.display()))?; - return Ok(status.code().unwrap_or(1)); - } - - let status = Command::new(program) - .args(args) + let status = crate::agent_process::std_command(&command_argv(program, args)) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -554,24 +510,19 @@ impl CommandRunner for RealCommandRunner { } fn run_capture(&self, program: &Path, args: &[String]) -> Result { - #[cfg(windows)] - if is_windows_command_script(program) { - let output = Command::new(env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())) - .args(["/d", "/s", "/c"]) - .arg(windows_command_line(program, args)) - .output() - .map_err(|error| format!("failed to run {}: {error}", program.display()))?; - return Ok(command_output(output)); - } - - let output = Command::new(program) - .args(args) + let output = crate::agent_process::std_command(&command_argv(program, args)) .output() .map_err(|error| format!("failed to run {}: {error}", program.display()))?; Ok(command_output(output)) } } +fn command_argv(program: &Path, args: &[String]) -> Vec { + std::iter::once(program.display().to_string()) + .chain(args.iter().cloned()) + .collect() +} + fn command_output(output: std::process::Output) -> CommandOutput { CommandOutput { status: output.status.code().unwrap_or(1), @@ -579,53 +530,3 @@ fn command_output(output: std::process::Output) -> CommandOutput { stderr: String::from_utf8_lossy(&output.stderr).into_owned(), } } - -fn find_executable(command: &str) -> Option { - let path = env::var_os("PATH")?; - let candidates = env::split_paths(&path); - let extensions = executable_extensions(command); - for dir in candidates { - for extension in &extensions { - let candidate = dir.join(format!("{command}{extension}")); - if candidate.is_file() { - return Some(candidate); - } - } - } - None -} - -fn executable_extensions(command: &str) -> Vec { - if cfg!(windows) && Path::new(command).extension().is_none() { - env::var("PATHEXT") - .unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".into()) - .split(';') - .map(str::to_string) - .collect() - } else { - vec![String::new()] - } -} - -#[cfg(windows)] -fn is_windows_command_script(program: &Path) -> bool { - program - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| { - extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat") - }) -} - -#[cfg(windows)] -fn windows_command_line(program: &Path, args: &[String]) -> String { - std::iter::once(windows_command_argument(&program.display().to_string())) - .chain(args.iter().map(|arg| windows_command_argument(arg))) - .collect::>() - .join(" ") -} - -#[cfg(windows)] -fn windows_command_argument(argument: &str) -> String { - format!("\"{}\"", argument.replace('"', "\\\"")) -} diff --git a/crates/cli/src/plugin_install/marketplace.rs b/crates/cli/src/plugin_install/marketplace.rs index c9a8971f8..d9eb7e56d 100644 --- a/crates/cli/src/plugin_install/marketplace.rs +++ b/crates/cli/src/plugin_install/marketplace.rs @@ -178,7 +178,7 @@ pub(super) fn plugin_mcp_config( "command": relay, "args": ["mcp", "--agent", "codex"], "env": { - "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632", + "NEMO_RELAY_GATEWAY_BIND": crate::sidecar::DEFAULT_BIND, (GENERATION_FILE_ENV): generation_fence }, "env_vars": plugin_mcp_env_vars()?, @@ -189,7 +189,7 @@ pub(super) fn plugin_mcp_config( "command": relay, "args": ["mcp", "--agent", "claude"], "env": { - "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632", + "NEMO_RELAY_GATEWAY_BIND": crate::sidecar::DEFAULT_BIND, (GENERATION_FILE_ENV): generation_fence }, "alwaysLoad": true diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/plugin_install/mod.rs index 599c56b71..e4823ae53 100644 --- a/crates/cli/src/plugin_install/mod.rs +++ b/crates/cli/src/plugin_install/mod.rs @@ -42,7 +42,7 @@ use state::{ remove_path, state_path, write_state, write_state_for_host, }; -pub(super) const DEFAULT_GATEWAY_URL: &str = "http://127.0.0.1:47632"; +pub(super) use crate::sidecar::DEFAULT_URL as DEFAULT_GATEWAY_URL; pub(super) const MARKETPLACE_NAME: &str = "nemo-relay-local"; pub(super) const PLUGIN_NAME: &str = "nemo-relay-plugin"; pub(super) const RELAY_COMMAND: &str = "nemo-relay"; @@ -449,6 +449,7 @@ fn install_hermes_host( println!("configure Hermes MCP and hooks at {}", config.display()); return Ok(()); } + crate::hermes::retire_persistent_gateway().map_err(|error| error.to_string())?; crate::hermes::install_persistent(&config, &relay).map_err(|error| error.to_string())?; if !options.skip_doctor { crate::hermes::diagnose_persistent(&config)?; @@ -466,6 +467,7 @@ fn uninstall_hermes_host(options: &PluginInstallOptions) -> Result<(), String> { ); return Ok(()); } + crate::hermes::retire_persistent_gateway().map_err(|error| error.to_string())?; crate::hermes::uninstall_persistent(&config).map_err(|error| error.to_string())?; println!("uninstalled Hermes integration"); Ok(()) @@ -659,7 +661,7 @@ fn install_host_locked( let staged = stage_plugin_marketplace(host, &relay, &layout, options)?; match begin_force_replacement(host, &layout, preflight, options, runner, setup_runner) { Ok(mut snapshot) => { - if let Err(error) = setup_runner.refresh_gateway(host) { + if let Err(error) = setup_runner.refresh_gateway() { staged.cleanup(); return restore_force_replacement_after_error( host, @@ -684,7 +686,7 @@ fn install_host_locked( }; if options.force && staged.is_none() { if !options.dry_run && plugin_uses_mcp(host) { - setup_runner.refresh_gateway(host)?; + setup_runner.refresh_gateway()?; } force_cleanup_existing_install(host, &layout, options, runner, setup_runner)?; } @@ -903,7 +905,7 @@ fn retire_installed_generation( if retirement.is_none() && existing_install && !legacy_plugin_without_mcp(host, plugin_root)? { return Err(missing_generation_fence_error(host, &generation_fence)); } - setup_runner.refresh_gateway(host)?; + setup_runner.refresh_gateway()?; Ok(retirement) } @@ -919,7 +921,7 @@ fn retire_replacement_before_rollback( let mut retirement = GenerationRetirement::acquire(&layout.generation_fence) .map_err(|cause| invalid_generation_fence_error(host, &layout.generation_fence, &cause))? .ok_or_else(|| missing_generation_fence_error(host, &layout.generation_fence))?; - setup_runner.refresh_gateway(host)?; + setup_runner.refresh_gateway()?; retirement.invalidate_for_replacement().map_err(|error| { format!( "failed to retire replacement MCP generation {} before rollback: {error}", diff --git a/crates/cli/src/plugin_install/setup.rs b/crates/cli/src/plugin_install/setup.rs index f2ed1a21a..c8931977d 100644 --- a/crates/cli/src/plugin_install/setup.rs +++ b/crates/cli/src/plugin_install/setup.rs @@ -87,7 +87,7 @@ pub(super) trait PluginSetupRunner { Ok(()) } - fn refresh_gateway(&self, _host: IntegrationHost) -> Result<(), String> { + fn refresh_gateway(&self) -> Result<(), String> { Ok(()) } @@ -150,16 +150,8 @@ impl PluginSetupRunner for RealPluginSetupRunner { } } - fn refresh_gateway(&self, host: IntegrationHost) -> Result<(), String> { - match host { - IntegrationHost::Codex => plugin_host::stop_plugin_gateway(CodingAgent::Codex), - IntegrationHost::ClaudeCode => { - plugin_host::stop_plugin_gateway(CodingAgent::ClaudeCode) - } - IntegrationHost::Hermes | IntegrationHost::All => { - unreachable!("all is expanded before plugin setup") - } - } + fn refresh_gateway(&self) -> Result<(), String> { + plugin_host::stop_plugin_gateway() } fn setup( diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index 5161ec470..9e5d015f0 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -49,6 +49,7 @@ pub(crate) struct AppState { pub(crate) sessions: SessionManager, pub(crate) last_activity: Arc>, pub(crate) bootstrap_shutdown: Option, + pub(crate) instance_id: String, } #[derive(Clone)] @@ -214,6 +215,7 @@ async fn serve_listener_with_dynamic_inner( bootstrap_challenge_key, bootstrap_shutdown, ); + let instance_id = state.instance_id.clone(); let sessions = state.sessions.clone(); let last_activity = state.last_activity.clone(); let app = router_with_state(state); @@ -223,7 +225,7 @@ async fn serve_listener_with_dynamic_inner( } crate::sidecar::publish_sidecar_owner_from_env(local_address).map_err(CliError::Launch)?; if let Some(path) = ready_file { - write_ready_file(path, local_address)?; + write_ready_file(path, local_address, &instance_id)?; } let idle_shutdown = if matches!(&shutdown_mode, None | Some(ShutdownMode::ProcessSignal)) { plugin_idle_timeout()? @@ -352,6 +354,7 @@ impl AppState { sessions, last_activity: Arc::new(Mutex::new(Instant::now())), bootstrap_shutdown, + instance_id: uuid::Uuid::now_v7().to_string(), } } @@ -470,18 +473,20 @@ async fn healthz(State(state): State, headers: HeaderMap) -> Response "status": if compatible { "ok" } else { "incompatible" }, "service": "nemo-relay", "version": env!("CARGO_PKG_VERSION"), - "bootstrap_protocol": 1 + "bootstrap_protocol": crate::sidecar::BOOTSTRAP_PROTOCOL_VERSION, + "instance_id": state.instance_id, })), ) .into_response() } -fn write_ready_file(path: &Path, bind: SocketAddr) -> Result<(), CliError> { +fn write_ready_file(path: &Path, bind: SocketAddr, instance_id: &str) -> Result<(), CliError> { let bytes = serde_json::to_vec(&serde_json::json!({ "address": bind, "service": "nemo-relay", "version": env!("CARGO_PKG_VERSION"), - "bootstrap_protocol": 1 + "bootstrap_protocol": crate::sidecar::BOOTSTRAP_PROTOCOL_VERSION, + "instance_id": instance_id, })) .map_err(|error| CliError::Launch(format!("failed to encode readiness file: {error}")))?; let temporary = path.with_extension(format!( diff --git a/crates/cli/src/session.rs b/crates/cli/src/session.rs index 1c93a3411..ea58c7064 100644 --- a/crates/cli/src/session.rs +++ b/crates/cli/src/session.rs @@ -473,7 +473,7 @@ impl SessionManager { /// /// Host sessions can remain durable after their current turn ends: Codex may omit `SessionEnd`, /// while Hermes keeps a session open for later resumption. A dormant agent scope must therefore - /// not keep the hook-supervised sidecar alive forever. Active turns, subagents, tools, LLMs, and + /// not keep the MCP-managed sidecar alive forever. Active turns, subagents, tools, LLMs, and /// gateway calls still block idle shutdown; [`Self::close_all`] balances the dormant agent scope /// when the gateway exits. pub(crate) async fn has_open_sessions(&self) -> bool { diff --git a/crates/cli/src/setup.rs b/crates/cli/src/setup.rs index 46c5ee08e..6d734cd2a 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/setup.rs @@ -26,9 +26,8 @@ mod model; pub(crate) use self::model::reset; use self::model::{ ConfigScope, SetupAnswers, agent_key_and_command, build_config, detect_installed_agents, - hermes_config_path_for_agents, home_dir, install_hermes_integration, - plugins_edit_command_for_scope, plugins_resume_command, preview_paths, read_existing_defaults, - save_config, + home_dir, plugins_edit_command_for_scope, plugins_resume_command, preview_paths, + read_existing_defaults, save_config, }; #[cfg(test)] @@ -89,11 +88,7 @@ pub(crate) fn prompt_user( print_codex_api_key_guide(); } - Ok(SetupAnswers { - scope, - agents, - hermes_hooks_path: None, - }) + Ok(SetupAnswers { scope, agents }) } /// Top-level setup entry point used by `nemo-relay config` and the easy-path fallback. @@ -104,26 +99,20 @@ pub(crate) fn prompt_user( /// `nemo-relay config` asks the full set so users can configure multiple agents at once. pub(crate) async fn run(agent_hint: Option) -> Result<(), CliError> { let detected = detect_installed_agents(); - let mut answers = prompt_user(&detected, agent_hint)?; + let answers = prompt_user(&detected, agent_hint)?; let cwd = std::env::current_dir()?; let home = home_dir().ok_or_else(|| { CliError::Config("cannot determine home directory (set $HOME or $USERPROFILE)".into()) })?; - answers.hermes_hooks_path = hermes_config_path_for_agents(&answers.agents, &home); - let doc = build_config(&answers); - let mut preview_paths = preview_paths(answers.scope, &cwd, &home); - preview_paths.extend(answers.hermes_hooks_path.iter().cloned()); + let preview_paths = preview_paths(answers.scope, &cwd, &home); if !confirm_summary(&preview_paths, &doc)? { return Err(CliError::Config("setup cancelled — no config saved".into())); } - let mut written = save_config(&doc, answers.scope, &cwd, &home, agent_hint)?; - if answers.agents.contains(&CodingAgent::Hermes) { - written.extend(install_hermes_integration(&home)?); - } + let written = save_config(&doc, answers.scope, &cwd, &home, agent_hint)?; println!(); println!(" ✓ Saved:"); for path in &written { diff --git a/crates/cli/src/setup/model.rs b/crates/cli/src/setup/model.rs index 9a15eac9d..9147785e9 100644 --- a/crates/cli/src/setup/model.rs +++ b/crates/cli/src/setup/model.rs @@ -66,9 +66,6 @@ pub(super) fn plugins_resume_command(scope: ConfigScope) -> &'static str { pub(crate) struct SetupAnswers { pub scope: ConfigScope, pub agents: Vec, - /// User-owned Hermes config recorded under `[agents.hermes].hooks_path`. Set by `run` so the - /// wizard preview and transparent launcher reference the same host configuration. - pub hermes_hooks_path: Option, } /// Scans `$PATH` for the supported coding-agent binaries and returns the ones present. @@ -85,19 +82,11 @@ pub(crate) fn detect_installed_agents_in(path_var: Option<&std::ffi::OsStr>) -> return Vec::new(); }; // Pairs of (CodingAgent, exec name to look for on $PATH). - let candidates = [ - (CodingAgent::ClaudeCode, "claude"), - (CodingAgent::Codex, "codex"), - (CodingAgent::Hermes, "hermes"), - ]; - candidates + CodingAgent::ALL .into_iter() - .filter_map(|(agent, exec)| { - let found = std::env::split_paths(path_var).any(|dir| { - let candidate = dir.join(exec); - candidate.is_file() - }); - found.then_some(agent) + .filter(|agent| { + crate::agent_process::resolve_executable_in_path(agent.executable(), Some(path_var)) + .is_some() }) .collect() } @@ -127,11 +116,6 @@ pub(super) fn build_agents_table(answers: &SetupAnswers) -> Option { let (key, command) = agent_key_and_command(*agent); let mut agent_table = Table::new(); agent_table["command"] = value(command); - if matches!(agent, CodingAgent::Hermes) - && let Some(path) = answers.hermes_hooks_path.as_deref() - { - agent_table["hooks_path"] = value(path.display().to_string()); - } agents_table.insert(key, Item::Table(agent_table)); } Some(agents_table) @@ -243,20 +227,7 @@ pub(super) fn merge_agents_entry(dst: &mut DocumentMut, src: &DocumentMut, agent /// In both cases this targets the *project* layer; global and system layers are left to direct /// editing because they typically aren't owned by the wizard. pub(crate) fn reset(agent_hint: Option) -> Result<(), CliError> { - reset_project_config(agent_hint)?; - if matches!(agent_hint, Some(CodingAgent::Hermes)) { - let home = home_dir().ok_or_else(|| { - CliError::Config("cannot determine home directory (set $HOME or $USERPROFILE)".into()) - })?; - let removed = crate::hermes::uninstall_persistent(&crate::hermes::user_config_path(&home))?; - for path in removed { - println!( - " ✓ Removed Relay-owned Hermes state from {}", - path.display() - ); - } - } - Ok(()) + reset_project_config(agent_hint) } fn reset_project_config(agent_hint: Option) -> Result<(), CliError> { @@ -308,31 +279,6 @@ fn reset_project_config(agent_hint: Option) -> Result<(), CliError> Ok(()) } -/// Returns Hermes's user-owned configuration path when Hermes is selected. Relay's project/global -/// scope does not change where the Hermes host itself reads MCP servers and shell hooks. -pub(crate) fn hermes_config_path_for_agents( - agents: &[CodingAgent], - home: &Path, -) -> Option { - if !agents.contains(&CodingAgent::Hermes) { - return None; - } - Some(crate::hermes::user_config_path(home)) -} - -/// Installs Hermes's lifecycle-bound MCP client and exact trusted Relay hooks transactionally. -pub(crate) fn install_hermes_integration(home: &Path) -> Result, CliError> { - CodingAgent::Hermes - .validate_executable(Path::new(CodingAgent::Hermes.executable())) - .map_err(CliError::Install)?; - let relay = std::env::current_exe().map_err(|error| { - CliError::Install(format!( - "failed to resolve the nemo-relay executable: {error}" - )) - })?; - crate::hermes::install_persistent(&crate::hermes::user_config_path(home), &relay) -} - /// Pre-filled wizard defaults read from an existing `config.toml`. When the file is missing or /// unparseable the defaults are all-empty and the wizard behaves like a first-run setup. #[derive(Debug, Clone, Default)] diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs index c35bf39ea..2542aa1f0 100644 --- a/crates/cli/src/sidecar.rs +++ b/crates/cli/src/sidecar.rs @@ -23,9 +23,10 @@ use crate::file_io::{LockAttempt, try_lock_exclusive}; #[cfg(test)] pub(crate) use health::healthz_compatible; use health::{ - RelayHealth, probe as probe_relay_health, probe_after_lock as probe_relay_health_after_lock, + RelayHealth, probe_after_lock as probe_relay_health_after_lock, + probe_with_instance as probe_relay_health_with_instance, }; -pub(crate) use health::{healthz, loopback_authority, loopback_bind, parse_loopback_url}; +pub(crate) use health::{healthz, loopback_bind}; use process::DetachedSidecarProcess; #[cfg(all(windows, not(test)))] use process::SidecarJob; @@ -42,7 +43,7 @@ pub(crate) use process::{ WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, terminate_unready_sidecar, windows_sidecar_creation_flags, }; -pub(crate) use state::{BOOTSTRAP_AGENT_ENV, BOOTSTRAP_STATE_DIR_ENV}; +pub(crate) use state::BOOTSTRAP_STATE_DIR_ENV; use state::{ create_private_runtime_dir, open_lock as open_sidecar_lock, read_owner_record, read_ready_file as read_sidecar_ready_file, runtime_dir, @@ -50,8 +51,8 @@ use state::{ pub(crate) use state::{ lock_endpoint as lock_sidecar_endpoint, lock_path as sidecar_lock_path, owner_path as sidecar_owner_path, owner_paths as sidecar_owner_paths, - pid_path as sidecar_pid_path, state_dir as sidecar_state_dir, - validate_owner as validate_sidecar_owner, + owner_pid_path as sidecar_owner_pid_path, pid_path as sidecar_pid_path, + state_dir as sidecar_state_dir, validate_owner as validate_sidecar_owner, }; #[cfg(test)] pub(crate) use state::{ @@ -69,17 +70,12 @@ pub(crate) const HEALTHZ_TIMEOUT: Duration = Duration::from_millis(500); pub(super) const SIDECAR_LOCK_TIMEOUT: Duration = Duration::from_secs(20); const SIDECAR_START_TIMEOUT: Duration = Duration::from_secs(10); -pub(crate) const BOOTSTRAP_PROTOCOL_VERSION: u64 = 1; +pub(crate) const BOOTSTRAP_PROTOCOL_VERSION: u64 = 2; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct GatewayEndpoint { pub(crate) address: SocketAddr, pub(crate) url: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct GatewayBootstrap { - pub(crate) endpoint: GatewayEndpoint, - pub(crate) started: bool, + pub(crate) instance_id: String, } /// Complete launch and compatibility contract for one shared gateway. @@ -126,12 +122,12 @@ impl GatewaySpec { self.bind } - pub(crate) fn ensure(&self) -> Result { + pub(crate) fn ensure(&self) -> Result { ensure_gateway(self) } - pub(crate) fn is_healthy(&self, url: &str) -> bool { - probe_relay_health(url, self.bootstrap_fingerprint.as_deref()) == RelayHealth::Compatible + pub(crate) fn healthy_instance(&self, url: &str) -> Option { + health::compatible_instance_id(url, self.bootstrap_fingerprint.as_deref()) } } @@ -182,12 +178,10 @@ pub(crate) fn ensure_sidecar_bind( agent: CodingAgent, bind: SocketAddr, ) -> Result { - GatewaySpec::new(agent, bind) - .ensure() - .map(|bootstrap| bootstrap.endpoint) + GatewaySpec::new(agent, bind).ensure() } -fn ensure_gateway(spec: &GatewaySpec) -> Result { +fn ensure_gateway(spec: &GatewaySpec) -> Result { let agent = spec.agent; let bind = spec.bind; let bootstrap_fingerprint = spec.bootstrap_fingerprint.as_deref(); @@ -201,7 +195,6 @@ fn ensure_gateway(spec: &GatewaySpec) -> Result { create_private_runtime_dir(&runtime).map_err(|error| { sidecar_start_error( agent, - &url, &runtime, &format!("failed to create {}: {error}", runtime.display()), ) @@ -210,23 +203,20 @@ fn ensure_gateway(spec: &GatewaySpec) -> Result { create_private_runtime_dir(&state).map_err(|error| { sidecar_start_error( agent, - &url, &runtime, &format!("failed to create {}: {error}", state.display()), ) })?; if bind.port() == 0 { return start_sidecar_bind(spec, &runtime, &state, None) - .map_err(|error| sidecar_start_error(agent, &url, &runtime, &error)); + .map_err(|error| sidecar_start_error(agent, &runtime, &error)); } let lock_path = sidecar_lock_path(&state, &url); - let initial_health = probe_relay_health(&url, bootstrap_fingerprint); + let (initial_health, initial_instance) = + probe_relay_health_with_instance(&url, bootstrap_fingerprint); match initial_health { RelayHealth::Compatible => { - return Ok(GatewayBootstrap { - endpoint: GatewayEndpoint { address: bind, url }, - started: false, - }); + return compatible_endpoint(bind, url, initial_instance); } RelayHealth::Incompatible | RelayHealth::Foreign | RelayHealth::Unavailable => {} } @@ -239,7 +229,6 @@ fn ensure_gateway(spec: &GatewaySpec) -> Result { .map_err(|error| { sidecar_start_error( agent, - &url, &runtime, &format!( "failed to open sidecar lock {}: {error}", @@ -252,16 +241,9 @@ fn ensure_gateway(spec: &GatewaySpec) -> Result { match try_lock_exclusive(&lock) { Ok(LockAttempt::Acquired) => break, Ok(LockAttempt::Contended) => { - if probe_relay_health(&url, bootstrap_fingerprint) == RelayHealth::Compatible { - return Ok(GatewayBootstrap { - endpoint: GatewayEndpoint { address: bind, url }, - started: false, - }); - } if Instant::now() >= lock_deadline { return Err(sidecar_start_error( agent, - &url, &runtime, "sidecar lock timed out", )); @@ -271,26 +253,36 @@ fn ensure_gateway(spec: &GatewaySpec) -> Result { Err(error) => { return Err(sidecar_start_error( agent, - &url, &runtime, &format!("failed to acquire sidecar lock: {error}"), )); } } } - match probe_relay_health_after_lock(&url, bootstrap_fingerprint) { + let (health, instance_id) = probe_relay_health_after_lock(&url, bootstrap_fingerprint); + match health { RelayHealth::Compatible => { - return Ok(GatewayBootstrap { - endpoint: GatewayEndpoint { address: bind, url }, - started: false, - }); + return compatible_endpoint(bind, url, instance_id); } RelayHealth::Incompatible => return Err(incompatible_relay_error(agent, &url)), RelayHealth::Foreign => return Err(foreign_listener_error(&url)), RelayHealth::Unavailable => {} } let result = start_sidecar_bind(spec, &runtime, &state, Some(lock)); - result.map_err(|error| sidecar_start_error(agent, &url, &runtime, &error)) + result.map_err(|error| sidecar_start_error(agent, &runtime, &error)) +} + +fn compatible_endpoint( + address: SocketAddr, + url: String, + instance_id: Option, +) -> Result { + let instance_id = instance_id.ok_or_else(|| foreign_listener_error(&url))?; + Ok(GatewayEndpoint { + address, + url, + instance_id, + }) } fn foreign_listener_error(url: &str) -> String { @@ -310,15 +302,9 @@ fn incompatible_relay_error(agent: CodingAgent, url: &str) -> String { ) } -fn sidecar_start_error(agent: CodingAgent, url: &str, runtime: &Path, error: &str) -> String { +fn sidecar_start_error(agent: CodingAgent, runtime: &Path, error: &str) -> String { let log_path = runtime.join(format!("{}-sidecar.log", agent.as_arg())); - let manual = parse_loopback_url(url) - .map(|(host, port)| format!("nemo-relay --bind {}", loopback_authority(&host, port))) - .unwrap_or_else(|_| "nemo-relay --bind 127.0.0.1:47632".into()); - format!( - "{error}; inspect {}; or start the gateway manually with `{manual}`", - log_path.display() - ) + format!("{error}; inspect {}", log_path.display()) } #[cfg(all(test, unix))] @@ -331,7 +317,6 @@ struct ArmedSidecarChild { process: Option, startup_pid_path: PathBuf, state: PathBuf, - agent: CodingAgent, pid: u32, } @@ -340,7 +325,6 @@ impl ArmedSidecarChild { child: Child, startup_pid_path: PathBuf, state: &Path, - agent: CodingAgent, #[cfg(windows)] prepared_job: Option, ) -> Self { let pid = child.id(); @@ -352,7 +336,6 @@ impl ArmedSidecarChild { )), startup_pid_path, state: state.to_path_buf(), - agent, pid, } } @@ -381,14 +364,14 @@ impl Drop for ArmedSidecarChild { // The launcher may exit before readiness while leaving descendants behind. Always // target the detached process group even when the direct child has already exited. process.terminate(); - cleanup_sidecar_records_for_pid(&self.state, self.agent, self.pid); + cleanup_sidecar_records_for_pid(&self.state, self.pid); } let _ = fs::remove_file(&self.startup_pid_path); } } -fn cleanup_sidecar_records_for_pid(runtime: &Path, agent: CodingAgent, pid: u32) { - let Ok(paths) = sidecar_owner_paths(runtime, agent) else { +fn cleanup_sidecar_records_for_pid(runtime: &Path, pid: u32) { + let Ok(paths) = sidecar_owner_paths(runtime) else { return; }; for owner_path in paths { @@ -399,7 +382,7 @@ fn cleanup_sidecar_records_for_pid(runtime: &Path, agent: CodingAgent, pid: u32) continue; } let _ = fs::remove_file(&owner_path); - let _ = fs::remove_file(sidecar_pid_path(runtime, agent, &owner.url)); + let _ = fs::remove_file(sidecar_owner_pid_path(runtime, &owner_path, &owner.url)); } } @@ -408,22 +391,18 @@ fn start_sidecar_bind( runtime: &Path, state: &Path, mut startup_lock: Option, -) -> Result { +) -> Result { let agent = spec.agent; let bind = spec.bind; let sidecar_args = &spec.sidecar_args; let bootstrap_fingerprint = spec.bootstrap_fingerprint.as_deref(); let requested_url = format!("http://{bind}"); if bind.port() != 0 { - match probe_relay_health(&requested_url, bootstrap_fingerprint) { + let (health, instance_id) = + probe_relay_health_with_instance(&requested_url, bootstrap_fingerprint); + match health { RelayHealth::Compatible => { - return Ok(GatewayBootstrap { - endpoint: GatewayEndpoint { - address: bind, - url: requested_url, - }, - started: false, - }); + return compatible_endpoint(bind, requested_url, instance_id); } RelayHealth::Incompatible => { return Err(incompatible_relay_error(agent, &requested_url)); @@ -464,7 +443,6 @@ fn start_sidecar_bind( "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", idle_timeout.as_secs().to_string(), ) - .env(BOOTSTRAP_AGENT_ENV, agent.as_arg()) .env( crate::config::BOOTSTRAP_FINGERPRINT_ENV, bootstrap_fingerprint.unwrap_or_default(), @@ -498,7 +476,6 @@ fn start_sidecar_bind( child, startup_pid_path, state, - agent, #[cfg(windows)] Some(sidecar_job), ); @@ -507,15 +484,16 @@ fn start_sidecar_bind( match read_sidecar_ready_file(&ready_path) { Ok(Some(endpoint)) if (bind.port() == 0 || endpoint.address == bind) - && probe_relay_health(&endpoint.url, bootstrap_fingerprint) - == RelayHealth::Compatible => + && health::compatible_instance_id(&endpoint.url, bootstrap_fingerprint) + .as_deref() + == Some(endpoint.instance_id.as_str()) => { let ownership_lock = match startup_lock.take() { Some(lock) => lock, None => lock_sidecar_endpoint(state, &endpoint.url)?, }; - let owner_path = sidecar_owner_path(state, agent, &endpoint.url); - let pid_path = sidecar_pid_path(state, agent, &endpoint.url); + let owner_path = sidecar_owner_path(state, &endpoint.url); + let pid_path = sidecar_pid_path(state, &endpoint.url); let pid = child.id(); validate_sidecar_owner( &owner_path, @@ -538,10 +516,7 @@ fn start_sidecar_bind( } drop(ownership_lock); let _ = fs::remove_file(&ready_path); - return Ok(GatewayBootstrap { - endpoint, - started: true, - }); + return Ok(endpoint); } Ok(_) => {} Err(error) => { @@ -552,17 +527,10 @@ fn start_sidecar_bind( match child.try_wait() { Ok(Some(status)) => { let _ = fs::remove_file(&ready_path); - if bind.port() != 0 - && probe_relay_health(&requested_url, bootstrap_fingerprint) - == RelayHealth::Compatible - { - return Ok(GatewayBootstrap { - endpoint: GatewayEndpoint { - address: bind, - url: requested_url, - }, - started: false, - }); + let (health, instance_id) = + probe_relay_health_with_instance(&requested_url, bootstrap_fingerprint); + if bind.port() != 0 && health == RelayHealth::Compatible { + return compatible_endpoint(bind, requested_url, instance_id); } return Err(format!( "nemo-relay sidecar exited before becoming ready at {requested_url}: {status}" diff --git a/crates/cli/src/sidecar/health.rs b/crates/cli/src/sidecar/health.rs index 9cdcc529f..a4f1e7563 100644 --- a/crates/cli/src/sidecar/health.rs +++ b/crates/cli/src/sidecar/health.rs @@ -33,24 +33,44 @@ pub(crate) fn healthz_compatible(url: &str, bootstrap_fingerprint: &str) -> bool probe(url, Some(bootstrap_fingerprint)) == RelayHealth::Compatible } -pub(super) fn probe_after_lock(url: &str, bootstrap_fingerprint: Option<&str>) -> RelayHealth { - let mut health = probe(url, bootstrap_fingerprint); +pub(super) fn probe_after_lock( + url: &str, + bootstrap_fingerprint: Option<&str>, +) -> (RelayHealth, Option) { + let mut result = probe_with_instance(url, bootstrap_fingerprint); for _ in 1..3 { - if health != RelayHealth::Foreign { + if result.0 != RelayHealth::Foreign { break; } thread::sleep(Duration::from_millis(50)); - health = probe(url, bootstrap_fingerprint); + result = probe_with_instance(url, bootstrap_fingerprint); } - health + result } pub(super) fn probe(url: &str, bootstrap_fingerprint: Option<&str>) -> RelayHealth { + probe_with_instance(url, bootstrap_fingerprint).0 +} + +pub(super) fn compatible_instance_id( + url: &str, + bootstrap_fingerprint: Option<&str>, +) -> Option { + let (health, instance_id) = probe_with_instance(url, bootstrap_fingerprint); + (health == RelayHealth::Compatible) + .then_some(instance_id) + .flatten() +} + +pub(super) fn probe_with_instance( + url: &str, + bootstrap_fingerprint: Option<&str>, +) -> (RelayHealth, Option) { let Ok((host, port)) = parse_loopback_url(url) else { - return RelayHealth::Unavailable; + return (RelayHealth::Unavailable, None); }; let Ok(addrs) = (host.as_str(), port).to_socket_addrs() else { - return RelayHealth::Unavailable; + return (RelayHealth::Unavailable, None); }; let mut stream = None; for addr in addrs { @@ -63,12 +83,12 @@ pub(super) fn probe(url: &str, bootstrap_fingerprint: Option<&str>) -> RelayHeal } } let Some(mut stream) = stream else { - return RelayHealth::Unavailable; + return (RelayHealth::Unavailable, None); }; if stream.set_read_timeout(Some(HEALTHZ_TIMEOUT)).is_err() || stream.set_write_timeout(Some(HEALTHZ_TIMEOUT)).is_err() { - return RelayHealth::Foreign; + return (RelayHealth::Foreign, None); } let challenge = bootstrap_fingerprint.map(|fingerprint| { let key = BootstrapChallengeKey::load().map_err(|_| ())?; @@ -82,7 +102,7 @@ pub(super) fn probe(url: &str, bootstrap_fingerprint: Option<&str>) -> RelayHeal }); let challenge = match challenge.transpose() { Ok(challenge) => challenge, - Err(()) => return RelayHealth::Foreign, + Err(()) => return (RelayHealth::Foreign, None), }; let fingerprint_headers = challenge .as_ref() @@ -97,44 +117,51 @@ pub(super) fn probe(url: &str, bootstrap_fingerprint: Option<&str>) -> RelayHeal loopback_authority(&host, port) ); if stream.write_all(request.as_bytes()).is_err() { - return RelayHealth::Foreign; + return (RelayHealth::Foreign, None); } let mut response = Vec::new(); if stream.take(16 * 1024).read_to_end(&mut response).is_err() { - return RelayHealth::Foreign; + return (RelayHealth::Foreign, None); } let Some((headers, body)) = split_http_response(&response) else { - return RelayHealth::Foreign; + return (RelayHealth::Foreign, None); }; let Ok(body) = serde_json::from_slice::(body) else { - return RelayHealth::Foreign; + return (RelayHealth::Foreign, None); }; if body.get("service").and_then(Value::as_str) != Some("nemo-relay") || body.get("bootstrap_protocol").and_then(Value::as_u64) != Some(BOOTSTRAP_PROTOCOL_VERSION) { - return RelayHealth::Foreign; + return (RelayHealth::Foreign, None); } if body.get("version").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION")) || headers.starts_with(b"HTTP/1.1 409") || headers.starts_with(b"HTTP/1.0 409") { - return RelayHealth::Incompatible; + return (RelayHealth::Incompatible, None); } if (headers.starts_with(b"HTTP/1.1 200") || headers.starts_with(b"HTTP/1.0 200")) && body.get("status").and_then(Value::as_str) == Some("ok") { if let Some((fingerprint, nonce, key)) = challenge { let Some(proof) = http_header(headers, "x-nemo-relay-bootstrap-proof") else { - return RelayHealth::Foreign; + return (RelayHealth::Foreign, None); }; if !key.verify(fingerprint, &nonce, proof) { - return RelayHealth::Foreign; + return (RelayHealth::Foreign, None); } } - return RelayHealth::Compatible; + let Some(instance_id) = body + .get("instance_id") + .and_then(Value::as_str) + .filter(|instance_id| !instance_id.is_empty() && instance_id.len() <= 128) + else { + return (RelayHealth::Foreign, None); + }; + return (RelayHealth::Compatible, Some(instance_id.to_owned())); } - RelayHealth::Foreign + (RelayHealth::Foreign, None) } pub(super) fn request_shutdown(url: &str, token: &str) -> Result<(), String> { diff --git a/crates/cli/src/sidecar/state.rs b/crates/cli/src/sidecar/state.rs index 9ff2f4a99..ed1c5d258 100644 --- a/crates/cli/src/sidecar/state.rs +++ b/crates/cli/src/sidecar/state.rs @@ -13,13 +13,11 @@ use std::time::{Duration, Instant}; use reqwest::Url; use serde::{Deserialize, Serialize}; -use crate::config::CodingAgent; use crate::file_io::{LockAttempt, atomic_write, try_lock_exclusive}; use super::health::{RelayHealth, probe, request_shutdown}; use super::{BOOTSTRAP_PROTOCOL_VERSION, GatewayEndpoint, SIDECAR_LOCK_TIMEOUT}; -pub(crate) const BOOTSTRAP_AGENT_ENV: &str = "NEMO_RELAY_BOOTSTRAP_AGENT"; pub(crate) const BOOTSTRAP_STATE_DIR_ENV: &str = "NEMO_RELAY_BOOTSTRAP_STATE_DIR"; #[derive(Debug, Deserialize, Serialize)] @@ -69,6 +67,7 @@ struct ReadyRecord { version: String, bootstrap_protocol: u64, address: String, + instance_id: String, } pub(super) fn read_owner_record(path: &Path) -> Result, String> { @@ -103,6 +102,7 @@ pub(super) fn read_ready_file(path: &Path) -> Result, St if record.service != "nemo-relay" || record.version != env!("CARGO_PKG_VERSION") || record.bootstrap_protocol != BOOTSTRAP_PROTOCOL_VERSION + || record.instance_id.is_empty() { return Err(format!( "incompatible sidecar readiness file {}", @@ -116,19 +116,16 @@ pub(super) fn read_ready_file(path: &Path) -> Result, St Ok(Some(GatewayEndpoint { address, url: format!("http://{address}"), + instance_id: record.instance_id, })) } -pub(crate) fn owner_path(runtime: &Path, agent: CodingAgent, url: &str) -> PathBuf { - runtime.join(format!( - "{}-sidecar-{}.owner.json", - agent.as_arg(), - lock_name(url) - )) +pub(crate) fn owner_path(runtime: &Path, url: &str) -> PathBuf { + runtime.join(format!("sidecar-{}.owner.json", lock_name(url))) } -pub(crate) fn pid_path(runtime: &Path, agent: CodingAgent, url: &str) -> PathBuf { - runtime.join(format!("{}-sidecar-{}.pid", agent.as_arg(), lock_name(url))) +pub(crate) fn pid_path(runtime: &Path, url: &str) -> PathBuf { + runtime.join(format!("sidecar-{}.pid", lock_name(url))) } pub(crate) fn lock_path(runtime: &Path, url: &str) -> PathBuf { @@ -203,12 +200,11 @@ pub(crate) fn write_owner( pub(crate) fn publish_owner_from_env(address: SocketAddr) -> Result<(), String> { let state = env::var_os(BOOTSTRAP_STATE_DIR_ENV); - let agent = env::var(BOOTSTRAP_AGENT_ENV).ok(); let token = env::var("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN").ok(); let bootstrap_fingerprint = env::var(crate::config::BOOTSTRAP_FINGERPRINT_ENV) .ok() .filter(|fingerprint| !fingerprint.is_empty()); - if state.is_none() && agent.is_none() && token.is_none() { + if state.is_none() && token.is_none() { return Ok(()); } let state = state @@ -220,17 +216,6 @@ pub(crate) fn publish_owner_from_env(address: SocketAddr) -> Result<(), String> state.display() )); } - let agent = match agent.as_deref() { - Some("codex") => CodingAgent::Codex, - Some("claude" | "claude-code") => CodingAgent::ClaudeCode, - Some("hermes") => CodingAgent::Hermes, - Some(other) => return Err(format!("unsupported bootstrap agent {other}")), - None => { - return Err(format!( - "{BOOTSTRAP_AGENT_ENV} is required for managed bootstrap" - )); - } - }; let token = token.filter(|token| !token.is_empty()).ok_or_else(|| { "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN is required for managed bootstrap".to_string() })?; @@ -247,8 +232,21 @@ pub(crate) fn publish_owner_from_env(address: SocketAddr) -> Result<(), String> })?; let url = format!("http://{address}"); let pid = std::process::id(); - let owner_path = owner_path(&state, agent, &url); - let pid_path = pid_path(&state, agent, &url); + let owner_path = owner_path(&state, &url); + let pid_path = pid_path(&state, &url); + for stale_path in owner_paths(&state)? { + if stale_path == owner_path { + continue; + } + let Ok(Some(stale)) = read_owner_record(&stale_path) else { + continue; + }; + if stale.url == url { + let stale_pid = owner_pid_path(&state, &stale_path, &url); + let _ = fs::remove_file(stale_path); + let _ = fs::remove_file(stale_pid); + } + } atomic_write(&pid_path, pid.to_string().as_bytes())?; if let Err(error) = write_owner( &owner_path, @@ -291,12 +289,18 @@ pub(crate) fn validate_owner( } } -pub(crate) fn stop_owned(agent: CodingAgent) -> Result<(), String> { +pub(crate) fn stop_owned(target_url: &str) -> Result<(), String> { let runtime = state_dir()?; let mut errors = Vec::new(); - for owner_path in owner_paths(&runtime, agent)? { - if let Err(error) = stop_owned_record(agent, &runtime, &owner_path) { - errors.push(error); + for owner_path in owner_paths(&runtime)? { + match read_owner_record(&owner_path) { + Ok(Some(owner)) if owner.url == target_url => { + if let Err(error) = stop_owned_record(&runtime, &owner_path) { + errors.push(error); + } + } + Ok(_) => {} + Err(error) => errors.push(error), } } if errors.is_empty() { @@ -306,7 +310,7 @@ pub(crate) fn stop_owned(agent: CodingAgent) -> Result<(), String> { } } -pub(crate) fn owner_paths(runtime: &Path, agent: CodingAgent) -> Result, String> { +pub(crate) fn owner_paths(runtime: &Path) -> Result, String> { let entries = match fs::read_dir(runtime) { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), @@ -317,27 +321,43 @@ pub(crate) fn owner_paths(runtime: &Path, agent: CodingAgent) -> Result>(); paths.sort(); Ok(paths) } -pub(crate) fn stop_owned_record( - agent: CodingAgent, - runtime: &Path, - owner_path: &Path, -) -> Result<(), String> { +fn is_sidecar_owner_name(name: &str) -> bool { + name == "sidecar.owner.json" + || (name.starts_with("sidecar-") && name.ends_with(".owner.json")) + || (name.ends_with("-sidecar.owner.json")) + || (name.contains("-sidecar-") && name.ends_with(".owner.json")) +} + +pub(crate) fn owner_pid_path(runtime: &Path, owner_path: &Path, url: &str) -> PathBuf { + let name = owner_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + if name == "sidecar.owner.json" { + return runtime.join("sidecar.pid"); + } + if name.ends_with("-sidecar.owner.json") { + return owner_path.with_file_name(name.replace(".owner.json", ".pid")); + } + if name.contains("-sidecar-") && !name.starts_with("sidecar-") { + return owner_path.with_file_name(name.replace(".owner.json", ".pid")); + } + pid_path(runtime, url) +} + +pub(crate) fn stop_owned_record(runtime: &Path, owner_path: &Path) -> Result<(), String> { let Some(initial_owner) = read_owner_record(owner_path)? else { return Ok(()); }; @@ -364,15 +384,7 @@ pub(crate) fn stop_owned_record( owner_path.display() ) })?; - let legacy = owner_path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name == format!("{}-sidecar.owner.json", agent.as_arg())); - let pid_path = if legacy { - runtime.join(format!("{}-sidecar.pid", agent.as_arg())) - } else { - pid_path(runtime, agent, url) - }; + let pid_path = owner_pid_path(runtime, owner_path, url); match probe(url, Some(bootstrap_fingerprint)) { RelayHealth::Unavailable => { let _ = fs::remove_file(owner_path); diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 9213fd1a8..148a7421b 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -568,7 +568,7 @@ fn run_fake_bootstrap_listener_with_hook_delay( } }; let body = format!( - r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":1}}"#, + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":2,"instance_id":"test-instance"}}"#, env!("CARGO_PKG_VERSION") ); stream @@ -873,7 +873,7 @@ fn wait_for_owned_sidecar( ) -> serde_json::Value { let deadline = Instant::now() + Duration::from_secs(10); loop { - for path in find_runtime_files_matching(temp, &format!("{agent}-sidecar"), ".owner.json") { + for path in find_runtime_files_matching(temp, "sidecar-", ".owner.json") { if let Ok(raw) = std::fs::read(path) && let Ok(owner) = serde_json::from_slice::(&raw) && owner["pid"] @@ -959,7 +959,13 @@ fn cli_mcp_clients_share_gateway_until_final_idle_shutdown() { let health = relay_health(address); assert_eq!(health["service"], "nemo-relay"); assert_eq!(health["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(health["bootstrap_protocol"], 1); + assert_eq!(health["bootstrap_protocol"], 2); + assert!( + health["instance_id"] + .as_str() + .is_some_and(|instance_id| !instance_id.is_empty()), + "the shared gateway should publish its process identity" + ); assert!( find_runtime_file(temp.path(), &format!("{second_agent}-sidecar.log")).is_none(), "the second MCP client should adopt the first host's gateway" @@ -2026,6 +2032,17 @@ fn cli_rejects_removed_cursor_entry_points() { assert!(String::from_utf8_lossy(&output.stderr).contains("invalid value 'cursor'")); } +#[test] +fn cli_rejects_removed_plugin_shim_entry_point() { + let output = Command::new(gateway_bin()) + .args(["plugin-shim", "--help"]) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("unrecognized subcommand")); +} + #[test] fn cli_help_lists_model_pricing_command_only() { let output = Command::new(gateway_bin()).arg("--help").output().unwrap(); diff --git a/crates/cli/tests/coverage/agent_process_tests.rs b/crates/cli/tests/coverage/agent_process_tests.rs new file mode 100644 index 000000000..fb11beb45 --- /dev/null +++ b/crates/cli/tests/coverage/agent_process_tests.rs @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +#[test] +fn wrapper_probe_uses_last_host_token_and_validates_opaque_wrappers() { + assert_eq!( + version_probe_argv( + CodingAgent::Codex, + &command_argv("npm exec --package @openai/codex -- codex exec") + ), + [ + "npm", + "exec", + "--package", + "@openai/codex", + "--", + "codex", + "--version" + ] + ); + assert_eq!( + version_probe_argv( + CodingAgent::Codex, + &command_argv("custom-codex-wrapper --profile dev") + ), + ["custom-codex-wrapper", "--profile", "dev", "--version"] + ); +} + +#[test] +fn platform_resolution_supports_explicit_paths_and_windows_pathext() { + let temp = tempfile::tempdir().unwrap(); + let shim = temp.path().join("codex.CMD"); + std::fs::write(&shim, "").unwrap(); + + assert_eq!( + resolve_executable_for_platform( + "codex", + Some(temp.path().as_os_str()), + Some(std::ffi::OsStr::new(".EXE;.CMD")), + true, + ), + Some(shim.clone()) + ); + assert_eq!( + resolve_executable_for_platform( + shim.to_str().unwrap(), + None, + Some(std::ffi::OsStr::new(".EXE;.CMD")), + true, + ), + Some(shim) + ); +} + +#[test] +fn windows_command_line_quotes_program_and_arguments() { + assert!(is_windows_command_script(Path::new("codex.CMD"))); + assert!(!is_windows_command_script(Path::new("codex.exe"))); + let command = windows_command_line( + Path::new(r"C:\Program Files\Relay & Co\codex.cmd"), + &["exec".into(), "a & b".into(), "%TOKEN%".into()], + ); + assert!(command.contains(r#""C:\Program Files\Relay ^& Co\codex.cmd""#)); + assert!(command.contains(r#""a ^& b""#)); + assert!(command.contains("%%TOKEN%%")); +} diff --git a/crates/cli/tests/coverage/coding_agent_tests.rs b/crates/cli/tests/coverage/coding_agent_tests.rs index c785ceed9..d2e5860ad 100644 --- a/crates/cli/tests/coverage/coding_agent_tests.rs +++ b/crates/cli/tests/coverage/coding_agent_tests.rs @@ -41,6 +41,7 @@ fn centralized_minimum_versions_reject_old_prerelease_and_malformed_output() { let cases = [ (CodingAgent::ClaudeCode, "2.1.120 (Claude Code)"), (CodingAgent::ClaudeCode, "2.1.121-beta.1 (Claude Code)"), + (CodingAgent::ClaudeCode, "2.1.121 (Other Agent)"), (CodingAgent::Codex, "codex-cli 0.142.9"), (CodingAgent::Codex, "codex-cli 0.143.0-alpha.1"), (CodingAgent::Hermes, "Hermes Agent v0.18.1"), @@ -70,6 +71,12 @@ fn agent_inference_accepts_supported_binary_aliases() { Some(CodingAgent::ClaudeCode) ); assert_eq!(CodingAgent::infer("codex"), Some(CodingAgent::Codex)); + assert_eq!(CodingAgent::infer("CODEX.EXE"), Some(CodingAgent::Codex)); + assert_eq!( + CodingAgent::infer(r"C:\\tools\\codex.cmd"), + Some(CodingAgent::Codex) + ); + assert_eq!(CodingAgent::infer("@openai/codex"), None); assert_eq!( CodingAgent::infer("hermes-agent"), Some(CodingAgent::Hermes) diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index db5b90894..41dd8eb1d 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -620,8 +620,10 @@ fn collect_configuration_uses_xdg_global_path_and_renders_resolution_branches() #[test] fn agent_helper_statuses_cover_configured_target_and_hook_paths() { - assert_eq!(command_executable("codex --full-auto"), "codex"); - assert_eq!(command_executable(""), ""); + assert_eq!( + crate::agent_process::command_argv("codex --full-auto"), + ["codex", "--full-auto"] + ); assert_eq!( agent_command(CodingAgent::ClaudeCode, &AgentConfigs::default()), "claude" @@ -645,34 +647,20 @@ fn agent_helper_statuses_cover_configured_target_and_hook_paths() { assert!(agent_configured(CodingAgent::Hermes, &agents)); assert_eq!(configured_agent_names(&agents), vec!["hermes".to_string()]); assert_eq!( - hook_status(CodingAgent::ClaudeCode, &agents, true), + hook_status(CodingAgent::ClaudeCode, &agents), (Status::Pass, "hooks: injected during run".into()) ); assert_eq!( - hook_status(CodingAgent::Codex, &agents, true), + hook_status(CodingAgent::Codex, &agents), (Status::Pass, "hooks: injected during run".into()) ); - let temp = tempfile::tempdir().unwrap(); - let hook = temp.path().join("hooks.yaml"); - std::fs::write(&hook, "cmd: nemo-relay hook-forward hermes\n").unwrap(); - let (status, details) = hook_file_status(Ok(hook.clone()), CodingAgent::Hermes, true, "hooks"); - assert_eq!(status, Status::Pass); - assert!(details.contains(hook.to_str().unwrap())); - - std::fs::write(&hook, "cmd: custom\n").unwrap(); - let (status, details) = hook_file_status(Ok(hook.clone()), CodingAgent::Hermes, true, "hooks"); - assert_eq!(status, Status::Fail); - assert!(details.contains("missing NeMo Relay hook")); - let (status, _) = hook_file_status(Ok(hook), CodingAgent::Hermes, false, "hooks"); - assert_eq!(status, Status::Info); - - let agents = AgentConfigs::default(); - let (status, details) = hook_status(CodingAgent::Hermes, &agents, true); - assert_eq!(status, Status::Fail); - assert!(details.contains("not installed")); - let (status, details) = hook_status(CodingAgent::Hermes, &agents, false); - assert_eq!(status, Status::Info); - assert!(details.contains("not configured")); + assert_eq!( + hook_status(CodingAgent::Hermes, &AgentConfigs::default()), + ( + Status::Pass, + "hooks: injected through an isolated HERMES_HOME during run".into() + ) + ); } #[test] @@ -738,6 +726,27 @@ async fn collect_agents_filters_target_and_records_version() { assert_eq!(agents[0].version.as_deref(), Some("codex-cli 0.143.0")); } +#[cfg(unix)] +#[tokio::test] +async fn collect_agents_preserves_wrapper_argv_for_version_validation() { + let temp = tempfile::tempdir().unwrap(); + let wrapper = temp.path().join("npx"); + std::fs::write( + &wrapper, + "#!/bin/sh\n[ \"$1\" = codex ] && [ \"$2\" = --version ] || exit 9\nprintf 'codex-cli 0.143.0\\n'\n", + ) + .unwrap(); + make_executable(&wrapper); + + let mut resolved = ResolvedConfig::default(); + resolved.agents.codex.command = Some(format!("{} codex", wrapper.display())); + let agents = collect_agents(Some(CodingAgent::Codex), &resolved).await; + + assert_eq!(agents[0].status, Status::Pass); + assert_eq!(agents[0].path.as_deref(), Some(wrapper.as_path())); + assert_eq!(agents[0].version.as_deref(), Some("codex-cli 0.143.0")); +} + #[cfg(unix)] #[tokio::test] async fn probe_version_returns_none_for_empty_output_and_spawn_failures() { @@ -749,10 +758,20 @@ async fn probe_version_returns_none_for_empty_output_and_spawn_failures() { std::fs::write(&failed, "#!/bin/sh\nprintf 'codex-cli 99.0.0\\n'\nexit 7\n").unwrap(); make_executable(&failed); - assert_eq!(probe_version(&quiet).await, None); - assert_eq!(probe_version(&failed).await, None); assert_eq!( - probe_version(&temp.path().join("missing-agent")).await, + probe_version(&[quiet.display().to_string(), "--version".into()]).await, + None + ); + assert_eq!( + probe_version(&[failed.display().to_string(), "--version".into()]).await, + None + ); + assert_eq!( + probe_version(&[ + temp.path().join("missing-agent").display().to_string(), + "--version".into(), + ]) + .await, None ); } @@ -799,12 +818,18 @@ fn configuration_and_path_helpers_cover_direct_paths_and_fallbacks() { assert!(info.global.path.starts_with(&home)); assert_eq!(info.configured_agents, vec!["codex".to_string()]); - assert_eq!(which_on_path("definitely-missing"), None); - assert_eq!(which_command("/definitely/missing"), None); + assert_eq!( + crate::agent_process::resolve_executable("definitely-missing"), + None + ); + assert_eq!( + crate::agent_process::resolve_executable("/definitely/missing"), + None + ); let binary = temp.path().join("agent-bin"); std::fs::write(&binary, "").unwrap(); assert_eq!( - which_command(binary.to_str().unwrap()).as_deref(), + crate::agent_process::resolve_executable(binary.to_str().unwrap()).as_deref(), Some(binary.as_path()) ); } @@ -891,52 +916,6 @@ fn check_directory_reports_pass_warn_and_fail() { assert_eq!(fail.status, Status::Fail); } -#[test] -fn hook_file_status_covers_resolution_and_missing_paths() { - let resolution_error = hook_file_status( - Err(CliError::Config("bad path".into())), - CodingAgent::Hermes, - true, - "hooks", - ); - assert_eq!(resolution_error.0, Status::Fail); - assert!(resolution_error.1.contains("could not resolve path")); - - let missing = tempfile::tempdir().unwrap().path().join("missing.yaml"); - let (status, details) = - hook_file_status(Ok(missing.clone()), CodingAgent::Hermes, true, "hooks"); - assert_eq!(status, Status::Fail); - assert!(details.contains("missing")); - - let (status, details) = hook_file_status(Ok(missing), CodingAgent::Hermes, false, "hooks"); - assert_eq!(status, Status::Info); - assert!(details.contains("missing")); -} - -#[test] -fn hook_file_status_covers_plain_files_and_read_errors() { - let temp = tempfile::tempdir().unwrap(); - let hooks_path = temp.path().join("config.yaml"); - std::fs::write(&hooks_path, "hooks:\n PreToolUse: []\n").unwrap(); - - let (status, details) = - hook_file_status(Ok(hooks_path.clone()), CodingAgent::Hermes, true, "hooks"); - assert_eq!(status, Status::Fail); - assert!(details.contains("missing NeMo Relay hook")); - - let (status, details) = - hook_file_status(Ok(hooks_path.clone()), CodingAgent::Hermes, false, "hooks"); - assert_eq!(status, Status::Info); - assert!(details.contains("no NeMo Relay hook")); - - let unreadable_path = temp.path().join("hooks-dir"); - std::fs::create_dir(&unreadable_path).unwrap(); - let (status, details) = - hook_file_status(Ok(unreadable_path), CodingAgent::Hermes, false, "hooks"); - assert_eq!(status, Status::Fail); - assert!(details.contains("could not read")); -} - #[tokio::test] async fn collect_observability_warns_for_missing_atif_dir_without_creating_it() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/gateway_tests.rs b/crates/cli/tests/coverage/gateway_tests.rs index 1c247e441..fa2aaa76f 100644 --- a/crates/cli/tests/coverage/gateway_tests.rs +++ b/crates/cli/tests/coverage/gateway_tests.rs @@ -751,6 +751,7 @@ async fn passthrough_rejects_unsupported_provider_path_directly() { sessions: SessionManager::new(config), last_activity: std::sync::Arc::new(std::sync::Mutex::new(std::time::Instant::now())), bootstrap_shutdown: None, + instance_id: "test-instance".into(), }; let request = Request::builder() .method(Method::POST) @@ -783,6 +784,7 @@ async fn models_rejects_non_get_requests_directly() { sessions: SessionManager::new(config), last_activity: std::sync::Arc::new(std::sync::Mutex::new(std::time::Instant::now())), bootstrap_shutdown: None, + instance_id: "test-instance".into(), }; let request = Request::builder() .method(Method::POST) diff --git a/crates/cli/tests/coverage/hermes_tests.rs b/crates/cli/tests/coverage/hermes_tests.rs index f1208fcd8..44093658f 100644 --- a/crates/cli/tests/coverage/hermes_tests.rs +++ b/crates/cli/tests/coverage/hermes_tests.rs @@ -306,6 +306,29 @@ fn trusted_hooks_migrates_only_relay_approvals_and_records_every_event() { } } +#[test] +fn verification_rejects_relay_handlers_and_approvals_on_unexpected_events() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let command = persistent_hook_command(&relay); + let generation = temp.path().join(GENERATION_FILE_NAME); + let mut config = persistent_config(None, &relay, &command, &generation, &[]).unwrap(); + config["hooks"]["unexpected_event"] = json!([{"command": command, "timeout": 30}]); + let error = verify_hook_definitions(&config, &command).unwrap_err(); + assert!(error.contains("unexpected Relay hook")); + + let mut allowlist = trusted_hooks(None, &command, &relay, UNIX_EPOCH).unwrap(); + allowlist["approvals"].as_array_mut().unwrap().push(json!({ + "event": "unexpected_event", + "command": command, + "approved_at": "1970-01-01T00:00:00.000000Z" + })); + let path = temp.path().join("shell-hooks-allowlist.json"); + std::fs::write(&path, serde_json::to_vec(&allowlist).unwrap()).unwrap(); + let error = verify_trust(&path, &command).unwrap_err(); + assert!(error.contains("unexpected Relay hook approval")); +} + #[test] fn install_is_verified_idempotent_and_rotates_the_generation() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs index 36607d5f3..6f2d4db14 100644 --- a/crates/cli/tests/coverage/installer_tests.rs +++ b/crates/cli/tests/coverage/installer_tests.rs @@ -38,42 +38,6 @@ fn hook_payload_reader_rejects_oversized_invalid_and_unreadable_input() { ); } -#[test] -fn hermes_config_merge_preserves_existing_yaml() { - let existing = r#" -model: - provider: auto -hooks: - pre_tool_call: - - command: ~/.hermes/agent-hooks/audit.sh -"#; - let merged = - merge_hermes_config(existing, hermes_hooks("nemo-relay hook-forward hermes")).unwrap(); - let yaml: Value = serde_yaml::from_str(&merged).unwrap(); - - assert_eq!(yaml["model"]["provider"], json!("auto")); - assert_eq!(yaml["hooks"]["pre_tool_call"].as_array().unwrap().len(), 2); - assert_eq!( - yaml["hooks"]["on_session_finalize"] - .as_array() - .unwrap() - .len(), - 1 - ); -} - -#[test] -fn hermes_config_merge_rejects_invalid_yaml() { - let error = merge_hermes_config( - "hooks: [not valid", - hermes_hooks("nemo-relay hook-forward hermes"), - ) - .unwrap_err() - .to_string(); - - assert!(error.contains("invalid YAML in Hermes config")); -} - #[test] fn explicit_persistent_destinations_ignore_ambient_urls() { let destination = resolve_hook_destination( @@ -157,13 +121,22 @@ fn generated_hook_dispatch_covers_all_agents() { assert!(generated_hooks(agent, "cmd")["hooks"].is_object()); } assert_eq!( - hook_forward_command("nemo-relay", CodingAgent::Hermes), + transparent_hook_forward_command(Path::new("nemo-relay"), CodingAgent::Hermes), "nemo-relay hook-forward hermes" ); assert_eq!( - hook_forward_command("/abs/path/to/nemo-relay", CodingAgent::Codex), + transparent_hook_forward_command(Path::new("/abs/path/to/nemo-relay"), CodingAgent::Codex), "/abs/path/to/nemo-relay hook-forward codex" ); + let relay = Path::new("/opt/NeMo Relay's & tools/nemo-relay"); + assert_eq!( + transparent_hook_forward_command_for_platform(relay, CodingAgent::Codex, false), + r#"'/opt/NeMo Relay'\''s & tools/nemo-relay' hook-forward codex"# + ); + assert_eq!( + transparent_hook_forward_command_for_platform(relay, CodingAgent::ClaudeCode, true), + r#""/opt/NeMo Relay's ^& tools/nemo-relay" hook-forward claude"# + ); } #[test] @@ -252,6 +225,16 @@ fn packaged_plugin_hooks_use_expected_forwarding_commands() { crate::sidecar::DEFAULT_URL )) ); + assert_eq!( + claude["hooks"], + generated_hooks( + CodingAgent::ClaudeCode, + &format!( + "nemo-relay hook-forward claude --gateway-url {}", + crate::sidecar::DEFAULT_URL + ), + )["hooks"] + ); assert_eq!( codex["hooks"], generated_hooks( @@ -318,9 +301,10 @@ fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { ); assert_eq!(server["required"], json!(true)); assert_eq!(server["startup_timeout_sec"], json!(20)); - let env_vars = server["env_vars"].as_array().unwrap(); - assert!(env_vars.contains(&json!("OPENAI_API_KEY"))); - assert!(env_vars.contains(&json!("XDG_CONFIG_HOME"))); + assert_eq!( + server["env_vars"], + json!(crate::mcp_environment::forwarded_names(Vec::new(), None)) + ); let claude_mcp_path = root.join("claude-code/.mcp.json"); let claude_mcp = @@ -332,6 +316,7 @@ fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { claude_server["env"], json!({"NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632"}) ); + assert_eq!(claude_server["alwaysLoad"], json!(true)); let codex_marketplace_path = root.join("../../.agents/plugins/marketplace.json"); let codex_marketplace = diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index 3e68fb568..b60614830 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -508,35 +508,33 @@ fn insert_after_agent_uses_last_matching_agent_or_first_word_fallback() { } #[test] -fn version_probe_preserves_known_wrappers_and_skips_opaque_ones() { +fn version_probe_preserves_known_wrappers_and_validates_opaque_ones() { assert_eq!( - version_probe_argv(CodingAgent::Codex, &["codex".into(), "exec".into()]), - Some(vec!["codex".into(), "--version".into()]) + crate::agent_process::version_probe_argv( + CodingAgent::Codex, + &["codex".into(), "exec".into()] + ), + vec!["codex", "--version"] ); assert_eq!( - version_probe_argv( + crate::agent_process::version_probe_argv( CodingAgent::Codex, &["npx".into(), "--yes".into(), "codex".into(), "exec".into(),], ), - Some(vec![ - "npx".into(), - "--yes".into(), - "codex".into(), - "--version".into(), - ]) + vec!["npx", "--yes", "codex", "--version"] ); assert_eq!( - version_probe_argv( + crate::agent_process::version_probe_argv( CodingAgent::Hermes, &["company-agent-wrapper".into(), "chat".into()], ), - None + vec!["company-agent-wrapper", "chat", "--version"] ); } #[test] fn windows_agent_command_line_quotes_paths_and_metacharacters() { - let line = windows_command_line( + let line = crate::agent_process::windows_command_line( Path::new(r"C:\Program Files\Codex&Tools\npx.cmd"), &["codex".into(), "--version".into(), "100%".into()], ); @@ -557,11 +555,10 @@ async fn wrapped_agent_version_probe_runs_through_the_wrapper() { ) .unwrap(); make_executable(&wrapper); - let probe = version_probe_argv( + let probe = crate::agent_process::version_probe_argv( CodingAgent::Codex, &[wrapper.display().to_string(), "codex".into(), "exec".into()], - ) - .unwrap(); + ); validate_agent_version(CodingAgent::Codex, &probe) .await @@ -634,9 +631,11 @@ fn prepares_claude_dry_inserts_plugin_dir_after_last_agent_executable() { fn prepares_hermes_hook_environment() { let _guard = current_dir_lock().lock().unwrap(); let temp = tempfile::tempdir().unwrap(); - let previous = std::env::current_dir().unwrap(); - std::env::set_current_dir(temp.path()).unwrap(); let hooks_path = temp.path().join("hermes-home/config.yaml"); + std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); + std::fs::write(&hooks_path, "model:\n default: test\n").unwrap(); + let state = hooks_path.parent().unwrap().join("state.db"); + std::fs::write(&state, "state").unwrap(); let resolved = ResolvedConfig { gateway: GatewayConfig::default(), agents: AgentConfigs { @@ -668,20 +667,100 @@ fn prepares_hermes_hook_environment() { .env .contains(&("HERMES_ACCEPT_HOOKS".into(), "1".into())) ); + let overlay = prepared + .env + .iter() + .find_map(|(name, value)| (name == "HERMES_HOME").then(|| PathBuf::from(value))) + .expect("Hermes overlay path"); + let hooks = std::fs::read_to_string(overlay.join("config.yaml")).unwrap(); + assert!(hooks.contains("hook-forward hermes")); + assert!(overlay.join("state.db").exists()); assert_eq!( - prepared - .hermes_restore - .as_ref() - .map(|restore| &restore.path), - Some(&hooks_path) + std::fs::read_to_string(&hooks_path).unwrap(), + "model:\n default: test\n" ); - let hooks = std::fs::read_to_string(&hooks_path).unwrap(); - assert!(hooks.contains("hook-forward hermes")); - assert!(prepared.notes[0].contains("temporarily merged")); + assert!(prepared.notes[0].contains("isolated Hermes config overlay")); prepared.restore().unwrap(); - assert!(!hooks_path.exists()); - std::env::set_current_dir(previous).unwrap(); + assert!(hooks_path.exists()); + assert!(!overlay.exists()); +} + +#[test] +fn concurrent_hermes_runs_use_independent_overlays_without_mutating_user_config() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("hermes/config.yaml"); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + let original = "model:\n default: test\n"; + std::fs::write(&config, original).unwrap(); + let resolved = ResolvedConfig { + agents: AgentConfigs { + hermes: AgentCommandConfig { + hooks_path: Some(config.clone()), + ..AgentCommandConfig::default() + }, + ..AgentConfigs::default() + }, + ..ResolvedConfig::default() + }; + + let first = PreparedRun::new( + CodingAgent::Hermes, + vec!["hermes".into()], + "http://127.0.0.1:4001", + &resolved, + false, + ) + .unwrap(); + let second = PreparedRun::new( + CodingAgent::Hermes, + vec!["hermes".into()], + "http://127.0.0.1:4002", + &resolved, + false, + ) + .unwrap(); + let overlay = |run: &PreparedRun| { + run.env + .iter() + .find_map(|(name, value)| (name == "HERMES_HOME").then(|| PathBuf::from(value))) + .unwrap() + }; + let first_overlay = overlay(&first); + let second_overlay = overlay(&second); + + assert_ne!(first_overlay, second_overlay); + assert!( + std::fs::read_to_string(first_overlay.join("config.yaml")) + .unwrap() + .contains("hook-forward hermes") + ); + assert!( + std::fs::read_to_string(second_overlay.join("config.yaml")) + .unwrap() + .contains("hook-forward hermes") + ); + assert_eq!(std::fs::read_to_string(&config).unwrap(), original); + + first.restore().unwrap(); + assert!(!first_overlay.exists()); + assert!(second_overlay.exists()); + assert_eq!(std::fs::read_to_string(&config).unwrap(), original); + second.restore().unwrap(); +} + +#[test] +fn hermes_overlay_does_not_link_an_ancestor_entry_that_contains_it() { + let source_home = tempfile::tempdir().unwrap(); + let source_config = source_home.path().join("config.yaml"); + std::fs::write(&source_config, "model:\n default: test\n").unwrap(); + let overlay = source_home.path().join("overlay"); + std::fs::create_dir(&overlay).unwrap(); + + populate_hermes_overlay(&overlay, source_home.path(), &source_config).unwrap(); + + assert!(!overlay.join("overlay").exists()); + assert!(overlay.join("config.yaml").exists()); } #[test] @@ -754,11 +833,9 @@ fn hermes_hooks_path_prefers_configured_then_env_then_home() { } #[test] -fn hermes_patch_restore_restores_original_file() { +fn hermes_overlay_preserves_original_file() { let _guard = current_dir_lock().lock().unwrap(); let temp = tempfile::tempdir().unwrap(); - let previous = std::env::current_dir().unwrap(); - std::env::set_current_dir(temp.path()).unwrap(); let hooks_path = temp.path().join("hermes-home/config.yaml"); std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); let original = r#"mcp_servers: @@ -792,7 +869,12 @@ hooks: ) .unwrap(); - let patched = std::fs::read_to_string(&hooks_path).unwrap(); + let overlay = prepared + .env + .iter() + .find_map(|(name, value)| (name == "HERMES_HOME").then(|| PathBuf::from(value))) + .unwrap(); + let patched = std::fs::read_to_string(overlay.join("config.yaml")).unwrap(); assert!(patched.contains("hook-forward hermes")); let patched_yaml: serde_json::Value = serde_yaml::from_str(&patched).unwrap(); assert!(patched_yaml["mcp_servers"].get("nemo-relay").is_none()); @@ -800,9 +882,9 @@ hooks: patched_yaml["mcp_servers"]["filesystem"]["command"], json!("fs-mcp") ); - prepared.restore().unwrap(); assert_eq!(std::fs::read_to_string(&hooks_path).unwrap(), original); - std::env::set_current_dir(previous).unwrap(); + prepared.restore().unwrap(); + assert!(!overlay.exists()); } #[test] @@ -837,56 +919,8 @@ fn prepares_claude_temp_plugin() { } #[test] -fn hermes_restore_reports_restore_and_temporary_removal_failures() { - let temp = tempfile::tempdir().unwrap(); - let restore_missing_backup = PreparedRun { - argv: vec![], - env: vec![], - temp_dirs: vec![], - hermes_restore: Some(HermesRestore { - path: temp.path().join("config.yaml"), - backup_path: Some(temp.path().join("missing-backup.yaml")), - had_original: true, - }), - notes: vec![], - }; - - let error = restore_missing_backup.restore().unwrap_err().to_string(); - assert!(error.contains("failed to restore Hermes hooks")); - - let hooks_path = temp.path().join("hooks-dir"); - std::fs::create_dir(&hooks_path).unwrap(); - let remove_temporary_dir = PreparedRun { - argv: vec![], - env: vec![], - temp_dirs: vec![], - hermes_restore: Some(HermesRestore { - path: hooks_path, - backup_path: None, - had_original: false, - }), - notes: vec![], - }; - - let error = remove_temporary_dir.restore().unwrap_err().to_string(); - assert!(error.contains("failed to remove temporary Hermes hooks")); -} - -#[test] -fn hook_backup_and_write_helpers_cover_missing_existing_and_toml_escaping() { +fn hook_write_helpers_cover_toml_escaping() { let temp = tempfile::tempdir().unwrap(); - let missing_hermes = temp.path().join("missing-config.yaml"); - assert_eq!( - backup_existing_hermes_hooks(&missing_hermes).unwrap(), - (false, None) - ); - - let hermes_hooks = temp.path().join("config.yaml"); - std::fs::write(&hermes_hooks, "hooks: {}\n").unwrap(); - let (had_original, hermes_backup) = backup_existing_hermes_hooks(&hermes_hooks).unwrap(); - assert!(had_original); - assert!(hermes_backup.as_ref().unwrap().exists()); - let written_hooks = temp.path().join("written/hooks.json"); std::fs::create_dir_all(written_hooks.parent().unwrap()).unwrap(); write_hooks(&written_hooks, json!({"hooks": []})).unwrap(); @@ -1119,7 +1153,7 @@ async fn execute_live_run_reports_gateway_startup_error_when_health_check_fails( } #[tokio::test] -async fn execute_live_run_restores_hermes_hooks_when_health_check_fails() { +async fn execute_live_run_removes_hermes_overlay_when_health_check_fails() { let temp = tempfile::tempdir().unwrap(); let hooks_path = temp.path().join("hermes-home/config.yaml"); std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); @@ -1144,8 +1178,13 @@ async fn execute_live_run_restores_hermes_hooks_when_health_check_fails() { false, ) .unwrap(); + let overlay = prepared + .env + .iter() + .find_map(|(name, value)| (name == "HERMES_HOME").then(|| PathBuf::from(value))) + .unwrap(); assert!( - std::fs::read_to_string(&hooks_path) + std::fs::read_to_string(overlay.join("config.yaml")) .unwrap() .contains("hook-forward hermes") ); @@ -1163,6 +1202,7 @@ async fn execute_live_run_restores_hermes_hooks_when_health_check_fails() { assert!(error.contains("gateway did not become ready")); assert_eq!(std::fs::read_to_string(&hooks_path).unwrap(), original); + assert!(!overlay.exists()); } #[cfg(unix)] diff --git a/crates/cli/tests/coverage/mcp_gateway_tests.rs b/crates/cli/tests/coverage/mcp_gateway_tests.rs index fde004199..a48fca766 100644 --- a/crates/cli/tests/coverage/mcp_gateway_tests.rs +++ b/crates/cli/tests/coverage/mcp_gateway_tests.rs @@ -4,37 +4,126 @@ use super::*; #[test] -fn recovery_requires_three_consecutive_failures() { - let mut recovery = RecoveryState::default(); - - assert!(!recovery.record_failure().unwrap()); - assert!(!recovery.record_failure().unwrap()); - recovery.record_healthy(); - assert!(!recovery.record_failure().unwrap()); - assert!(!recovery.record_failure().unwrap()); - assert!(recovery.record_failure().unwrap()); +fn recovery_tracks_gateway_instances_instead_of_the_local_starter() { + let mut recovery = RecoveryState::new("first".into()); + + recovery.observe("first".into()).unwrap(); + recovery.require_restart().unwrap(); + recovery.observe("second".into()).unwrap(); + assert_eq!(recovery.instance_id(), "second"); + assert!(recovery.require_restart().is_err()); } #[test] -fn rediscovery_preserves_the_single_restart_allowance() { - let mut recovery = RecoveryState::default(); - for _ in 0..2 { - assert!(!recovery.record_failure().unwrap()); - } - assert!(recovery.record_failure().unwrap()); - recovery.record_recovery(false); +fn observing_two_replacements_exhausts_the_single_restart_allowance() { + let mut recovery = RecoveryState::new("first".into()); + recovery.observe("second".into()).unwrap(); + + let error = recovery.observe("third".into()).unwrap_err(); + + assert!(error.to_string().contains("replaced again")); +} + +#[tokio::test(start_paused = true)] +async fn production_heartbeat_recovers_after_one_thirty_second_interval() { + let (restarted_tx, restarted_rx) = tokio::sync::oneshot::channel(); + let mut restarted_tx = Some(restarted_tx); + let monitor = tokio::spawn(maintain_gateway_with( + "127.0.0.1:47632".parse().unwrap(), + "http://gateway".into(), + Duration::from_secs(30), + |_url| async { Ok(false) }, + move |address| { + let sender = restarted_tx.take(); + async move { + if let Some(sender) = sender { + let _ = sender.send(()); + } + Ok(crate::sidecar::GatewayEndpoint { + address, + url: "http://recovered".into(), + instance_id: "recovered".into(), + }) + } + }, + )); + tokio::time::advance(Duration::from_secs(30)).await; + restarted_rx.await.unwrap(); + assert!(!monitor.is_finished()); + monitor.abort(); +} + +#[tokio::test] +async fn concurrent_clients_consume_the_same_replacement_allowance() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + let current = Arc::new(Mutex::new(Some("first".to_string()))); + let restart_count = Arc::new(AtomicUsize::new(0)); + let observed_replacement = Arc::new(AtomicUsize::new(0)); + let mut monitors = Vec::new(); for _ in 0..2 { - assert!(!recovery.record_failure().unwrap()); + let current_for_health = current.clone(); + let current_for_restart = current.clone(); + let restart_count = restart_count.clone(); + let observed_replacement = observed_replacement.clone(); + monitors.push(tokio::spawn(maintain_gateway_instances_with_generation( + "127.0.0.1:47632".parse().unwrap(), + crate::sidecar::GatewayEndpoint { + address: "127.0.0.1:47632".parse().unwrap(), + url: "http://gateway".into(), + instance_id: "first".into(), + }, + Duration::from_millis(1), + move |_url, expected| { + let current = current_for_health.lock().unwrap().clone(); + if expected == "second" && current.as_deref() == Some("second") { + observed_replacement.fetch_add(1, Ordering::SeqCst); + } + async move { Ok(current) } + }, + move |address| { + let current = current_for_restart.clone(); + let restart_count = restart_count.clone(); + async move { + let mut current = current.lock().unwrap(); + let started = current.is_none(); + if started { + *current = Some("second".into()); + restart_count.fetch_add(1, Ordering::SeqCst); + } + Ok(crate::sidecar::GatewayEndpoint { + address, + url: "http://gateway".into(), + instance_id: current.clone().unwrap(), + }) + } + }, + || async { Ok(()) }, + ))); } - assert!(recovery.record_failure().unwrap()); - recovery.record_recovery(true); - for _ in 0..2 { - assert!(!recovery.record_failure().unwrap()); + *current.lock().unwrap() = None; + tokio::time::timeout(Duration::from_secs(2), async { + while observed_replacement.load(Ordering::SeqCst) < 2 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(restart_count.load(Ordering::SeqCst), 1); + + *current.lock().unwrap() = None; + for monitor in monitors { + let error = tokio::time::timeout(Duration::from_secs(2), monitor) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("after its coordinated restart")); } - let error = recovery.record_failure().unwrap_err(); - assert!(error.to_string().contains("after its coordinated restart")); + assert_eq!(restart_count.load(Ordering::SeqCst), 1); } #[tokio::test] diff --git a/crates/cli/tests/coverage/mcp_tests.rs b/crates/cli/tests/coverage/mcp_tests.rs index cf7f8631b..c80f64656 100644 --- a/crates/cli/tests/coverage/mcp_tests.rs +++ b/crates/cli/tests/coverage/mcp_tests.rs @@ -10,7 +10,6 @@ use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use super::*; -use crate::config::CodingAgent; use crate::install_generation::{ GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration, write_new_generation, }; @@ -295,12 +294,10 @@ async fn heartbeat_keeps_a_compatible_gateway_session_alive() { move |address| { restart_calls.fetch_add(1, Ordering::SeqCst); async move { - Ok(crate::sidecar::GatewayBootstrap { - endpoint: crate::sidecar::GatewayEndpoint { - address, - url: "http://unexpected-restart".into(), - }, - started: false, + Ok(crate::sidecar::GatewayEndpoint { + address, + url: "http://unexpected-restart".into(), + instance_id: "unexpected".into(), }) } } @@ -356,12 +353,10 @@ async fn heartbeat_performs_one_restart_and_tracks_the_recovered_gateway() { let restart_calls = restart_calls.clone(); async move { restart_calls.fetch_add(1, Ordering::SeqCst); - Ok(crate::sidecar::GatewayBootstrap { - endpoint: crate::sidecar::GatewayEndpoint { - address, - url: "http://recovered-gateway".into(), - }, - started: true, + Ok(crate::sidecar::GatewayEndpoint { + address, + url: "http://recovered-gateway".into(), + instance_id: "recovered".into(), }) } } @@ -412,12 +407,10 @@ async fn heartbeat_ignores_isolated_transient_health_failures() { move |address| { restart_calls.fetch_add(1, Ordering::SeqCst); async move { - Ok(crate::sidecar::GatewayBootstrap { - endpoint: crate::sidecar::GatewayEndpoint { - address, - url: "http://gateway".into(), - }, - started: false, + Ok(crate::sidecar::GatewayEndpoint { + address, + url: "http://gateway".into(), + instance_id: "gateway".into(), }) } } @@ -436,7 +429,7 @@ async fn heartbeat_ignores_isolated_transient_health_failures() { } #[tokio::test] -async fn heartbeat_rediscovery_does_not_consume_the_actual_restart_allowance() { +async fn heartbeat_rediscovery_consumes_the_shared_restart_allowance() { let restart_calls = Arc::new(AtomicUsize::new(0)); let error = maintain_gateway_with( "127.0.0.1:47632".parse().unwrap(), @@ -449,12 +442,10 @@ async fn heartbeat_rediscovery_does_not_consume_the_actual_restart_allowance() { let restart_calls = restart_calls.clone(); async move { let attempt = restart_calls.fetch_add(1, Ordering::SeqCst); - Ok(crate::sidecar::GatewayBootstrap { - endpoint: crate::sidecar::GatewayEndpoint { - address, - url: "http://gateway".into(), - }, - started: attempt > 0, + Ok(crate::sidecar::GatewayEndpoint { + address, + url: "http://gateway".into(), + instance_id: format!("gateway-{attempt}"), }) } } @@ -463,7 +454,7 @@ async fn heartbeat_rediscovery_does_not_consume_the_actual_restart_allowance() { .await .unwrap_err(); - assert_eq!(restart_calls.load(Ordering::SeqCst), 2); + assert_eq!(restart_calls.load(Ordering::SeqCst), 1); assert!(error.to_string().contains("after its coordinated restart")); } @@ -496,12 +487,10 @@ async fn heartbeat_attempts_at_most_one_successful_restart() { let restart_calls = restart_calls.clone(); async move { restart_calls.fetch_add(1, Ordering::SeqCst); - Ok(crate::sidecar::GatewayBootstrap { - endpoint: crate::sidecar::GatewayEndpoint { - address, - url: "http://still-unhealthy".into(), - }, - started: true, + Ok(crate::sidecar::GatewayEndpoint { + address, + url: "http://still-unhealthy".into(), + instance_id: "still-unhealthy".into(), }) } } @@ -546,12 +535,10 @@ async fn old_mcp_maintenance_loop_exits_when_install_generation_is_replaced() { move |address| { restart_calls.fetch_add(1, Ordering::SeqCst); async move { - Ok(crate::sidecar::GatewayBootstrap { - endpoint: crate::sidecar::GatewayEndpoint { - address, - url: "http://unexpected-restart".into(), - }, - started: true, + Ok(crate::sidecar::GatewayEndpoint { + address, + url: "http://unexpected-restart".into(), + instance_id: "unexpected".into(), }) } } @@ -645,8 +632,8 @@ fn assert_retired_mcp_postcheck_preserves_gateway(gateway_pid: u32, gateway_toke let url = format!("http://{address}"); let state = dir.path().join("bootstrap-state"); std::fs::create_dir(&state).unwrap(); - let owner_path = crate::sidecar::sidecar_owner_path(&state, CodingAgent::Codex, &url); - let pid_path = crate::sidecar::sidecar_pid_path(&state, CodingAgent::Codex, &url); + let owner_path = crate::sidecar::sidecar_owner_path(&state, &url); + let pid_path = crate::sidecar::sidecar_pid_path(&state, &url); crate::sidecar::write_sidecar_owner( &owner_path, gateway_pid, diff --git a/crates/cli/tests/coverage/plugin_host_tests.rs b/crates/cli/tests/coverage/plugin_host_tests.rs index 934fe6290..4634c370a 100644 --- a/crates/cli/tests/coverage/plugin_host_tests.rs +++ b/crates/cli/tests/coverage/plugin_host_tests.rs @@ -2174,26 +2174,23 @@ fn sidecar_lock_zero_wait_fails_when_the_endpoint_is_owned() { } #[test] -fn sidecar_ownership_records_are_endpoint_specific_and_enumerated() { +fn sidecar_ownership_records_are_endpoint_scoped_and_recognize_legacy_files() { let dir = tempdir().unwrap(); - let first = sidecar_owner_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47632"); - let second = sidecar_owner_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47633"); + let first = sidecar_owner_path(dir.path(), "http://127.0.0.1:47632"); + let second = sidecar_owner_path(dir.path(), "http://127.0.0.1:47633"); let legacy = dir.path().join("codex-sidecar.owner.json"); - let ignored = sidecar_owner_path( - dir.path(), - CodingAgent::ClaudeCode, - "http://127.0.0.1:47634", - ); + let ignored = sidecar_owner_path(dir.path(), "http://127.0.0.1:47634"); for path in [&first, &second, &legacy, &ignored] { fs::write(path, "{}").unwrap(); } - let paths = sidecar_owner_paths(dir.path(), CodingAgent::Codex).unwrap(); + let paths = sidecar_owner_paths(dir.path()).unwrap(); - assert_eq!(paths.len(), 3); + assert_eq!(paths.len(), 4); assert!(paths.contains(&first)); assert!(paths.contains(&second)); assert!(paths.contains(&legacy)); + assert!(paths.contains(&ignored)); assert_ne!(first, second); } @@ -2203,7 +2200,6 @@ fn managed_sidecar_publishes_valid_ownership_before_parent_validation() { let _home = HomeScope::enter(dir.path()); let state = dir.path().join("bootstrap-state"); let _state = EnvVarGuard::set_path(BOOTSTRAP_STATE_DIR_ENV, &state); - let _agent = EnvVarGuard::set_value(BOOTSTRAP_AGENT_ENV, "codex"); let _token = EnvVarGuard::set_value("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", "test-shutdown-token"); let _fingerprint = @@ -2213,8 +2209,8 @@ fn managed_sidecar_publishes_valid_ownership_before_parent_validation() { publish_sidecar_owner_from_env(address).unwrap(); let url = format!("http://{address}"); - let owner_path = sidecar_owner_path(&state, CodingAgent::Codex, &url); - let pid_path = sidecar_pid_path(&state, CodingAgent::Codex, &url); + let owner_path = sidecar_owner_path(&state, &url); + let pid_path = sidecar_pid_path(&state, &url); validate_sidecar_owner( &owner_path, &pid_path, @@ -2235,7 +2231,7 @@ fn managed_shutdown_rejects_a_listener_without_authenticated_health_proof() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let url = format!("http://{address}"); - let owner_path = sidecar_owner_path(&state, CodingAgent::Codex, &url); + let owner_path = sidecar_owner_path(&state, &url); write_sidecar_owner( &owner_path, std::process::id(), @@ -2250,7 +2246,7 @@ fn managed_shutdown_rejects_a_listener_without_authenticated_health_proof() { let request = read_http_request(&mut stream); request_sender.send(request).unwrap(); let body = format!( - r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{},"instance_id":"test-instance"}}"#, env!("CARGO_PKG_VERSION"), BOOTSTRAP_PROTOCOL_VERSION ); @@ -2265,7 +2261,7 @@ fn managed_shutdown_rejects_a_listener_without_authenticated_health_proof() { .unwrap(); }); - let error = stop_owned_sidecar_record(CodingAgent::Codex, &state, &owner_path).unwrap_err(); + let error = stop_owned_sidecar_record(&state, &owner_path).unwrap_err(); assert!(error.contains("foreign listener"), "{error}"); let request = request_receiver @@ -2346,8 +2342,8 @@ fn windows_sidecar_job_terminates_an_assigned_process() { fn sidecar_reaper_removes_only_the_exited_process_records() { let dir = tempdir().unwrap(); let url = "http://127.0.0.1:47632"; - let owner_path = sidecar_owner_path(dir.path(), CodingAgent::Codex, url); - let pid_path = sidecar_pid_path(dir.path(), CodingAgent::Codex, url); + let owner_path = sidecar_owner_path(dir.path(), url); + let pid_path = sidecar_pid_path(dir.path(), url); let endpoint_lock = lock_sidecar_endpoint(dir.path(), url).unwrap(); #[cfg(windows)] let mut command = { @@ -2400,8 +2396,8 @@ fn sidecar_reaper_does_not_block_other_cleanup_on_a_contended_lock() { for url in [blocked_url, free_url] { let child = short_lived_command().spawn().unwrap(); let pid = child.id(); - let owner_path = sidecar_owner_path(dir.path(), CodingAgent::Codex, url); - let pid_path = sidecar_pid_path(dir.path(), CodingAgent::Codex, url); + let owner_path = sidecar_owner_path(dir.path(), url); + let pid_path = sidecar_pid_path(dir.path(), url); write_sidecar_owner( &owner_path, pid, @@ -2659,7 +2655,7 @@ fn startup_reprobes_a_transient_foreign_health_result_after_locking() { let (mut ready, _) = listener.accept().unwrap(); let _ = read_http_request(&mut ready); let body = format!( - r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{},"instance_id":"test-instance"}}"#, env!("CARGO_PKG_VERSION"), BOOTSTRAP_PROTOCOL_VERSION ); @@ -2700,11 +2696,12 @@ fn active_startup_lock_waits_for_relay_identity_instead_of_rejecting_listener() .write_all(b"HTTP/1.1 503 Starting\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") .unwrap(); drop(starting); + drop(owner_lock); let (mut ready, _) = listener.accept().unwrap(); let _ = read_http_request(&mut ready); let body = format!( - r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{},"instance_id":"test-instance"}}"#, env!("CARGO_PKG_VERSION"), BOOTSTRAP_PROTOCOL_VERSION ); @@ -2717,7 +2714,6 @@ fn active_startup_lock_waits_for_relay_identity_instead_of_rejecting_listener() .as_bytes(), ) .unwrap(); - drop(owner_lock); }); let endpoint = ensure_sidecar_bind(CodingAgent::Codex, address).unwrap(); diff --git a/crates/cli/tests/coverage/plugin_install_setup_tests.rs b/crates/cli/tests/coverage/plugin_install_setup_tests.rs index f9a0148fa..14de47921 100644 --- a/crates/cli/tests/coverage/plugin_install_setup_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_setup_tests.rs @@ -51,7 +51,7 @@ fn setup_runner_defaults_are_explicit_no_ops() { assert!(runner.snapshot(IntegrationHost::Codex).unwrap().is_none()); runner.restore_snapshot(&PluginSetupSnapshot::Mock).unwrap(); - runner.refresh_gateway(IntegrationHost::Codex).unwrap(); + runner.refresh_gateway().unwrap(); } #[test] @@ -68,7 +68,6 @@ fn setup_descriptions_reject_unexpanded_hosts_and_unknown_actions() { let runner = RealPluginSetupRunner; let root = Path::new("unused"); assert!(std::panic::catch_unwind(|| runner.snapshot(IntegrationHost::All)).is_err()); - assert!(std::panic::catch_unwind(|| runner.refresh_gateway(IntegrationHost::All)).is_err()); assert!( std::panic::catch_unwind(|| runner.setup(IntegrationHost::All, DEFAULT_GATEWAY_URL, root)) .is_err() diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index 8884816c9..9f1e784a9 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -645,8 +645,8 @@ impl PluginSetupRunner for MockSetupRunner { self.record("restore snapshot".into()) } - fn refresh_gateway(&self, host: IntegrationHost) -> Result<(), String> { - self.record(format!("refresh {}", host.as_arg())) + fn refresh_gateway(&self) -> Result<(), String> { + self.record("refresh gateway".into()) } fn setup( @@ -1519,8 +1519,14 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { "codex", &["plugin".into(), "arg with space".into(), "quote\"$".into()], ); + #[cfg(not(windows))] + assert!(quoted.contains("'arg with space'")); + #[cfg(not(windows))] + assert!(quoted.contains("'quote\"$'")); + #[cfg(windows)] assert!(quoted.contains("\"arg with space\"")); - assert!(quoted.contains("\"quote\\\"\\$\"")); + #[cfg(windows)] + assert!(quoted.contains("\"quote^\"$\"")); let runner = MockRunner::default() .with_executable("codex", "/bin/codex") @@ -2111,12 +2117,12 @@ fn force_install_unregisters_existing_host_before_reinstall() { setup_runner .calls() .iter() - .any(|call| call == "refresh codex") + .any(|call| call == "refresh gateway") ); let setup_calls = setup_runner.calls(); let refresh_index = setup_calls .iter() - .position(|call| call == "refresh codex") + .position(|call| call == "refresh gateway") .unwrap(); let snapshot_index = setup_calls .iter() @@ -2190,7 +2196,7 @@ fn claude_force_install_retires_and_replaces_its_mcp_generation() { setup_runner .calls() .iter() - .any(|call| call == "refresh claude-code") + .any(|call| call == "refresh gateway") ); } @@ -2242,7 +2248,7 @@ fn claude_force_install_rollback_restores_generation_files_and_setup_snapshot() assert_eq!( setup_calls .iter() - .filter(|call| call.as_str() == "refresh claude-code") + .filter(|call| call.as_str() == "refresh gateway") .count(), 2, "the previous and replacement gateway generations must both be retired: {setup_calls:?}" @@ -2655,7 +2661,7 @@ fn force_install_keeps_existing_registration_when_gateway_refresh_fails() { .with_executable("codex", "/bin/codex") .with_codex_registration(true, true); let setup_runner = MockSetupRunner { - failing_call: Some("refresh codex".into()), + failing_call: Some("refresh gateway".into()), ..MockSetupRunner::default() }; let options = PluginInstallOptions { @@ -2668,7 +2674,7 @@ fn force_install_keeps_existing_registration_when_gateway_refresh_fails() { let error = install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); - assert!(error.contains("refresh codex failed")); + assert!(error.contains("refresh gateway failed")); assert!(layout.state_path.exists()); assert!(layout.plugin_root.exists()); previous.verify_current().unwrap(); @@ -2683,7 +2689,7 @@ fn force_install_keeps_existing_registration_when_gateway_refresh_fails() { setup_runner.calls(), vec![ "snapshot codex".to_string(), - "refresh codex".to_string(), + "refresh gateway".to_string(), "restore snapshot".to_string(), ] ); @@ -2726,7 +2732,7 @@ fn force_install_restores_previous_install_after_doctor_failure() { let refreshes = setup_calls .iter() .enumerate() - .filter(|(_, call)| call.as_str() == "refresh codex") + .filter(|(_, call)| call.as_str() == "refresh gateway") .map(|(index, _)| index) .collect::>(); assert_eq!( @@ -3038,7 +3044,11 @@ fn plugin_registration_failure_rolls_back_marketplace_without_plugin_removal() { ) .unwrap_err(); - assert!(error.contains("plugin add nemo-relay-plugin")); + assert!(error.contains("plugin add"), "{error}"); + assert!( + error.contains("nemo-relay-plugin@nemo-relay-local"), + "{error}" + ); assert!(!layout.marketplace_root.exists()); assert!(!layout.state_path.exists()); assert!( @@ -3120,7 +3130,7 @@ fn retry_after_partial_registration_rollback_does_not_restore_uninstalled_setup( assert_eq!( setup_runner.calls(), - vec!["refresh codex"], + vec!["refresh gateway"], "retry cleanup may stop the gateway but must not restore provider/hooks setup that install never reached" ); } @@ -3200,7 +3210,7 @@ fn uninstall_uses_installed_state_and_removes_marketplace() { let setup_calls = setup_runner.calls(); let refresh_index = setup_calls .iter() - .position(|call| call == "refresh codex") + .position(|call| call == "refresh gateway") .unwrap(); let uninstall_index = setup_calls .iter() @@ -3977,7 +3987,7 @@ fn uninstall_cleans_up_plugin_setup_before_host_removal_failure() { assert_eq!( setup_runner.calls(), vec![ - "refresh codex".to_string(), + "refresh gateway".to_string(), format!("uninstall codex {DEFAULT_GATEWAY_URL}"), ] ); diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index 0a69d3fad..da47ef8eb 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -375,7 +375,15 @@ async fn healthz_returns_ok() { assert_eq!(body["status"], json!("ok")); assert_eq!(body["service"], json!("nemo-relay")); assert_eq!(body["version"], json!(env!("CARGO_PKG_VERSION"))); - assert_eq!(body["bootstrap_protocol"], json!(1)); + assert_eq!( + body["bootstrap_protocol"], + json!(crate::sidecar::BOOTSTRAP_PROTOCOL_VERSION) + ); + assert!( + body["instance_id"] + .as_str() + .is_some_and(|value| !value.is_empty()) + ); } #[tokio::test] @@ -521,13 +529,17 @@ fn readiness_file_is_published_atomically_with_gateway_identity() { let path = directory.path().join("gateway.ready.json"); let address = "127.0.0.1:43123".parse().unwrap(); - write_ready_file(&path, address).unwrap(); + write_ready_file(&path, address, "test-instance").unwrap(); let ready: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); assert_eq!(ready["address"], json!(address)); assert_eq!(ready["service"], json!("nemo-relay")); assert_eq!(ready["version"], json!(env!("CARGO_PKG_VERSION"))); - assert_eq!(ready["bootstrap_protocol"], json!(1)); + assert_eq!( + ready["bootstrap_protocol"], + json!(crate::sidecar::BOOTSTRAP_PROTOCOL_VERSION) + ); + assert_eq!(ready["instance_id"], json!("test-instance")); assert!(!path.with_extension("json.tmp").exists()); } diff --git a/crates/cli/tests/coverage/setup_tests.rs b/crates/cli/tests/coverage/setup_tests.rs index 783ca14db..22f2da053 100644 --- a/crates/cli/tests/coverage/setup_tests.rs +++ b/crates/cli/tests/coverage/setup_tests.rs @@ -4,7 +4,6 @@ use super::*; use crate::test_support::CwdTestScope as CwdScope; use std::ffi::OsString; -use std::path::PathBuf; // Tests that exercise the global-config write path clear `$XDG_CONFIG_HOME` // because CI runners commonly set it to a real `/home/runner/.config` path. @@ -118,7 +117,6 @@ fn build_config_does_not_emit_observability_exporters() { let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![], - hermes_hooks_path: None, }; let rendered = build_config(&answers).to_string(); @@ -135,7 +133,6 @@ fn build_config_skips_empty_sections_when_no_backends_selected() { let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![], - hermes_hooks_path: None, }; let doc = build_config(&answers); @@ -152,7 +149,6 @@ fn build_config_emits_agents_block_with_user_facing_keys() { let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![CodingAgent::ClaudeCode, CodingAgent::Codex], - hermes_hooks_path: None, }; let doc = build_config(&answers); @@ -170,7 +166,6 @@ fn save_config_writes_project_scope_to_workspace_dir() { let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![CodingAgent::ClaudeCode], - hermes_hooks_path: None, }; let doc = build_config(&answers); let temp = tempfile::tempdir().unwrap(); @@ -212,7 +207,6 @@ command = "codex --full-auto" let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![CodingAgent::ClaudeCode], - hermes_hooks_path: None, }; let doc = build_config(&answers); save_config( @@ -255,7 +249,6 @@ fn save_config_writes_both_scopes_when_both_selected() { let answers = SetupAnswers { scope: ConfigScope::Both, agents: vec![], - hermes_hooks_path: None, }; let doc = build_config(&answers); let cwd = tempfile::tempdir().unwrap(); @@ -288,18 +281,6 @@ fn global_config_dir_and_preview_paths_prefer_xdg_when_set() { ); } -#[test] -fn build_config_emits_hooks_path_for_hermes_when_set() { - let answers = SetupAnswers { - scope: ConfigScope::Project, - agents: vec![CodingAgent::Hermes], - hermes_hooks_path: Some(std::path::PathBuf::from("/tmp/proj/.hermes/config.yaml")), - }; - let rendered = build_config(&answers).to_string(); - assert!(rendered.contains("[agents.hermes]")); - assert!(rendered.contains(r#"hooks_path = "/tmp/proj/.hermes/config.yaml""#)); -} - #[test] fn config_scope_labels_are_user_facing_and_stable() { assert!( @@ -319,18 +300,6 @@ fn config_scope_labels_are_user_facing_and_stable() { ); } -#[test] -fn hermes_host_config_is_user_owned_independent_of_relay_scope() { - let home = PathBuf::from("/home/user"); - let agents = [CodingAgent::Hermes]; - - assert_eq!( - hermes_config_path_for_agents(&agents, &home), - Some(PathBuf::from("/home/user/.hermes/config.yaml")) - ); - assert_eq!(hermes_config_path_for_agents(&[], &home), None); -} - #[test] fn existing_defaults_detects_scope_and_agents_from_docs() { let empty = Defaults::default(); @@ -417,7 +386,6 @@ config = { version = 1, components = [] } let doc = build_config(&SetupAnswers { scope: ConfigScope::Project, agents: vec![CodingAgent::Codex], - hermes_hooks_path: None, }); write_or_merge(&path, &doc, Some(CodingAgent::Codex)).unwrap(); @@ -436,7 +404,6 @@ fn write_or_merge_overwrites_without_merge_scope_and_reports_malformed_existing_ let doc = build_config(&SetupAnswers { scope: ConfigScope::Project, agents: vec![CodingAgent::Hermes], - hermes_hooks_path: Some(temp.path().join(".hermes/config.yaml")), }); write_or_merge(&path, &doc, None).unwrap(); diff --git a/crates/cli/tests/coverage/sidecar_state_tests.rs b/crates/cli/tests/coverage/sidecar_state_tests.rs index b5e099b7e..266867e63 100644 --- a/crates/cli/tests/coverage/sidecar_state_tests.rs +++ b/crates/cli/tests/coverage/sidecar_state_tests.rs @@ -29,7 +29,6 @@ impl Environment { "USERPROFILE", "XDG_CONFIG_HOME", BOOTSTRAP_STATE_DIR_ENV, - BOOTSTRAP_AGENT_ENV, SHUTDOWN_TOKEN_ENV, crate::config::BOOTSTRAP_FINGERPRINT_ENV, ]; @@ -52,7 +51,6 @@ impl Environment { fn clear_managed_bootstrap(&self) { self.remove(BOOTSTRAP_STATE_DIR_ENV); - self.remove(BOOTSTRAP_AGENT_ENV); self.remove(SHUTDOWN_TOKEN_ENV); self.remove(crate::config::BOOTSTRAP_FINGERPRINT_ENV); } @@ -74,7 +72,6 @@ impl Drop for Environment { fn configure_managed_bootstrap(environment: &Environment, state: &Path) { environment.set(BOOTSTRAP_STATE_DIR_ENV, state); - environment.set(BOOTSTRAP_AGENT_ENV, "codex"); environment.set(SHUTDOWN_TOKEN_ENV, "shutdown-token"); environment.set(crate::config::BOOTSTRAP_FINGERPRINT_ENV, "fingerprint"); } @@ -140,21 +137,11 @@ fn managed_owner_environment_is_validated_before_writing_state() { environment.clear_managed_bootstrap(); environment.set(BOOTSTRAP_STATE_DIR_ENV, "relative-state"); - environment.set(BOOTSTRAP_AGENT_ENV, "codex"); environment.set(SHUTDOWN_TOKEN_ENV, "shutdown-token"); let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); assert!(error.contains("must be an absolute path"), "{error}"); environment.set(BOOTSTRAP_STATE_DIR_ENV, dir.path()); - environment.set(BOOTSTRAP_AGENT_ENV, "unsupported"); - let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); - assert!(error.contains("unsupported bootstrap agent"), "{error}"); - - environment.remove(BOOTSTRAP_AGENT_ENV); - let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); - assert!(error.contains(BOOTSTRAP_AGENT_ENV), "{error}"); - - environment.set(BOOTSTRAP_AGENT_ENV, "codex"); environment.set(SHUTDOWN_TOKEN_ENV, ""); let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); assert!(error.contains(SHUTDOWN_TOKEN_ENV), "{error}"); @@ -174,47 +161,19 @@ fn managed_owner_environment_is_validated_before_writing_state() { } #[test] -fn managed_owner_supports_claude_code_identity() { +fn managed_owner_is_endpoint_scoped_without_a_host_identity() { let dir = tempdir().unwrap(); let state = dir.path().join("state"); let environment = Environment::isolated(); configure_managed_bootstrap(&environment, &state); - for (identity, port) in [("claude", 47633), ("claude-code", 47634)] { - environment.set(BOOTSTRAP_AGENT_ENV, identity); - let address = format!("127.0.0.1:{port}").parse().unwrap(); - - publish_owner_from_env(address).unwrap(); - - let url = format!("http://{address}"); - let owner = owner_path(&state, CodingAgent::ClaudeCode, &url); - let pid = pid_path(&state, CodingAgent::ClaudeCode, &url); - validate_owner( - &owner, - &pid, - std::process::id(), - &url, - "shutdown-token", - Some("fingerprint"), - ) - .unwrap(); - } -} - -#[test] -fn managed_owner_supports_hermes_identity() { - let dir = tempdir().unwrap(); - let state = dir.path().join("state"); - let environment = Environment::isolated(); - configure_managed_bootstrap(&environment, &state); - environment.set(BOOTSTRAP_AGENT_ENV, "hermes"); - let address = "127.0.0.1:47635".parse().unwrap(); + let address = "127.0.0.1:47633".parse().unwrap(); publish_owner_from_env(address).unwrap(); let url = format!("http://{address}"); validate_owner( - &owner_path(&state, CodingAgent::Hermes, &url), - &pid_path(&state, CodingAgent::Hermes, &url), + &owner_path(&state, &url), + &pid_path(&state, &url), std::process::id(), &url, "shutdown-token", @@ -223,6 +182,35 @@ fn managed_owner_supports_hermes_identity() { .unwrap(); } +#[test] +fn publishing_endpoint_owner_migrates_legacy_agent_scoped_records() { + let dir = tempdir().unwrap(); + let state = dir.path().join("state"); + std::fs::create_dir_all(&state).unwrap(); + let environment = Environment::isolated(); + configure_managed_bootstrap(&environment, &state); + let address = "127.0.0.1:47636".parse().unwrap(); + let url = format!("http://{address}"); + let legacy_owner = state.join(format!("codex-sidecar-{}.owner.json", lock_name(&url))); + let legacy_pid = state.join(format!("codex-sidecar-{}.pid", lock_name(&url))); + write_owner( + &legacy_owner, + 41, + &url, + "old-token", + Some("old-fingerprint"), + ) + .unwrap(); + std::fs::write(&legacy_pid, "41").unwrap(); + + publish_owner_from_env(address).unwrap(); + + assert!(!legacy_owner.exists()); + assert!(!legacy_pid.exists()); + assert!(owner_path(&state, &url).exists()); + assert!(pid_path(&state, &url).exists()); +} + #[test] fn failed_owner_publish_removes_the_partial_pid_record() { let dir = tempdir().unwrap(); @@ -232,8 +220,8 @@ fn failed_owner_publish_removes_the_partial_pid_record() { configure_managed_bootstrap(&environment, &state); let address = "127.0.0.1:47632".parse().unwrap(); let url = format!("http://{address}"); - let owner = owner_path(&state, CodingAgent::Codex, &url); - let pid = pid_path(&state, CodingAgent::Codex, &url); + let owner = owner_path(&state, &url); + let pid = pid_path(&state, &url); std::fs::create_dir_all(&owner).unwrap(); #[cfg(windows)] { @@ -270,15 +258,11 @@ fn owner_validation_reports_a_missing_record() { #[test] fn owner_enumeration_distinguishes_missing_and_unreadable_directories() { let dir = tempdir().unwrap(); - assert!( - owner_paths(&dir.path().join("missing"), CodingAgent::Codex) - .unwrap() - .is_empty() - ); + assert!(owner_paths(&dir.path().join("missing")).unwrap().is_empty()); let file = dir.path().join("not-a-directory"); std::fs::write(&file, "file").unwrap(); - let error = owner_paths(&file, CodingAgent::Codex).unwrap_err(); + let error = owner_paths(&file).unwrap_err(); assert!(error.contains("failed to enumerate"), "{error}"); } @@ -286,19 +270,19 @@ fn owner_enumeration_distinguishes_missing_and_unreadable_directories() { fn stale_owner_cleanup_requires_shutdown_credentials() { let dir = tempdir().unwrap(); let url = "http://127.0.0.1:9"; - let owner = owner_path(dir.path(), CodingAgent::Codex, url); + let owner = owner_path(dir.path(), url); assert!( - stop_owned_record(CodingAgent::Codex, dir.path(), &owner).is_ok(), + stop_owned_record(dir.path(), &owner).is_ok(), "an already absent owner is clean" ); write_owner(&owner, 42, url, "", Some("fingerprint")).unwrap(); - let error = stop_owned_record(CodingAgent::Codex, dir.path(), &owner).unwrap_err(); + let error = stop_owned_record(dir.path(), &owner).unwrap_err(); assert!(error.contains("has no shutdown token"), "{error}"); write_owner(&owner, 42, url, "shutdown-token", None).unwrap(); - let error = stop_owned_record(CodingAgent::Codex, dir.path(), &owner).unwrap_err(); + let error = stop_owned_record(dir.path(), &owner).unwrap_err(); assert!( error.contains("no authenticated bootstrap fingerprint"), "{error}" @@ -309,12 +293,12 @@ fn stale_owner_cleanup_requires_shutdown_credentials() { fn unavailable_owned_sidecar_is_removed_without_sending_shutdown() { let dir = tempdir().unwrap(); let url = "http://127.0.0.1:9"; - let owner = owner_path(dir.path(), CodingAgent::Codex, url); - let pid = pid_path(dir.path(), CodingAgent::Codex, url); + let owner = owner_path(dir.path(), url); + let pid = pid_path(dir.path(), url); write_owner(&owner, 42, url, "shutdown-token", Some("fingerprint")).unwrap(); std::fs::write(&pid, "42").unwrap(); - stop_owned_record(CodingAgent::Codex, dir.path(), &owner).unwrap(); + stop_owned_record(dir.path(), &owner).unwrap(); assert!(!owner.exists()); assert!(!pid.exists()); @@ -329,7 +313,7 @@ fn unavailable_legacy_owner_removes_the_legacy_pid_record() { write_owner(&owner, 42, url, "shutdown-token", Some("fingerprint")).unwrap(); std::fs::write(&pid, "42").unwrap(); - stop_owned_record(CodingAgent::Codex, dir.path(), &owner).unwrap(); + stop_owned_record(dir.path(), &owner).unwrap(); assert!(!owner.exists()); assert!(!pid.exists()); @@ -351,8 +335,8 @@ fn authenticated_owned_sidecar_is_shut_down_and_cleaned_up() { )); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); - let owner = owner_path(dir.path(), CodingAgent::Codex, &url); - let pid = pid_path(dir.path(), CodingAgent::Codex, &url); + let owner = owner_path(dir.path(), &url); + let pid = pid_path(dir.path(), &url); write_owner(&owner, 42, &url, "shutdown-token", Some("fingerprint")).unwrap(); std::fs::write(&pid, "42").unwrap(); let server = thread::spawn(move || { @@ -363,7 +347,7 @@ fn authenticated_owned_sidecar_is_shut_down_and_cleaned_up() { let nonce = request_header(&request, "x-nemo-relay-bootstrap-nonce"); let proof = key.proof(&fingerprint, &nonce); let body = format!( - r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{},"instance_id":"test-instance"}}"#, env!("CARGO_PKG_VERSION"), BOOTSTRAP_PROTOCOL_VERSION ); @@ -391,7 +375,7 @@ fn authenticated_owned_sidecar_is_shut_down_and_cleaned_up() { }); assert_eq!(probe(&url, Some("fingerprint")), RelayHealth::Compatible); - stop_owned_record(CodingAgent::Codex, dir.path(), &owner).unwrap(); + stop_owned_record(dir.path(), &owner).unwrap(); server.join().unwrap(); assert!(!owner.exists()); @@ -408,10 +392,10 @@ fn stop_owned_aggregates_record_errors() { let runtime = state_dir().unwrap(); std::fs::create_dir_all(&runtime).unwrap(); let url = "http://127.0.0.1:9"; - let owner = owner_path(&runtime, CodingAgent::Codex, url); + let owner = owner_path(&runtime, url); write_owner(&owner, 42, url, "", Some("fingerprint")).unwrap(); - let error = stop_owned(CodingAgent::Codex).unwrap_err(); + let error = stop_owned(url).unwrap_err(); assert!(error.contains("has no shutdown token"), "{error}"); } @@ -424,5 +408,23 @@ fn stop_owned_succeeds_when_no_records_exist() { environment.set("HOME", dir.path()); environment.remove("USERPROFILE"); - stop_owned(CodingAgent::Codex).unwrap(); + stop_owned(crate::sidecar::DEFAULT_URL).unwrap(); +} + +#[test] +fn stop_owned_leaves_other_managed_endpoints_untouched() { + let dir = tempdir().unwrap(); + let environment = Environment::isolated(); + environment.set("XDG_CONFIG_HOME", dir.path()); + environment.set("HOME", dir.path()); + environment.remove("USERPROFILE"); + let runtime = state_dir().unwrap(); + std::fs::create_dir_all(&runtime).unwrap(); + let other_url = "http://127.0.0.1:47633"; + let other_owner = owner_path(&runtime, other_url); + write_owner(&other_owner, 42, other_url, "", Some("fingerprint")).unwrap(); + + stop_owned(crate::sidecar::DEFAULT_URL).unwrap(); + + assert!(other_owner.exists()); } diff --git a/crates/cli/tests/coverage/sidecar_tests.rs b/crates/cli/tests/coverage/sidecar_tests.rs index f7d627ee3..66f7b0666 100644 --- a/crates/cli/tests/coverage/sidecar_tests.rs +++ b/crates/cli/tests/coverage/sidecar_tests.rs @@ -139,7 +139,8 @@ fn readiness_file_requires_exact_protocol_identity() { "service": "nemo-relay", "version": env!("CARGO_PKG_VERSION"), "bootstrap_protocol": BOOTSTRAP_PROTOCOL_VERSION, - "address": "127.0.0.1:47777" + "address": "127.0.0.1:47777", + "instance_id": "test-instance" })) .unwrap(), ) @@ -154,7 +155,8 @@ fn readiness_file_requires_exact_protocol_identity() { "service": "nemo-relay", "version": env!("CARGO_PKG_VERSION"), "bootstrap_protocol": BOOTSTRAP_PROTOCOL_VERSION + 1, - "address": "127.0.0.1:47777" + "address": "127.0.0.1:47777", + "instance_id": "test-instance" })) .unwrap(), ) @@ -194,20 +196,19 @@ fn unused_loopback_address() -> SocketAddr { fn direct_sidecar_start_classifies_existing_listeners_before_spawning() { let dir = tempfile::tempdir().unwrap(); let compatible_body = format!( - r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{},"instance_id":"test-instance"}}"#, env!("CARGO_PKG_VERSION"), BOOTSTRAP_PROTOCOL_VERSION ); let (address, server) = one_health_response(compatible_body); - let bootstrap = start_sidecar_bind( + let endpoint = start_sidecar_bind( &GatewaySpec::new(CodingAgent::Codex, address), dir.path(), dir.path(), None, ) .unwrap(); - assert!(!bootstrap.started); - assert_eq!(bootstrap.endpoint.address, address); + assert_eq!(endpoint.address, address); server.join().unwrap(); let incompatible_body = format!( @@ -240,9 +241,9 @@ fn direct_sidecar_start_classifies_existing_listeners_before_spawning() { #[test] fn sidecar_record_cleanup_is_scoped_to_the_exited_pid() { let dir = tempfile::tempdir().unwrap(); - let invalid = sidecar_owner_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47630"); + let invalid = sidecar_owner_path(dir.path(), "http://127.0.0.1:47630"); std::fs::write(&invalid, "not-json").unwrap(); - let matching = sidecar_owner_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47631"); + let matching = sidecar_owner_path(dir.path(), "http://127.0.0.1:47631"); write_sidecar_owner( &matching, 42, @@ -251,9 +252,9 @@ fn sidecar_record_cleanup_is_scoped_to_the_exited_pid() { Some("fingerprint"), ) .unwrap(); - let matching_pid = sidecar_pid_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47631"); + let matching_pid = sidecar_pid_path(dir.path(), "http://127.0.0.1:47631"); std::fs::write(&matching_pid, "42").unwrap(); - let other = sidecar_owner_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47632"); + let other = sidecar_owner_path(dir.path(), "http://127.0.0.1:47632"); write_sidecar_owner( &other, 43, @@ -263,7 +264,7 @@ fn sidecar_record_cleanup_is_scoped_to_the_exited_pid() { ) .unwrap(); - cleanup_sidecar_records_for_pid(dir.path(), CodingAgent::Codex, 42); + cleanup_sidecar_records_for_pid(dir.path(), 42); assert!(!matching.exists()); assert!(!matching_pid.exists()); @@ -272,7 +273,7 @@ fn sidecar_record_cleanup_is_scoped_to_the_exited_pid() { let not_a_directory = dir.path().join("runtime-file"); std::fs::write(¬_a_directory, "file").unwrap(); - cleanup_sidecar_records_for_pid(¬_a_directory, CodingAgent::Codex, 42); + cleanup_sidecar_records_for_pid(¬_a_directory, 42); } fn exited_command() -> Command { diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index eeb7d896b..bb6279c37 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -82,10 +82,11 @@ command = "my-codex-wrapper" nemo-relay run --agent codex ``` -For Hermes, setup installs the persistent user-level MCP and trusted shell-hook -configuration. Transparent `run --agent hermes` temporarily suppresses that -fixed MCP entry, exports the dynamic `NEMO_RELAY_GATEWAY_URL`, and restores the -original Hermes config after the run. +For Hermes, interactive setup configures only Relay's transparent wrapper. +Persistent MCP and trusted shell-hook state is owned by +`nemo-relay install hermes`. Transparent `run --agent hermes` exports the +dynamic `NEMO_RELAY_GATEWAY_URL` through a process-private `HERMES_HOME` +overlay; it never rewrites the user's Hermes config. Use `--dry-run --print` to inspect the generated hook config, gateway environment, gateway URL, and final command without launching the agent. @@ -317,7 +318,7 @@ Generated hook bundles subscribe to the events needed for that mapping: | Agent | LLM lifecycle and correlation hooks | Scope, tool, and mark hooks | | --- | --- | --- | -| Claude Code | `UserPromptSubmit`, `Stop` | `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `Notification`, `PreCompact`, `PostCompact` | +| Claude Code | `UserPromptSubmit`, `Stop` | `SessionStart`, `SessionEnd`, `UserPromptExpansion`, `SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `Notification`, `PreCompact`, `PostCompact` | | Codex | `UserPromptSubmit`, `Stop` | `SessionStart`, `SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `PreCompact`, `PostCompact` | | Hermes | `pre_api_request`, `post_api_request`, `api_request_error`, `pre_llm_call`, `post_llm_call` | `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`, `subagent_start`, `subagent_stop`, `pre_tool_call`, `post_tool_call` | diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index 044afcab9..e90de421b 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -131,7 +131,7 @@ referenced by user observability configuration. Rerun variable. Plugin-owned hook commands pin `http://127.0.0.1:47632` explicitly, so an -ambient `NEMO_RELAY_PLUGIN_GATEWAY_URL` cannot split hook traffic from the +ambient `NEMO_RELAY_GATEWAY_URL` cannot split hook traffic from the required MCP-managed gateway. If user configuration, forwarded credentials, or the Relay version changes diff --git a/docs/nemo-relay-cli/hermes.mdx b/docs/nemo-relay-cli/hermes.mdx index c874f4de0..42f04b63b 100644 --- a/docs/nemo-relay-cli/hermes.mdx +++ b/docs/nemo-relay-cli/hermes.mdx @@ -150,10 +150,12 @@ nemo-relay hermes -- chat --provider custom ``` This mode starts a gateway on an OS-assigned loopback port and exports -`NEMO_RELAY_GATEWAY_URL` to Hermes. Relay temporarily removes its fixed MCP -entry from the Hermes config so Hermes does not start a redundant sidecar. The -same canonical hooks target the dynamic gateway. The original Hermes config is -restored after the process exits, including failure paths. +`NEMO_RELAY_GATEWAY_URL` to Hermes. Relay creates a process-private +`HERMES_HOME` overlay containing the dynamic hooks and no fixed MCP entry, while +linking the rest of the active Hermes profile state. The user config is never +rewritten, so concurrent transparent and bare Hermes processes cannot observe +each other's temporary hook configuration. The overlay is removed after the +wrapped process exits. Inspect the launch without changing files or starting Hermes: @@ -170,8 +172,9 @@ nemo-relay uninstall hermes ``` Unrelated Hermes configuration remains unchanged. The interactive -`nemo-relay config hermes` and `nemo-relay config --reset hermes` paths remain -available when configuring Relay through the setup wizard. +`nemo-relay config hermes` and `nemo-relay config --reset hermes` paths manage +only Relay's transparent-wrapper configuration; persistent Hermes MCP, hook, +and trust state is owned exclusively by `install` and `uninstall`. ## Validate End-to-End Capture diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index 7c3288fe0..caba4d2f4 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -141,6 +141,8 @@ exits after 300 idle seconds by default. If the gateway exits while MCP stdio is open, the client performs one coordinated restart and fails if recovery does not succeed. A foreign process on the configured port is rejected with an actionable conflict instead of being accepted because it returned HTTP 200. +Health and readiness include a per-process instance ID, so every open MCP +client recognizes the same replacement and refuses a second replacement. Codex marks the MCP server as required, so the captured turn waits for verified gateway readiness. Claude Code 2.1.121 or newer uses `alwaysLoad`, which blocks @@ -150,6 +152,13 @@ asynchronously, so its first lifecycle hook can still run first. The installed under the same startup lock and retrying the original canonical payload once. The MCP server advertises no tools in any host. +Hook recovery retries only connection failures that are known to precede an +HTTP response. Relay does not claim crash-consistent exactly-once delivery +across a gateway failure after request acceptance: there is no durable hook +outbox or delivery-deduplication journal. The opt-in E2E assertions below verify +exactly-once lifecycle output for the exercised healthy and cold-start paths, +not arbitrary process crashes between request acceptance and response delivery. + On Windows, Relay requests Job Object breakaway only when the host job permits it. If the host applies a restrictive job but permits nested jobs, Relay keeps the sidecar scoped to that host job and retains a nested cleanup job for its @@ -162,9 +171,12 @@ The compatibility fingerprint covers resolved provider, exporter, dynamic plugin, and relevant environment settings without exposing their values in the health response. For Claude Code and Codex, `nemo-relay install --force` generation-fences the replacement MCP -process and uses a private ownership token to stop a sidecar owned by the -replaced host when necessary. It does not terminate an unrelated listener. -Hermes installation transactionally rotates its MCP generation fence. +process and uses a private ownership token to stop the shared fixed-endpoint +sidecar when necessary. Ownership is keyed by endpoint, not host, so an upgrade +from any installed host can retire a gateway originally started by another. +Custom managed endpoints are left untouched. Relay never sends its shutdown +token to an unrelated listener. Hermes installation transactionally rotates +its MCP generation fence. Persistent mode resolves only system and user Relay configuration and starts in the user configuration directory. Project `.nemo-relay` layers remain available @@ -260,7 +272,14 @@ provider routing is restored from the Relay backup. Unrelated user hooks and configuration remain unchanged. Hermes uninstall removes only Relay-owned MCP, hook, trust, and generation state. -## Migration from the Hidden Shim Command +## Compatibility and Migration + +This integration now requires Codex 0.143.0, Claude Code 2.1.121, or Hermes +Agent 0.18.2 at minimum. Prerelease and malformed version output is rejected. +Configured opaque wrappers must identify the selected host through their +canonical `--version` output. Interactive `nemo-relay config hermes` now owns +only transparent-wrapper configuration; use `nemo-relay install hermes` and +`nemo-relay uninstall hermes` for persistent Hermes state. This release removes the internal `nemo-relay plugin-shim` command. Existing generated installations should be refreshed with @@ -282,13 +301,14 @@ Persistent generated hook commands include the fixed gateway URL. Transparent wrappers provide `NEMO_RELAY_GATEWAY_URL` for their dynamic gateway. When a transparent run uses a recognizable wrapper command, such as `npx codex`, Relay checks the host version through that wrapper. Opaque custom wrappers remain -supported and are responsible for selecting a compatible host version; install -and doctor still enforce the minimum version for directly managed hosts. - -Hermes setup is now available through the same public install, doctor, and -uninstall commands as Claude Code and Codex. `nemo-relay config hermes` remains -an interactive configuration path, but automation should use -`nemo-relay install hermes`. +supported when invoking the configured command with `--version` emits the +selected host's canonical version output. Install and doctor enforce the same +minimum-version policy. + +Hermes setup uses the same public install, doctor, and uninstall commands as +Claude Code and Codex. `nemo-relay config hermes` configures Relay's transparent +wrapper only; it no longer mutates Hermes user configuration. Use +`nemo-relay install hermes` for the persistent host integration. ## Source Marketplace Discovery diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 3afb86cef..952480394 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -115,7 +115,9 @@ nemo-relay install all For Claude Code and Codex, `nemo-relay install` writes local marketplace files, registers the selected host plugin, and performs the required provider and hook -setup. For Hermes, it updates only Relay-owned user configuration. Use +setup. For Hermes, `install` is the only command that updates Relay-owned user +MCP, hook, trust, and generation state; interactive `config hermes` manages +only the transparent wrapper. Use `nemo-relay uninstall ` to roll back and `nemo-relay doctor --plugin ` to check an installed integration. @@ -153,9 +155,9 @@ nemo-relay install hermes ``` It writes the MCP server and trusted hooks to `$HERMES_HOME/config.yaml` or -`~/.hermes/config.yaml`. Transparent Hermes runs temporarily suppress that -fixed MCP entry, export the dynamic `NEMO_RELAY_GATEWAY_URL`, and restore the -original config afterward. +`~/.hermes/config.yaml`. Transparent Hermes runs leave that file untouched and +export the dynamic `NEMO_RELAY_GATEWAY_URL` through a process-private +`HERMES_HOME` overlay with no fixed MCP entry. Shared TOML config is loaded from `/etc/nemo-relay/config.toml`, then nearest project `.nemo-relay/config.toml`, then diff --git a/integrations/coding-agents/claude-code/README.md b/integrations/coding-agents/claude-code/README.md index 36dfeaf98..e0f86fd80 100644 --- a/integrations/coding-agents/claude-code/README.md +++ b/integrations/coding-agents/claude-code/README.md @@ -29,10 +29,10 @@ The bundle forwards `SessionStart`, `SessionEnd`, `UserPromptSubmit`, correlation events. The bundle requires Claude Code 2.1.121 or newer. That version provides the -`alwaysLoad` MCP startup barrier used to make Relay ready before session hooks. -Older versions before 2.1.116 also reject `UserPromptExpansion` in the plugin -hook configuration. Installation fails before changing host state when the -version is too old, and `nemo-relay doctor` reports the required upgrade. +`alwaysLoad` MCP startup barrier used to make Relay ready before session hooks +and accepts the complete generated hook schema. Installation fails before +changing host state when the version is too old, and `nemo-relay doctor` +reports the required upgrade. Claude Code observability is turn-oriented. A multi-turn session can produce one root `claude-code-turn` span or ATIF trajectory per user turn. That is expected diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index d0fd9b9d7..fa23a72f4 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -93,7 +93,7 @@ provider, Relay, OpenTelemetry, AWS, proxy, certificate, and config-referenced credential variable names without storing values. Plugin-owned hook commands pin `http://127.0.0.1:47632` explicitly, so an -ambient `NEMO_RELAY_PLUGIN_GATEWAY_URL` cannot split hook traffic from the +ambient `NEMO_RELAY_GATEWAY_URL` cannot split hook traffic from the required MCP-managed gateway. If the Relay version, user configuration, or forwarded credentials change, an diff --git a/scripts/test-codex-plugin-e2e.sh b/scripts/test-codex-plugin-e2e.sh index a1592428f..652e0a807 100755 --- a/scripts/test-codex-plugin-e2e.sh +++ b/scripts/test-codex-plugin-e2e.sh @@ -59,7 +59,7 @@ cleanup() { kill "$provider_pid" 2>/dev/null || true wait "$provider_pid" 2>/dev/null || true fi - pid_file="$(find_sidecar_file 'codex-sidecar*.pid')" + pid_file="$(find_sidecar_file 'sidecar-*.pid')" if [[ -n "$pid_file" && -f "$pid_file" ]]; then sidecar_pid="$(cat "$pid_file" 2>/dev/null || true)" if [[ "$sidecar_pid" =~ ^[0-9]+$ ]]; then @@ -393,7 +393,7 @@ if ! wait_for_mcp_initialize "$holder_stdout" "$holder_pid"; then cat "$holder_stderr" >&2 exit 1 fi -old_sidecar_pid_file="$(find_sidecar_file 'codex-sidecar*.pid')" +old_sidecar_pid_file="$(find_sidecar_file 'sidecar-*.pid')" [[ -s "$old_sidecar_pid_file" ]] old_sidecar_pid="$(cat "$old_sidecar_pid_file")" kill -0 "$old_sidecar_pid" @@ -430,8 +430,8 @@ replacement_stdout="$work/mcp-replacement.stdout" replacement_stderr="$work/mcp-replacement.stderr" run_mcp_once "$replacement_stdout" "$replacement_stderr" 3 grep -q '"serverInfo"' "$replacement_stdout" -replacement_pid_file="$(find_sidecar_file 'codex-sidecar*.pid')" -replacement_owner_file="$(find_sidecar_file 'codex-sidecar*.owner.json')" +replacement_pid_file="$(find_sidecar_file 'sidecar-*.pid')" +replacement_owner_file="$(find_sidecar_file 'sidecar-*.owner.json')" [[ -s "$replacement_pid_file" && -s "$replacement_owner_file" ]] replacement_pid="$(cat "$replacement_pid_file")" [[ "$replacement_pid" != "$old_sidecar_pid" ]] @@ -478,7 +478,7 @@ while time.monotonic() < deadline: raise SystemExit("concurrent Codex requests did not reach the provider within 25 seconds") PY [[ "$(cat "$provider_barrier/arrivals")" -eq 2 ]] -sidecar_pid_file="$(find_sidecar_file 'codex-sidecar*.pid')" +sidecar_pid_file="$(find_sidecar_file 'sidecar-*.pid')" [[ -s "$sidecar_pid_file" ]] shared_sidecar_pid="$(cat "$sidecar_pid_file")" [[ "$shared_sidecar_pid" =~ ^[0-9]+$ ]] @@ -497,7 +497,7 @@ with socket.socket() as sock: assert sock.connect_ex(("127.0.0.1", 47632)) == 0, "shared Relay gateway stopped early" PY -owner_file="$(find_sidecar_file 'codex-sidecar*.owner.json')" +owner_file="$(find_sidecar_file 'sidecar-*.owner.json')" stop_owned_sidecar "$owner_file" if ! wait_for_process_exit "$shared_sidecar_pid"; then echo "shared Relay gateway did not exit after the shutdown handshake" >&2 diff --git a/scripts/test-hermes-mcp-e2e.sh b/scripts/test-hermes-mcp-e2e.sh index 9ef6685c4..1dd742763 100755 --- a/scripts/test-hermes-mcp-e2e.sh +++ b/scripts/test-hermes-mcp-e2e.sh @@ -28,7 +28,7 @@ cleanup() { kill "$provider_pid" 2>/dev/null || true wait "$provider_pid" 2>/dev/null || true fi - for owner in "${XDG_CONFIG_HOME:-}/nemo-relay/bootstrap"/hermes-sidecar-*.owner.json; do + for owner in "${XDG_CONFIG_HOME:-}/nemo-relay/bootstrap"/sidecar-*.owner.json; do [[ -f "$owner" ]] || continue python3 - "$owner" <<'PY' || true import json From e15b0b613639c8c801622df3fb7711802fd25920 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sat, 11 Jul 2026 11:47:27 -0400 Subject: [PATCH 009/102] fix(cli): harden shared gateway lifecycle Signed-off-by: Will Killian --- crates/cli/src/agent_process.rs | 51 +---- crates/cli/src/config.rs | 122 ++++++++++-- crates/cli/src/gateway.rs | 118 +++++++++--- crates/cli/src/hermes.rs | 82 ++++++-- crates/cli/src/hermes/trust.rs | 22 ++- crates/cli/src/install_generation.rs | 15 +- crates/cli/src/launcher.rs | 62 +++++- crates/cli/src/mcp.rs | 4 +- crates/cli/src/mcp/gateway.rs | 70 ++++--- crates/cli/src/plugin_host/codex.rs | 25 ++- crates/cli/src/plugin_host/shared.rs | 17 +- crates/cli/src/plugin_install/mod.rs | 26 +-- crates/cli/src/server.rs | 21 +- crates/cli/src/sidecar.rs | 158 ++++++++++++++- crates/cli/src/sidecar/state.rs | 182 +++++++++++++++++- crates/cli/tests/cli_tests.rs | 58 ++++++ .../cli/tests/coverage/agent_process_tests.rs | 36 +++- crates/cli/tests/coverage/gateway_tests.rs | 69 ++++++- crates/cli/tests/coverage/hermes_tests.rs | 18 +- .../coverage/install_generation_tests.rs | 22 +++ crates/cli/tests/coverage/installer_tests.rs | 2 +- crates/cli/tests/coverage/launcher_tests.rs | 46 +++-- .../cli/tests/coverage/mcp_gateway_tests.rs | 9 +- crates/cli/tests/coverage/mcp_tests.rs | 64 +----- .../cli/tests/coverage/plugin_host_tests.rs | 101 +++++++++- .../tests/coverage/plugin_install_tests.rs | 26 +++ crates/cli/tests/coverage/server_tests.rs | 26 +++ crates/cli/tests/coverage/setup_tests.rs | 6 +- .../cli/tests/coverage/sidecar_state_tests.rs | 67 +++++++ crates/cli/tests/coverage/sidecar_tests.rs | 18 ++ docs/nemo-relay-cli/hermes.mdx | 13 +- docs/nemo-relay-cli/plugin-installation.mdx | 16 +- integrations/coding-agents/README.md | 16 +- integrations/coding-agents/codex/README.md | 11 ++ scripts/test-codex-plugin-e2e.sh | 1 + scripts/test-support/codex_mock_provider.py | 2 + 36 files changed, 1320 insertions(+), 282 deletions(-) diff --git a/crates/cli/src/agent_process.rs b/crates/cli/src/agent_process.rs index 8d332b87b..87427f332 100644 --- a/crates/cli/src/agent_process.rs +++ b/crates/cli/src/agent_process.rs @@ -100,67 +100,28 @@ fn resolve_candidate(base: &Path, extensions: &[OsString]) -> Option { }) } -/// Creates a synchronous command, including the `cmd.exe` bridge required by Windows shims. +/// Creates a synchronous command. +/// +/// Rust's Windows process implementation recognizes `.cmd` and `.bat` programs and applies its +/// hardened batch-file argument encoder. Keeping process construction here argv-based avoids +/// reinterpreting host arguments through a second, hand-built shell command line. pub(crate) fn std_command(argv: &[String]) -> Command { debug_assert!(!argv.is_empty()); let program = resolve_executable(&argv[0]).unwrap_or_else(|| PathBuf::from(&argv[0])); - #[cfg(windows)] - if is_windows_command_script(&program) { - let mut command = - Command::new(std::env::var_os("COMSPEC").unwrap_or_else(|| OsString::from("cmd.exe"))); - command - .args(["/d", "/s", "/c"]) - .arg(windows_command_line(&program, &argv[1..])); - return command; - } let mut command = Command::new(program); command.args(&argv[1..]); command } -/// Creates an asynchronous command with the same platform behavior as [`std_command`]. +/// Creates an asynchronous command with the same argv behavior as [`std_command`]. pub(crate) fn tokio_command(argv: &[String]) -> tokio::process::Command { debug_assert!(!argv.is_empty()); let program = resolve_executable(&argv[0]).unwrap_or_else(|| PathBuf::from(&argv[0])); - #[cfg(windows)] - if is_windows_command_script(&program) { - let mut command = tokio::process::Command::new( - std::env::var_os("COMSPEC").unwrap_or_else(|| OsString::from("cmd.exe")), - ); - command - .args(["/d", "/s", "/c"]) - .arg(windows_command_line(&program, &argv[1..])); - return command; - } let mut command = tokio::process::Command::new(program); command.args(&argv[1..]); command } -#[cfg(any(windows, test))] -pub(crate) fn is_windows_command_script(program: &Path) -> bool { - program - .extension() - .and_then(OsStr::to_str) - .is_some_and(|extension| { - extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat") - }) -} - -#[cfg(any(windows, test))] -pub(crate) fn windows_command_line(program: &Path, args: &[String]) -> String { - std::iter::once(crate::plugin_host::shell_quote_arg_for_platform( - &program.display().to_string(), - true, - )) - .chain( - args.iter() - .map(|argument| crate::plugin_host::shell_quote_arg_for_platform(argument, true)), - ) - .collect::>() - .join(" ") -} - #[cfg(test)] #[path = "../tests/coverage/agent_process_tests.rs"] mod tests; diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 349b40e33..e76486972 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -24,7 +24,7 @@ use strum::{Display, IntoStaticStr}; pub(crate) use crate::coding_agent::CodingAgent; use crate::error::CliError; -use crate::file_io::{LockAttempt, try_lock_exclusive}; +use crate::file_io::{LockAttempt, try_lock_exclusive, try_lock_shared}; #[cfg(test)] use crate::plugins::lifecycle::active_dynamic_plugin_components; use crate::plugins::lifecycle::{ @@ -215,9 +215,9 @@ pub(crate) struct ConfigCommand { /// only that agent's block from the existing config file. Omit to operate on all agents. #[arg(value_enum)] pub(crate) agent: Option, - /// Delete the project config file or the scoped agent block. A Hermes-scoped reset also - /// removes Relay-owned MCP, hooks, and trust from the user Hermes config. The wizard does not - /// run after reset; invoke `nemo-relay config` again to recreate configuration. + /// Delete the project config file or the scoped transparent-wrapper agent block. Persistent + /// Hermes MCP, hooks, and trust are removed with `nemo-relay uninstall hermes`. The wizard + /// does not run after reset; invoke `nemo-relay config` again to recreate configuration. #[arg(long)] pub(crate) reset: bool, } @@ -1175,9 +1175,13 @@ pub(crate) fn stream_bounded_regular_file( const BOOTSTRAP_HMAC_KEY_BYTES: usize = 32; const BOOTSTRAP_HMAC_LOCK_TIMEOUT: Duration = Duration::from_secs(5); const BOOTSTRAP_CHALLENGE_DOMAIN: &[u8] = b"nemo-relay/bootstrap-health/v1\0"; +const BOOTSTRAP_CLIENT_TOKEN_DOMAIN: &[u8] = b"nemo-relay/bootstrap-client/v1\0"; const PYTHON_ENVIRONMENT_ATTESTATION_DOMAIN: &[u8] = b"nemo-relay/python-environment-attestation/v1\0"; +/// Private proof installed into supported coding-agent provider configuration. +pub(crate) const BOOTSTRAP_CLIENT_TOKEN_HEADER: &str = "x-nemo-relay-client-token"; + /// Per-user secret used to authenticate a managed bootstrap listener without exposing key bytes. #[derive(Clone)] pub(crate) struct BootstrapChallengeKey(hmac::Key); @@ -1190,20 +1194,20 @@ impl BootstrapChallengeKey { ))) } + /// Loads an existing key without creating bootstrap state. Read-only diagnostics use this so + /// checking an uninstalled integration cannot mutate the user's configuration directory. + pub(crate) fn load_existing() -> Result, CliError> { + load_existing_bootstrap_hmac_key() + .map(|key| key.map(|key| Self(hmac::Key::new(hmac::HMAC_SHA256, &key)))) + } + pub(crate) fn proof(&self, fingerprint: &str, nonce: &str) -> String { let mut context = hmac::Context::with_key(&self.0); context.update(BOOTSTRAP_CHALLENGE_DOMAIN); context.update(fingerprint.as_bytes()); context.update(&[0]); context.update(nonce.as_bytes()); - let tag = context.sign(); - format!( - "hmac-sha256:{}", - tag.as_ref() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::() - ) + encode_hmac_tag(context.sign()) } pub(crate) fn verify(&self, fingerprint: &str, nonce: &str, proof: &str) -> bool { @@ -1223,12 +1227,39 @@ impl BootstrapChallengeKey { hmac::verify(&self.0, &message, &tag).is_ok() } + /// Returns a stable, per-user proof that authorizes use of credentials forwarded to a + /// managed sidecar. The HMAC key remains in Relay's private bootstrap state; coding-agent + /// configuration stores only this domain-separated proof. + pub(crate) fn client_token(&self) -> String { + encode_hmac_tag(hmac::sign(&self.0, BOOTSTRAP_CLIENT_TOKEN_DOMAIN)) + } + + pub(crate) fn verify_client_token(&self, token: &str) -> bool { + let Some(encoded) = token.strip_prefix("hmac-sha256:") else { + return false; + }; + let Some(tag) = decode_fixed_hex::<32>(encoded) else { + return false; + }; + hmac::verify(&self.0, BOOTSTRAP_CLIENT_TOKEN_DOMAIN, &tag).is_ok() + } + #[cfg(test)] pub(crate) fn from_bytes(bytes: &[u8]) -> Self { Self(hmac::Key::new(hmac::HMAC_SHA256, bytes)) } } +fn encode_hmac_tag(tag: hmac::Tag) -> String { + format!( + "hmac-sha256:{}", + tag.as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) +} + fn decode_fixed_hex(encoded: &str) -> Option<[u8; N]> { if encoded.len() != N * 2 || !encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) { return None; @@ -1295,15 +1326,76 @@ fn python_environment_attestation_message( } fn load_or_create_bootstrap_hmac_key() -> Result<[u8; BOOTSTRAP_HMAC_KEY_BYTES], CliError> { - let path = user_config_dir() + load_or_create_bootstrap_hmac_key_at(&bootstrap_hmac_key_path()?) +} + +fn bootstrap_hmac_key_path() -> Result { + user_config_dir() .map(|directory| directory.join("bootstrap").join("fingerprint-hmac.key")) .ok_or_else(|| { CliError::Config( "cannot determine the per-user NeMo Relay bootstrap state directory; set HOME or USERPROFILE" .into(), ) - })?; - load_or_create_bootstrap_hmac_key_at(&path) + }) +} + +fn load_existing_bootstrap_hmac_key() -> Result, CliError> { + let path = bootstrap_hmac_key_path()?; + let mut file = match OpenOptions::new().read(true).open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(CliError::Config(format!( + "failed to open bootstrap HMAC key {}: {error}", + path.display() + ))); + } + }; + let deadline = Instant::now() + BOOTSTRAP_HMAC_LOCK_TIMEOUT; + loop { + match try_lock_shared(&file) { + Ok(LockAttempt::Acquired) => break, + Ok(LockAttempt::Contended) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(25)); + } + Ok(LockAttempt::Contended) => { + return Err(CliError::Config(format!( + "timed out waiting for bootstrap HMAC key lock {}", + path.display() + ))); + } + Err(error) => { + return Err(CliError::Config(format!( + "failed to lock bootstrap HMAC key {}: {error}", + path.display() + ))); + } + } + } + let length = file + .metadata() + .map_err(|error| { + CliError::Config(format!( + "failed to inspect bootstrap HMAC key {}: {error}", + path.display() + )) + })? + .len(); + if length != BOOTSTRAP_HMAC_KEY_BYTES as u64 { + return Err(CliError::Config(format!( + "bootstrap HMAC key {} has invalid length {length}; expected {BOOTSTRAP_HMAC_KEY_BYTES} bytes", + path.display() + ))); + } + let mut key = [0_u8; BOOTSTRAP_HMAC_KEY_BYTES]; + file.read_exact(&mut key).map_err(|error| { + CliError::Config(format!( + "failed to read bootstrap HMAC key {}: {error}", + path.display() + )) + })?; + Ok(Some(key)) } fn load_or_create_bootstrap_hmac_key_at( diff --git a/crates/cli/src/gateway.rs b/crates/cli/src/gateway.rs index a49270140..f315ecb82 100644 --- a/crates/cli/src/gateway.rs +++ b/crates/cli/src/gateway.rs @@ -27,7 +27,7 @@ use nemo_relay::error::FlowError; use serde_json::{Map, Value, json}; use crate::alignment::{self, GatewayRouteKind}; -use crate::config::header_string; +use crate::config::{BOOTSTRAP_CLIENT_TOKEN_HEADER, header_string}; use crate::error::CliError; use crate::server::AppState; use crate::session::{GatewayCallPrep, GatewaySessionFinish, LlmGatewayStart, SessionManager}; @@ -50,7 +50,9 @@ pub(crate) async fn passthrough( request: Request, ) -> Result, CliError> { state.touch(); - let prepared = prepare_gateway_request(&state.config, request).await?; + let allow_environment_provider_auth = state.allows_environment_provider_auth(request.headers()); + let prepared = + prepare_gateway_request(&state.config, request, allow_environment_provider_auth).await?; let prep = state .sessions .prepare_gateway_call(&prepared.headers, build_llm_gateway_start(&prepared)) @@ -67,6 +69,7 @@ struct PreparedGatewayRequest { body_bytes: Bytes, request_json: Value, streaming: bool, + allow_environment_provider_auth: bool, } // Validates the gateway route, buffers the request body exactly once, and derives the metadata used @@ -75,8 +78,12 @@ struct PreparedGatewayRequest { async fn prepare_gateway_request( config: &crate::config::GatewayConfig, request: Request, + allow_environment_provider_auth: bool, ) -> Result { - let (parts, body) = request.into_parts(); + let (mut parts, body) = request.into_parts(); + // This proof authorizes Relay's local credential injection only. It must not be observed by + // middleware, recorded in ATOF, or forwarded to the model provider. + parts.headers.remove(BOOTSTRAP_CLIENT_TOKEN_HEADER); let provider = ProviderRoute::from_path(parts.uri.path()).ok_or_else(|| { CliError::InvalidPayload(format!("unsupported gateway path {}", parts.uri.path())) })?; @@ -89,8 +96,13 @@ async fn prepare_gateway_request( .path_and_query() .map(|p| p.as_str()) .unwrap_or(parts.uri.path()); - let upstream_url = gateway_upstream_url_override(provider, &parts.headers, path_and_query) - .unwrap_or_else(|| provider.upstream_url(config, path_and_query)); + let upstream_url = gateway_upstream_url_override( + provider, + &parts.headers, + path_and_query, + allow_environment_provider_auth, + ) + .unwrap_or_else(|| provider.upstream_url(config, path_and_query)); let streaming = request_json .get("stream") .and_then(Value::as_bool) @@ -104,6 +116,7 @@ async fn prepare_gateway_request( body_bytes, request_json, streaming, + allow_environment_provider_auth, }) } @@ -232,7 +245,7 @@ async fn run_unmanaged_gateway( &prepared.body_bytes, &prepared.headers, None, - prepared.provider, + ProviderForwarding::new(prepared.provider, prepared.allow_environment_provider_auth), ) .await?; let status = response.status(); @@ -354,7 +367,8 @@ fn build_buffered_func( let url = prepared.upstream_url.clone(); let body_bytes = prepared.body_bytes.clone(); let headers = prepared.headers.clone(); - let route = prepared.provider; + let forwarding = + ProviderForwarding::new(prepared.provider, prepared.allow_environment_provider_auth); Arc::new(move |request| { let http = http.clone(); let method = method.clone(); @@ -372,7 +386,7 @@ fn build_buffered_func( &body_bytes, &headers, Some(&request), - route, + forwarding, ) .await { @@ -522,7 +536,8 @@ fn build_streaming_func( let url = prepared.upstream_url.clone(); let body_bytes = prepared.body_bytes.clone(); let headers = prepared.headers.clone(); - let route = prepared.provider; + let forwarding = + ProviderForwarding::new(prepared.provider, prepared.allow_environment_provider_auth); Arc::new(move |request| { let http = http.clone(); let method = method.clone(); @@ -539,7 +554,7 @@ fn build_streaming_func( &body_bytes, &headers, Some(&request), - route, + forwarding, ) .await { @@ -749,17 +764,26 @@ async fn forward_upstream_request( body_bytes: &Bytes, headers: &HeaderMap, effective_request: Option<&LlmRequest>, - route: ProviderRoute, + forwarding: ProviderForwarding, ) -> Result { let (body_bytes, headers) = effective_upstream_request(body_bytes, headers, effective_request); - let sanitized = strip_replaceable_agent_auth_headers(&headers, route); + let sanitized = strip_replaceable_agent_auth_headers( + &headers, + forwarding.route, + forwarding.allow_environment_provider_auth, + ); let mut upstream = http.request(method.clone(), url).body(body_bytes.clone()); for (name, value) in &sanitized { if should_forward_request_header(name) { upstream = upstream.header(name, value); } } - upstream = inject_provider_auth(upstream, route, &sanitized); + upstream = inject_provider_auth( + upstream, + forwarding.route, + &sanitized, + forwarding.allow_environment_provider_auth, + ); upstream.send().await } @@ -814,8 +838,15 @@ fn inject_provider_auth( builder: reqwest::RequestBuilder, route: ProviderRoute, inbound: &HeaderMap, + allow_environment_provider_auth: bool, ) -> reqwest::RequestBuilder { - inject_provider_auth_with_env(builder, route, inbound, |key| std::env::var(key).ok()) + inject_provider_auth_with_env( + builder, + route, + inbound, + allow_environment_provider_auth, + |key| std::env::var(key).ok(), + ) } // Pure variant exposed for tests. The env lookup is injected so cases can be exercised without @@ -824,11 +855,15 @@ fn inject_provider_auth_with_env( builder: reqwest::RequestBuilder, route: ProviderRoute, inbound: &HeaderMap, + allow_environment_provider_auth: bool, env_lookup: F, ) -> reqwest::RequestBuilder where F: Fn(&str) -> Option, { + if !allow_environment_provider_auth { + return builder; + } let already_authed = inbound.contains_key(http::header::AUTHORIZATION) || inbound.contains_key("x-api-key") || inbound.contains_key("api-key") @@ -876,7 +911,7 @@ async fn passthrough_streaming( &prepared.body_bytes, &prepared.headers, None, - prepared.provider, + ProviderForwarding::new(prepared.provider, prepared.allow_environment_provider_auth), ) .await?; let status = response.status(); @@ -917,7 +952,7 @@ pub(crate) async fn models( request: Request, ) -> Result, CliError> { state.touch(); - let (parts, _body) = request.into_parts(); + let (mut parts, _body) = request.into_parts(); if parts.method != Method::GET { return build_response( StatusCode::METHOD_NOT_ALLOWED, @@ -931,16 +966,32 @@ pub(crate) async fn models( .path_and_query() .map(|p| p.as_str()) .unwrap_or(parts.uri.path()); - let upstream_url = gateway_upstream_url_override(provider, &parts.headers, path_and_query) - .unwrap_or_else(|| provider.upstream_url(&state.config, path_and_query)); - let sanitized = strip_replaceable_agent_auth_headers(&parts.headers, provider); + let allow_environment_provider_auth = state.allows_environment_provider_auth(&parts.headers); + parts.headers.remove(BOOTSTRAP_CLIENT_TOKEN_HEADER); + let upstream_url = gateway_upstream_url_override( + provider, + &parts.headers, + path_and_query, + allow_environment_provider_auth, + ) + .unwrap_or_else(|| provider.upstream_url(&state.config, path_and_query)); + let sanitized = strip_replaceable_agent_auth_headers( + &parts.headers, + provider, + allow_environment_provider_auth, + ); let mut upstream = state.http.get(upstream_url); for (name, value) in &sanitized { if should_forward_request_header(name) { upstream = upstream.header(name, value); } } - upstream = inject_provider_auth(upstream, provider, &sanitized); + upstream = inject_provider_auth( + upstream, + provider, + &sanitized, + allow_environment_provider_auth, + ); let upstream_response = upstream.send().await?; let status = upstream_response.status(); let headers = response_headers(upstream_response.headers()); @@ -957,6 +1008,21 @@ enum ProviderRoute { AnthropicCountTokens, } +#[derive(Clone, Copy)] +struct ProviderForwarding { + route: ProviderRoute, + allow_environment_provider_auth: bool, +} + +impl ProviderForwarding { + fn new(route: ProviderRoute, allow_environment_provider_auth: bool) -> Self { + Self { + route, + allow_environment_provider_auth, + } + } +} + impl ProviderRoute { // Maps public gateway paths to known upstream provider routes. Unsupported paths return `None` // so the caller can fail as a bad hook/gateway payload instead of constructing arbitrary URLs. @@ -1050,12 +1116,13 @@ fn gateway_upstream_url_override( route: ProviderRoute, headers: &HeaderMap, path_and_query: &str, + allow_environment_provider_auth: bool, ) -> Option { gateway_upstream_url_override_with_openai_key_state( route, headers, path_and_query, - env_var_is_nonempty("OPENAI_API_KEY"), + allow_environment_provider_auth && env_var_is_nonempty("OPENAI_API_KEY"), ) } @@ -1076,11 +1143,15 @@ fn gateway_upstream_url_override_with_openai_key_state( // Lets alignment adapters strip agent-native credentials only when the gateway can replace them // with standard provider API keys. Whitespace-only env vars are treated as missing because // forwarding an empty bearer value only replaces one authentication failure with another. -fn strip_replaceable_agent_auth_headers(headers: &HeaderMap, route: ProviderRoute) -> HeaderMap { +fn strip_replaceable_agent_auth_headers( + headers: &HeaderMap, + route: ProviderRoute, + allow_environment_provider_auth: bool, +) -> HeaderMap { strip_replaceable_agent_auth_headers_with_openai_key_state( headers, route, - env_var_is_nonempty("OPENAI_API_KEY"), + allow_environment_provider_auth && env_var_is_nonempty("OPENAI_API_KEY"), ) } @@ -1169,6 +1240,7 @@ fn should_forward_request_header(name: &HeaderName) -> bool { !is_hop_by_hop(name) && name != http::header::HOST && name != http::header::CONTENT_LENGTH + && name.as_str() != BOOTSTRAP_CLIENT_TOKEN_HEADER // Strip Accept-Encoding so upstreams return identity-encoded bodies; otherwise the // observability capture (`output.value` on LLM spans, ATIF trajectory bodies) records // gzip/br/zstd bytes that downstream consumers can't read. Bandwidth cost is paid only diff --git a/crates/cli/src/hermes.rs b/crates/cli/src/hermes.rs index abb6d16b2..712d0eade 100644 --- a/crates/cli/src/hermes.rs +++ b/crates/cli/src/hermes.rs @@ -28,9 +28,9 @@ use self::files::{ use self::trust::{json_bytes, parse_json_object, trusted_hooks, verify_trust}; use crate::error::CliError; use crate::file_io::atomic_write; -use crate::install_generation::GENERATION_FILE_ENV; #[cfg(test)] use crate::install_generation::GENERATION_FILE_NAME; +use crate::install_generation::{GENERATION_FILE_ENV, GenerationRetirement}; use crate::installer::HERMES_HOOK_EVENTS; use crate::sidecar::DEFAULT_BIND; pub(crate) use config::{persistent_hook_command, transparent_config}; @@ -59,14 +59,16 @@ pub(crate) fn install_persistent(config: &Path, relay: &Path) -> Result>(); - install_persistent_with( + let mut retirement = retire_generation_before_gateway_stop(&paths)?; + let result = install_persistent_with( paths, &relay, &environment, plugin_config.as_ref(), SystemTime::now(), atomic_write, - ) + ); + finish_generation_mutation(result, retirement.as_mut(), "install") } pub(crate) fn persistent_state_exists(config: &Path) -> bool { @@ -88,11 +90,53 @@ pub(crate) fn uninstall_persistent(config: &Path) -> Result, CliErr if !persistent_paths_have_managed_state(&paths)? { return Ok(Vec::new()); } - uninstall_persistent_with(paths, atomic_write) + let mut retirement = retire_generation_before_gateway_stop(&paths)?; + let result = uninstall_persistent_with(paths, atomic_write); + finish_generation_mutation(result, retirement.as_mut(), "uninstall") } -pub(crate) fn retire_persistent_gateway() -> Result<(), CliError> { - crate::plugin_host::stop_plugin_gateway().map_err(CliError::Install) +fn retire_generation_before_gateway_stop( + paths: &PersistentPaths, +) -> Result, CliError> { + let mut retirement = + GenerationRetirement::acquire(&paths.generation).map_err(CliError::Install)?; + if let Some(retirement) = retirement.as_mut() { + retirement + .invalidate_for_replacement() + .map_err(CliError::Install)?; + } + if let Err(error) = crate::plugin_host::stop_plugin_gateway() { + if let Some(retirement) = retirement.as_mut() + && let Err(restore_error) = retirement.restore_after_rollback() + { + return Err(CliError::Install(format!( + "{error}; additionally failed to restore the Hermes MCP generation: {restore_error}" + ))); + } + return Err(CliError::Install(error)); + } + Ok(retirement) +} + +fn finish_generation_mutation( + result: Result, + retirement: Option<&mut GenerationRetirement>, + operation: &str, +) -> Result { + match result { + Ok(value) => Ok(value), + Err(error) => { + let Some(retirement) = retirement else { + return Err(error); + }; + match retirement.restore_after_rollback() { + Ok(()) => Err(error), + Err(restore_error) => Err(CliError::Install(format!( + "{error}; additionally failed to restore the Hermes MCP generation after {operation}: {restore_error}" + ))), + } + } + } } fn persistent_paths_have_managed_state(paths: &PersistentPaths) -> Result { @@ -438,18 +482,24 @@ fn verify_hook_definitions(config: &Value, command: &str) -> Result<(), String> )); } } - let mut managed = config + let hooks = config .get("hooks") .and_then(Value::as_object) - .into_iter() - .flat_map(|hooks| hooks.iter()) - .flat_map(|(event, groups)| { - groups.as_array().into_iter().flatten().filter_map(|group| { - let candidate = group.get("command").and_then(Value::as_str)?; - is_managed_hook_command(candidate).then_some((event.as_str(), candidate)) - }) - }) - .collect::>(); + .ok_or_else(|| "Hermes hooks are missing".to_string())?; + let mut managed = Vec::new(); + for (event, groups) in hooks { + let groups = groups + .as_array() + .ok_or_else(|| format!("Hermes {event} hooks must be an array"))?; + for group in groups { + let Some(candidate) = group.get("command").and_then(Value::as_str) else { + continue; + }; + if is_managed_hook_command(candidate) { + managed.push((event.as_str(), candidate)); + } + } + } managed.sort_unstable(); let mut expected = HERMES_HOOK_EVENTS .iter() diff --git a/crates/cli/src/hermes/trust.rs b/crates/cli/src/hermes/trust.rs index f486312e7..179739ece 100644 --- a/crates/cli/src/hermes/trust.rs +++ b/crates/cli/src/hermes/trust.rs @@ -79,14 +79,20 @@ pub(super) fn verify_trust(allowlist_path: &Path, command: &str) -> Result<(), S )); } } - let mut managed = approvals - .iter() - .filter_map(|entry| { - let candidate = entry.get("command").and_then(Value::as_str)?; - let event = entry.get("event").and_then(Value::as_str)?; - is_managed_hook_command(candidate).then_some((event, candidate)) - }) - .collect::>(); + let mut managed = Vec::new(); + for entry in approvals { + let Some(candidate) = entry.get("command").and_then(Value::as_str) else { + continue; + }; + if !is_managed_hook_command(candidate) { + continue; + } + let event = entry + .get("event") + .and_then(Value::as_str) + .ok_or_else(|| "Hermes Relay hook approval is missing its event".to_string())?; + managed.push((event, candidate)); + } managed.sort_unstable(); let mut expected = HERMES_HOOK_EVENTS .iter() diff --git a/crates/cli/src/install_generation.rs b/crates/cli/src/install_generation.rs index b060fbc24..b8846283e 100644 --- a/crates/cli/src/install_generation.rs +++ b/crates/cli/src/install_generation.rs @@ -25,6 +25,15 @@ pub(crate) struct InstallGeneration { token: String, } +/// Shared generation lock held across one gateway adoption or startup. +/// +/// Retirement takes the exclusive side of the same file lock, so an installer cannot invalidate +/// and stop an endpoint until every startup that observed the old marker has either published a +/// ready gateway or failed. +pub(crate) struct ActiveGenerationGuard { + _lock: File, +} + impl InstallGeneration { pub(crate) fn capture_from_env() -> Result, String> { env::var_os(GENERATION_FILE_ENV) @@ -45,6 +54,10 @@ impl InstallGeneration { } pub(crate) fn verify_current(&self) -> Result<(), String> { + self.guard_current().map(|_| ()) + } + + pub(crate) fn guard_current(&self) -> Result { let file = open_generation(&self.path).map_err(|_| retired_generation_error(&self.path))?; lock_shared_with_timeout(&file, &self.path, DEFAULT_GENERATION_LOCK_TIMEOUT) .map_err(|_| retired_generation_error(&self.path))?; @@ -55,7 +68,7 @@ impl InstallGeneration { if locked != self.token || current != self.token { return Err(retired_generation_error(&self.path)); } - Ok(()) + Ok(ActiveGenerationGuard { _lock: file }) } } diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index de15aeadf..955a04e97 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -890,7 +890,15 @@ fn write_hooks(path: &Path, hooks: Value) -> Result<(), CliError> { // config and hook approval files remain private to this transparent run. Hermes has no standalone // config-file override, so `HERMES_HOME` is its supported process-scoped configuration boundary. fn create_hermes_overlay(source_home: &Path, source_config: &Path) -> Result { - let overlay = temp_dir("nemo-relay-hermes-home")?; + // Prefer a sibling of HERMES_HOME so Windows file hard links remain on one volume. Fall back + // to the OS temp directory when the profile parent is not writable; regular files then use a + // copy fallback, while profile directories remain live through junctions. + let overlay = source_home + .parent() + .filter(|parent| parent.is_dir()) + .and_then(|parent| private_temp_dir(parent, ".nemo-relay-hermes-home").ok()) + .map(Ok) + .unwrap_or_else(|| temp_dir("nemo-relay-hermes-home"))?; if let Err(error) = populate_hermes_overlay(&overlay, source_home, source_config) { let _ = std::fs::remove_dir_all(&overlay); return Err(error); @@ -949,9 +957,14 @@ fn link_hermes_state(source: &Path, destination: &Path, directory: bool) -> Resu #[cfg(windows)] { if directory { - std::os::windows::fs::symlink_dir(source, destination)?; + create_windows_junction(source, destination)?; } else { - std::fs::hard_link(source, destination)?; + match std::fs::hard_link(source, destination) { + Ok(()) => {} + Err(_) => { + std::fs::copy(source, destination)?; + } + } } Ok(()) } @@ -963,6 +976,35 @@ fn link_hermes_state(source: &Path, destination: &Path, directory: bool) -> Resu } } +#[cfg(windows)] +fn create_windows_junction(source: &Path, destination: &Path) -> Result<(), CliError> { + // Directory junctions do not require Developer Mode or SeCreateSymbolicLinkPrivilege. Paths + // travel through environment variables so the fixed cmd program never interpolates user + // content into shell syntax; delayed expansion is disabled for literal exclamation marks. + let status = std::process::Command::new( + std::env::var_os("COMSPEC").unwrap_or_else(|| std::ffi::OsString::from("cmd.exe")), + ) + .args([ + "/d", + "/v:off", + "/s", + "/c", + "mklink /J \"%NEMO_RELAY_JUNCTION_DEST%\" \"%NEMO_RELAY_JUNCTION_SOURCE%\" >nul", + ]) + .env("NEMO_RELAY_JUNCTION_SOURCE", source) + .env("NEMO_RELAY_JUNCTION_DEST", destination) + .status()?; + if status.success() { + Ok(()) + } else { + Err(CliError::Launch(format!( + "failed to create Hermes state junction {} -> {}: {status}", + destination.display(), + source.display() + ))) + } +} + // Chooses the Hermes config used as the source for a transparent-run overlay. If setup recorded a // specific path, reuse it; otherwise fall back to the active Hermes home. fn hermes_hooks_path(configured: Option<&Path>) -> Result { @@ -1008,8 +1050,18 @@ fn toml_string(value: &str) -> String { // Creates a uniquely named directory under the OS temp directory. UUIDv7 avoids collisions // between concurrent transparent runs without keeping persistent coordination state. fn temp_dir(prefix: &str) -> Result { - let path = std::env::temp_dir().join(format!("{prefix}-{}", uuid::Uuid::now_v7())); - std::fs::create_dir_all(&path)?; + private_temp_dir(&std::env::temp_dir(), prefix) +} + +fn private_temp_dir(parent: &Path, prefix: &str) -> Result { + let path = parent.join(format!("{prefix}-{}", uuid::Uuid::now_v7())); + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(&path)?; Ok(path) } diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs index dba799340..1bfa5bacc 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -61,9 +61,7 @@ where } #[cfg(test)] -use gateway::{ - maintain_gateway_with, maintain_gateway_with_generation, verify_bootstrap_generation, -}; +use gateway::{maintain_gateway_with, maintain_gateway_with_generation}; #[cfg(test)] use protocol::{MCP_PROTOCOL_VERSION, jsonrpc_error, response_for}; #[cfg(test)] diff --git a/crates/cli/src/mcp/gateway.rs b/crates/cli/src/mcp/gateway.rs index 5db6a8a56..5bd0c3b2f 100644 --- a/crates/cli/src/mcp/gateway.rs +++ b/crates/cli/src/mcp/gateway.rs @@ -45,10 +45,15 @@ impl GatewayPlan { }) } - pub(super) async fn acquire(self) -> Result { - let endpoint = ensure_gateway(self.spec.clone(), self.generation.clone()).await?; + pub(super) async fn acquire(mut self) -> Result { + let acquisition = acquire_gateway(self.spec.clone(), self.generation.clone()).await?; + let endpoint = acquisition.endpoint; + self.spec = acquisition.spec; let monitor = tokio::spawn(async move { self.monitor(endpoint).await }); - Ok(GatewayLease { monitor }) + Ok(GatewayLease { + monitor, + _endpoint_lease: acquisition.lease, + }) } async fn monitor(self, endpoint: crate::sidecar::GatewayEndpoint) -> Result<(), CliError> { @@ -70,7 +75,13 @@ impl GatewayPlan { }) } }, - move |_bind| ensure_gateway(restart_spec.clone(), restart_generation.clone()), + move |_bind, expected_instance| { + recover_gateway( + restart_spec.clone(), + restart_generation.clone(), + expected_instance, + ) + }, move || { let generation = verify_generation.clone(); async move { verify_generation_async(generation).await } @@ -104,13 +115,17 @@ impl GatewayPlan { instance_id, }; let monitor = tokio::spawn(async move { plan.monitor(endpoint).await }); - GatewayLease { monitor } + GatewayLease { + monitor, + _endpoint_lease: None, + } } } /// An active liveness lease. Dropping it stops heartbeats immediately. pub(super) struct GatewayLease { monitor: tokio::task::JoinHandle>, + _endpoint_lease: Option, } impl GatewayLease { @@ -127,30 +142,37 @@ impl Drop for GatewayLease { } } -async fn ensure_gateway( +async fn acquire_gateway( spec: GatewaySpec, generation: Option, -) -> Result { +) -> Result { tokio::task::spawn_blocking(move || { - if let Some(generation) = generation.as_ref() { - generation.verify_current()?; - } - let endpoint = spec.ensure()?; - if let Some(generation) = generation.as_ref() { - verify_bootstrap_generation(generation)?; - } - Ok(endpoint) + let _generation_guard = generation + .as_ref() + .map(InstallGeneration::guard_current) + .transpose()?; + spec.acquire() }) .await .map_err(|error| CliError::Launch(format!("gateway bootstrap task failed: {error}")))? .map_err(CliError::Launch) } -pub(super) fn verify_bootstrap_generation(generation: &InstallGeneration) -> Result<(), String> { - // A replacement MCP may already be reusing a compatible gateway started between the two - // generation checks. Leave a ready gateway for reuse or normal idle cleanup. Failures before - // readiness remain armed and are terminated by the sidecar launcher. - generation.verify_current() +async fn recover_gateway( + spec: GatewaySpec, + generation: Option, + expected_instance: String, +) -> Result { + tokio::task::spawn_blocking(move || { + let _generation_guard = generation + .as_ref() + .map(InstallGeneration::guard_current) + .transpose()?; + spec.recover(&expected_instance) + }) + .await + .map_err(|error| CliError::Launch(format!("gateway recovery task failed: {error}")))? + .map_err(CliError::Launch) } async fn verify_generation_async(generation: Option) -> Result<(), CliError> { @@ -175,7 +197,7 @@ pub(super) async fn maintain_gateway_with( where H: FnMut(String) -> HFuture, HFuture: std::future::Future>, - R: FnMut(SocketAddr) -> RFuture, + R: FnMut(SocketAddr, String) -> RFuture, RFuture: std::future::Future>, { maintain_gateway_with_generation( @@ -201,7 +223,7 @@ pub(super) async fn maintain_gateway_with_generation HFuture, HFuture: std::future::Future>, - R: FnMut(SocketAddr) -> RFuture, + R: FnMut(SocketAddr, String) -> RFuture, RFuture: std::future::Future>, G: FnMut() -> GFuture, GFuture: std::future::Future>, @@ -239,7 +261,7 @@ async fn maintain_gateway_instances_with_generation HFuture, HFuture: std::future::Future, CliError>>, - R: FnMut(SocketAddr) -> RFuture, + R: FnMut(SocketAddr, String) -> RFuture, RFuture: std::future::Future>, G: FnMut() -> GFuture, GFuture: std::future::Future>, @@ -269,7 +291,7 @@ where } recovery.require_restart()?; verify_generation().await?; - let recovered = restart(bind).await?; + let recovered = restart(bind, recovery.instance_id().into()).await?; recovery.observe(recovered.instance_id.clone())?; endpoint = recovered; } diff --git a/crates/cli/src/plugin_host/codex.rs b/crates/cli/src/plugin_host/codex.rs index 118d14959..c02c13458 100644 --- a/crates/cli/src/plugin_host/codex.rs +++ b/crates/cli/src/plugin_host/codex.rs @@ -10,9 +10,9 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use serde_json::{Value, json}; -use toml_edit::{DocumentMut, Item, Table, value}; +use toml_edit::{DocumentMut, InlineTable, Item, Table, Value as TomlValue, value}; -use crate::config::CodingAgent; +use crate::config::{BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey, CodingAgent}; use crate::installer::generated_hooks; #[cfg(test)] use crate::installer::merge_hooks; @@ -658,6 +658,9 @@ pub(super) fn prepare_codex_config(path: &Path) -> Result<(), String> { } pub(super) fn install_codex_config(path: &Path, gateway_url: &str) -> Result<(), String> { + let client_token = BootstrapChallengeKey::load() + .map_err(|error| error.to_string())? + .client_token(); let raw = read_optional_text(path)?; let mut doc = raw .parse::() @@ -676,6 +679,9 @@ pub(super) fn install_codex_config(path: &Path, gateway_url: &str) -> Result<(), provider["wire_api"] = value("responses"); provider["requires_openai_auth"] = value(true); provider["supports_websockets"] = value(false); + let mut headers = InlineTable::new(); + headers.insert(BOOTSTRAP_CLIENT_TOKEN_HEADER, TomlValue::from(client_token)); + provider["http_headers"] = Item::Value(TomlValue::InlineTable(headers)); providers["nemo-relay-openai"] = Item::Table(provider); if let Err(error) = atomic_write(path, doc.to_string().as_bytes()) { @@ -1101,7 +1107,22 @@ pub(super) fn codex_provider_installed(gateway_url: &str) -> bool { let Ok(doc) = raw.parse::() else { return false; }; + let Ok(Some(key)) = BootstrapChallengeKey::load_existing() else { + return false; + }; codex_config_doc_has_managed_install(&doc, gateway_url) + && codex_provider_client_token(&doc).is_some_and(|token| key.verify_client_token(token)) +} + +pub(super) fn codex_provider_client_token(doc: &DocumentMut) -> Option<&str> { + doc.get("model_providers") + .and_then(Item::as_table) + .and_then(|providers| providers.get("nemo-relay-openai")) + .and_then(Item::as_table) + .and_then(|provider| provider.get("http_headers")) + .and_then(Item::as_inline_table) + .and_then(|headers| headers.get(BOOTSTRAP_CLIENT_TOKEN_HEADER)) + .and_then(TomlValue::as_str) } pub(super) fn codex_hooks_installed(path: &Path) -> Result { diff --git a/crates/cli/src/plugin_host/shared.rs b/crates/cli/src/plugin_host/shared.rs index 361dfc7e0..27cba584d 100644 --- a/crates/cli/src/plugin_host/shared.rs +++ b/crates/cli/src/plugin_host/shared.rs @@ -42,7 +42,9 @@ fn posix_quote_arg(raw: &str) -> String { } fn cmd_quote_arg(raw: &str) -> String { - if raw.chars().all(|ch| { + if raw.is_empty() { + "\"\"".into() + } else if raw.chars().all(|ch| { ch.is_ascii_alphanumeric() || matches!(ch, '/' | '\\' | ':' | '.' | '_' | '-' | '=' | '@' | '+') }) { @@ -51,14 +53,17 @@ fn cmd_quote_arg(raw: &str) -> String { let mut escaped = String::new(); for ch in raw.chars() { match ch { - '%' => escaped.push_str("%%"), - '"' | '^' | '&' | '|' | '<' | '>' => { - escaped.push('^'); - escaped.push(ch); - } + // cmd expands percent variables even inside quotes. Insert a zero-length + // substring expansion before the literal percent, matching Rust's hardened + // batch-file encoder, so values such as `%USERPROFILE%` remain literal. + '%' => escaped.push_str("%%cd:~,%%"), + // Double quotes are represented by a paired quote inside a quoted cmd token. + '"' => escaped.push_str("\"\""), _ => escaped.push(ch), } } + // cmd metacharacters such as &, |, <, >, and ^ are literal inside this quote pair. A + // caret inside the quotes would become part of the argument, so do not add one. format!("\"{escaped}\"") } } diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/plugin_install/mod.rs index e4823ae53..ff45e18a9 100644 --- a/crates/cli/src/plugin_install/mod.rs +++ b/crates/cli/src/plugin_install/mod.rs @@ -449,7 +449,6 @@ fn install_hermes_host( println!("configure Hermes MCP and hooks at {}", config.display()); return Ok(()); } - crate::hermes::retire_persistent_gateway().map_err(|error| error.to_string())?; crate::hermes::install_persistent(&config, &relay).map_err(|error| error.to_string())?; if !options.skip_doctor { crate::hermes::diagnose_persistent(&config)?; @@ -467,7 +466,6 @@ fn uninstall_hermes_host(options: &PluginInstallOptions) -> Result<(), String> { ); return Ok(()); } - crate::hermes::retire_persistent_gateway().map_err(|error| error.to_string())?; crate::hermes::uninstall_persistent(&config).map_err(|error| error.to_string())?; println!("uninstalled Hermes integration"); Ok(()) @@ -855,14 +853,8 @@ fn uninstall_host_locked( .map(|state| state.plugin_root.as_path()) .unwrap_or(&layout.plugin_root); let local_install_exists = state.is_some() || layout.marketplace_root.exists(); - let mut generation_retirement = retire_installed_generation( - host, - plugin_root, - local_install_exists, - options, - runner, - setup_runner, - )?; + let mut generation_retirement = + retire_installed_generation(host, plugin_root, local_install_exists, options, runner)?; if let Some(retirement) = generation_retirement.as_mut() { retirement.invalidate_for_replacement().map_err(|error| { format!( @@ -871,6 +863,16 @@ fn uninstall_host_locked( ) })?; } + if let Err(error) = setup_runner.refresh_gateway() { + if let Some(retirement) = generation_retirement.as_mut() + && let Err(restore_error) = retirement.restore_after_rollback() + { + return Err(format!( + "{error}; additionally failed to restore the installed MCP generation: {restore_error}" + )); + } + return Err(error); + } uninstall_host_with_setup_override(host, options, runner, setup_runner, false) } @@ -880,7 +882,6 @@ fn retire_installed_generation( local_install_exists: bool, options: &PluginInstallOptions, runner: &dyn CommandRunner, - setup_runner: &dyn PluginSetupRunner, ) -> Result, String> { if options.dry_run || !plugin_uses_mcp(host) { return Ok(None); @@ -905,7 +906,6 @@ fn retire_installed_generation( if retirement.is_none() && existing_install && !legacy_plugin_without_mcp(host, plugin_root)? { return Err(missing_generation_fence_error(host, &generation_fence)); } - setup_runner.refresh_gateway()?; Ok(retirement) } @@ -921,13 +921,13 @@ fn retire_replacement_before_rollback( let mut retirement = GenerationRetirement::acquire(&layout.generation_fence) .map_err(|cause| invalid_generation_fence_error(host, &layout.generation_fence, &cause))? .ok_or_else(|| missing_generation_fence_error(host, &layout.generation_fence))?; - setup_runner.refresh_gateway()?; retirement.invalidate_for_replacement().map_err(|error| { format!( "failed to retire replacement MCP generation {} before rollback: {error}", layout.generation_fence.display() ) })?; + setup_runner.refresh_gateway()?; Ok(Some(retirement)) } diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index 9e5d015f0..d3d9c5dc9 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -30,7 +30,9 @@ use tokio::net::TcpListener; use tokio::sync::oneshot; use crate::adapters::{claude_code, codex, hermes}; -use crate::config::{BootstrapChallengeKey, GatewayConfig, ManagedBootstrapIdentity}; +use crate::config::{ + BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey, GatewayConfig, ManagedBootstrapIdentity, +}; use crate::error::CliError; use crate::gateway; use crate::plugins::lifecycle::{ActiveDynamicPluginComponent, DynamicPluginActivationSnapshot}; @@ -363,6 +365,23 @@ impl AppState { *last_activity = Instant::now(); } } + + /// Foreground gateways may supply provider credentials from their own environment for simple + /// local proxy use. Managed plugin sidecars are long-lived loopback services, so callers must + /// present the private per-user proof installed into their provider configuration before Relay + /// can spend a forwarded credential on their behalf. + pub(crate) fn allows_environment_provider_auth(&self, headers: &HeaderMap) -> bool { + if self.bootstrap_fingerprint.is_none() { + return true; + } + let Some(key) = self.bootstrap_challenge_key.as_ref() else { + return false; + }; + headers + .get(BOOTSTRAP_CLIENT_TOKEN_HEADER) + .and_then(|value| value.to_str().ok()) + .is_some_and(|token| key.verify_client_token(token)) + } } fn router_with_state(state: AppState) -> Router { diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs index 2542aa1f0..f90bd8554 100644 --- a/crates/cli/src/sidecar.rs +++ b/crates/cli/src/sidecar.rs @@ -44,9 +44,11 @@ pub(crate) use process::{ windows_sidecar_creation_flags, }; pub(crate) use state::BOOTSTRAP_STATE_DIR_ENV; +pub(crate) use state::EndpointLease; use state::{ - create_private_runtime_dir, open_lock as open_sidecar_lock, read_owner_record, - read_ready_file as read_sidecar_ready_file, runtime_dir, + RecoveryEpoch, create_private_runtime_dir, open_lock as open_sidecar_lock, read_owner_record, + read_ready_file as read_sidecar_ready_file, read_recovery_epoch, runtime_dir, + write_recovery_epoch, }; pub(crate) use state::{ lock_endpoint as lock_sidecar_endpoint, lock_path as sidecar_lock_path, @@ -78,6 +80,12 @@ pub(crate) struct GatewayEndpoint { pub(crate) instance_id: String, } +pub(crate) struct GatewayAcquisition { + pub(crate) endpoint: GatewayEndpoint, + pub(crate) lease: Option, + pub(crate) spec: GatewaySpec, +} + /// Complete launch and compatibility contract for one shared gateway. /// /// Callers construct this once and use the same value for discovery, startup, health checks, and @@ -126,11 +134,157 @@ impl GatewaySpec { ensure_gateway(self) } + pub(crate) fn acquire(&self) -> Result { + acquire_gateway(self) + } + + pub(crate) fn recover(&self, expected_instance: &str) -> Result { + recover_gateway(self, expected_instance) + } + pub(crate) fn healthy_instance(&self, url: &str) -> Option { health::compatible_instance_id(url, self.bootstrap_fingerprint.as_deref()) } } +fn acquire_gateway(spec: &GatewaySpec) -> Result { + if spec.bind.port() == 0 { + let endpoint = spec.ensure()?; + let effective_spec = GatewaySpec { + bind: endpoint.address, + ..spec.clone() + }; + let state = sidecar_state_dir()?; + let lease = EndpointLease::acquire(&state, &endpoint.url)?; + record_gateway_epoch( + &effective_spec, + &state, + &endpoint.url, + lease.fresh_epoch(), + &endpoint, + )?; + return Ok(GatewayAcquisition { + endpoint, + lease: Some(lease), + spec: effective_spec, + }); + } + let url = format!("http://{}", spec.bind); + let state = sidecar_state_dir()?; + let lease = EndpointLease::acquire(&state, &url)?; + let endpoint = if lease.fresh_epoch() { + spec.ensure()? + } else if let Some(epoch) = read_recovery_epoch(&state, &url)? { + spec.recover(&epoch.instance_id)? + } else { + spec.ensure()? + }; + record_gateway_epoch(spec, &state, &url, lease.fresh_epoch(), &endpoint)?; + Ok(GatewayAcquisition { + endpoint, + lease: Some(lease), + spec: spec.clone(), + }) +} + +fn recover_gateway(spec: &GatewaySpec, expected_instance: &str) -> Result { + debug_assert_ne!(spec.bind.port(), 0, "acquisition resolves automatic ports"); + let agent = spec.agent; + let url = format!("http://{}", spec.bind); + let runtime = runtime_dir(); + let state = sidecar_state_dir()?; + create_private_runtime_dir(&runtime).map_err(|error| { + sidecar_start_error( + agent, + &runtime, + &format!("failed to create {}: {error}", runtime.display()), + ) + })?; + create_private_runtime_dir(&state).map_err(|error| { + sidecar_start_error( + agent, + &runtime, + &format!("failed to create {}: {error}", state.display()), + ) + })?; + let lock = lock_sidecar_endpoint(&state, &url)?; + let (health, instance_id) = + probe_relay_health_after_lock(&url, spec.bootstrap_fingerprint.as_deref()); + match health { + RelayHealth::Compatible => { + let endpoint = compatible_endpoint(spec.bind, url.clone(), instance_id)?; + reconcile_gateway_epoch(&state, &url, false, &endpoint.instance_id)?; + return Ok(endpoint); + } + RelayHealth::Incompatible => return Err(incompatible_relay_error(agent, &url)), + RelayHealth::Foreign => return Err(foreign_listener_error(&url)), + RelayHealth::Unavailable => {} + } + let mut epoch = read_recovery_epoch(&state, &url)? + .ok_or_else(|| "shared gateway recovery epoch is missing".to_string())?; + if epoch.instance_id != expected_instance { + return Err(format!( + "shared Relay gateway recovery moved from instance {expected_instance} to {}", + epoch.instance_id + )); + } + if epoch.restarts >= 1 || epoch.pending { + return Err( + "shared Relay gateway became unhealthy after its endpoint-scoped restart".into(), + ); + } + epoch.restarts = 1; + epoch.pending = true; + write_recovery_epoch(&state, &epoch)?; + let endpoint = start_sidecar_bind(spec, &runtime, &state, Some(lock)) + .map_err(|error| sidecar_start_error(agent, &runtime, &error))?; + record_gateway_epoch(spec, &state, &url, false, &endpoint)?; + Ok(endpoint) +} + +fn record_gateway_epoch( + spec: &GatewaySpec, + state: &Path, + url: &str, + fresh: bool, + endpoint: &GatewayEndpoint, +) -> Result<(), String> { + let _lock = lock_sidecar_endpoint(state, url)?; + let current = spec.healthy_instance(url).ok_or_else(|| { + "shared Relay gateway disappeared before recovery was recorded".to_string() + })?; + let instance_id = if current == endpoint.instance_id { + &endpoint.instance_id + } else { + ¤t + }; + reconcile_gateway_epoch(state, url, fresh, instance_id) +} + +fn reconcile_gateway_epoch( + state: &Path, + url: &str, + fresh: bool, + instance_id: &str, +) -> Result<(), String> { + let mut epoch = read_recovery_epoch(state, url)?; + if fresh || epoch.is_none() { + return write_recovery_epoch(state, &RecoveryEpoch::new(url, instance_id)); + } + let epoch = epoch.as_mut().expect("gateway epoch is present"); + if epoch.instance_id == instance_id { + epoch.pending = false; + return write_recovery_epoch(state, epoch); + } + if epoch.pending || epoch.restarts == 0 { + epoch.instance_id = instance_id.into(); + epoch.restarts = 1; + epoch.pending = false; + return write_recovery_epoch(state, epoch); + } + Err("shared Relay gateway was replaced again after its endpoint-scoped restart".into()) +} + /// Persistent plugin gateway settings shared by MCP bootstrap and hook recovery. pub(crate) struct PluginGatewaySpec { pub(crate) gateway: GatewaySpec, diff --git a/crates/cli/src/sidecar/state.rs b/crates/cli/src/sidecar/state.rs index ed1c5d258..b957e7127 100644 --- a/crates/cli/src/sidecar/state.rs +++ b/crates/cli/src/sidecar/state.rs @@ -70,6 +70,140 @@ struct ReadyRecord { instance_id: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct RecoveryEpoch { + service: String, + bootstrap_protocol: u64, + url: String, + pub(super) instance_id: String, + pub(super) restarts: u8, + pub(super) pending: bool, +} + +impl RecoveryEpoch { + pub(super) fn new(url: &str, instance_id: &str) -> Self { + Self { + service: "nemo-relay".into(), + bootstrap_protocol: BOOTSTRAP_PROTOCOL_VERSION, + url: url.into(), + instance_id: instance_id.into(), + restarts: 0, + pending: false, + } + } +} + +/// Process-lifetime registration used to delimit one shared recovery epoch. +pub(crate) struct EndpointLease { + file: Option, + path: PathBuf, + fresh_epoch: bool, +} + +impl EndpointLease { + pub(crate) fn acquire(runtime: &Path, url: &str) -> Result { + create_private_runtime_dir(runtime).map_err(|error| { + format!( + "failed to create bootstrap state directory {}: {error}", + runtime.display() + ) + })?; + let registry_path = runtime.join(format!("{}-leases.lock", lock_name(url))); + let _registry = lock_file_for(®istry_path, SIDECAR_LOCK_TIMEOUT)?; + let prefix = format!("{}-lease-", lock_name(url)); + let mut active = false; + let entries = fs::read_dir(runtime).map_err(|error| { + format!( + "failed to inspect bootstrap leases in {}: {error}", + runtime.display() + ) + })?; + for entry in entries.filter_map(Result::ok) { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if !name.starts_with(&prefix) || !name.ends_with(".lock") { + continue; + } + let path = entry.path(); + let file = match OpenOptions::new().read(true).write(true).open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(format!( + "failed to inspect bootstrap lease {}: {error}", + path.display() + )); + } + }; + match try_lock_exclusive(&file) { + Ok(LockAttempt::Acquired) => { + drop(file); + let _ = fs::remove_file(path); + } + Ok(LockAttempt::Contended) => active = true, + Err(error) => { + return Err(format!( + "failed to inspect bootstrap lease {}: {error}", + path.display() + )); + } + } + } + let path = runtime.join(format!( + "{prefix}{}-{}.lock", + std::process::id(), + uuid::Uuid::now_v7() + )); + let file = OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(&path) + .map_err(|error| { + format!( + "failed to create bootstrap lease {}: {error}", + path.display() + ) + })?; + match try_lock_exclusive(&file) { + Ok(LockAttempt::Acquired) => {} + Ok(LockAttempt::Contended) => { + return Err(format!( + "new bootstrap lease {} was unexpectedly contended", + path.display() + )); + } + Err(error) => { + return Err(format!( + "failed to lock bootstrap lease {}: {error}", + path.display() + )); + } + } + if !active { + let _ = fs::remove_file(recovery_path(runtime, url)); + } + Ok(Self { + file: Some(file), + path, + fresh_epoch: !active, + }) + } + + pub(crate) fn fresh_epoch(&self) -> bool { + self.fresh_epoch + } +} + +impl Drop for EndpointLease { + fn drop(&mut self) { + drop(self.file.take()); + let _ = fs::remove_file(&self.path); + } +} + pub(super) fn read_owner_record(path: &Path) -> Result, String> { let raw = match fs::read(path) { Ok(raw) => raw, @@ -151,7 +285,11 @@ pub(crate) fn lock_endpoint_for( timeout: Duration, ) -> Result { let path = lock_path(runtime, url); - let lock = open_lock(&path)?; + lock_file_for(&path, timeout) +} + +fn lock_file_for(path: &Path, timeout: Duration) -> Result { + let lock = open_lock(path)?; let deadline = Instant::now() + timeout; loop { match try_lock_exclusive(&lock) { @@ -175,6 +313,48 @@ pub(crate) fn lock_endpoint_for( } } +pub(super) fn recovery_path(runtime: &Path, url: &str) -> PathBuf { + runtime.join(format!("sidecar-{}.recovery.json", lock_name(url))) +} + +pub(super) fn read_recovery_epoch( + runtime: &Path, + url: &str, +) -> Result, String> { + let path = recovery_path(runtime, url); + let raw = match fs::read(&path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "failed to read gateway recovery epoch {}: {error}", + path.display() + )); + } + }; + let epoch = serde_json::from_slice::(&raw) + .map_err(|error| format!("invalid gateway recovery epoch {}: {error}", path.display()))?; + if epoch.service != "nemo-relay" + || epoch.bootstrap_protocol != BOOTSTRAP_PROTOCOL_VERSION + || epoch.url != url + || epoch.instance_id.is_empty() + || epoch.restarts > 1 + { + return Err(format!( + "incompatible gateway recovery epoch {}", + path.display() + )); + } + Ok(Some(epoch)) +} + +pub(super) fn write_recovery_epoch(runtime: &Path, epoch: &RecoveryEpoch) -> Result<(), String> { + let path = recovery_path(runtime, &epoch.url); + let bytes = serde_json::to_vec(epoch) + .map_err(|error| format!("failed to encode gateway recovery epoch: {error}"))?; + atomic_write(&path, &bytes) +} + pub(super) fn open_lock(path: &Path) -> Result { OpenOptions::new() .create(true) diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 148a7421b..282646a08 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -178,6 +178,19 @@ fn cli_mcp_help_describes_lifecycle_bound_native_gateway() { assert!(stdout.contains("127.0.0.1:47632")); } +#[test] +fn cli_config_help_keeps_hermes_persistent_state_under_uninstall() { + let output = Command::new(gateway_bin()) + .args(["config", "--help"]) + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("nemo-relay uninstall hermes")); + assert!(!stdout.contains("Hermes-scoped reset also removes")); +} + #[test] fn cli_mcp_starts_gateway_before_initialize_and_exits_cleanly() { let temp = tempfile::tempdir().unwrap(); @@ -996,6 +1009,51 @@ fn cli_mcp_restarts_one_stopped_gateway_then_fails_after_the_second_stop() { ); } +#[test] +fn cli_mcp_staggered_clients_share_one_endpoint_restart_budget() { + let temp = tempfile::tempdir().unwrap(); + let (mut fast_client, _fast_stdin) = start_mcp_client_with_idle_timeout( + temp.path(), + "127.0.0.1:0".parse().unwrap(), + "codex", + "1", + ); + let first = wait_for_owned_sidecar(temp.path(), "codex", None); + let first_pid = first["pid"].as_u64().unwrap(); + let address = first["url"] + .as_str() + .unwrap() + .strip_prefix("http://") + .unwrap() + .parse() + .unwrap(); + // Offset the heartbeat phases while keeping the persistent fingerprint identical. + thread::sleep(Duration::from_millis(150)); + let (mut slow_client, _slow_stdin) = + start_mcp_client_with_idle_timeout(temp.path(), address, "claude", "1"); + + stop_owned_sidecar(&first); + let second = wait_for_owned_sidecar(temp.path(), "codex", Some(first_pid)); + let second_pid = second["pid"].as_u64().unwrap(); + stop_owned_sidecar(&second); + + assert!(!wait_child(&mut fast_client).success()); + assert!(!wait_child(&mut slow_client).success()); + let unexpected = find_runtime_files_matching(temp.path(), "sidecar-", ".owner.json") + .into_iter() + .filter_map(|path| std::fs::read(path).ok()) + .filter_map(|raw| serde_json::from_slice::(&raw).ok()) + .any(|owner| { + owner["pid"] + .as_u64() + .is_some_and(|pid| pid != first_pid && pid != second_pid) + }); + assert!( + !unexpected, + "a staggered MCP client restarted the endpoint a second time" + ); +} + #[test] fn cli_agents_json_emits_supported_agent_shapes() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/agent_process_tests.rs b/crates/cli/tests/coverage/agent_process_tests.rs index fb11beb45..887f02d80 100644 --- a/crates/cli/tests/coverage/agent_process_tests.rs +++ b/crates/cli/tests/coverage/agent_process_tests.rs @@ -55,15 +55,31 @@ fn platform_resolution_supports_explicit_paths_and_windows_pathext() { ); } +#[cfg(windows)] #[test] -fn windows_command_line_quotes_program_and_arguments() { - assert!(is_windows_command_script(Path::new("codex.CMD"))); - assert!(!is_windows_command_script(Path::new("codex.exe"))); - let command = windows_command_line( - Path::new(r"C:\Program Files\Relay & Co\codex.cmd"), - &["exec".into(), "a & b".into(), "%TOKEN%".into()], - ); - assert!(command.contains(r#""C:\Program Files\Relay ^& Co\codex.cmd""#)); - assert!(command.contains(r#""a ^& b""#)); - assert!(command.contains("%%TOKEN%%")); +fn windows_command_shim_preserves_metacharacter_arguments() { + let temp = tempfile::tempdir().unwrap(); + let shim = temp.path().join("agent shim.cmd"); + let marker = temp.path().join("completed.txt"); + std::fs::write( + &shim, + "@echo off\r\n\ + @if not \"%~1\"==\"space & value\" exit /b 11\r\n\ + @if not \"%~2\"==\"caret^value\" exit /b 12\r\n\ + @if not \"%~3\"==\"%%TOKEN%%\" exit /b 13\r\n\ + @echo ok>\"%NEMO_RELAY_ARGV_MARKER%\"\r\n", + ) + .unwrap(); + let argv = vec![ + shim.display().to_string(), + "space & value".into(), + "caret^value".into(), + "%TOKEN%".into(), + ]; + let status = std_command(&argv) + .env("NEMO_RELAY_ARGV_MARKER", &marker) + .status() + .unwrap(); + assert!(status.success()); + assert_eq!(std::fs::read_to_string(marker).unwrap().trim(), "ok"); } diff --git a/crates/cli/tests/coverage/gateway_tests.rs b/crates/cli/tests/coverage/gateway_tests.rs index fa2aaa76f..86aabda9e 100644 --- a/crates/cli/tests/coverage/gateway_tests.rs +++ b/crates/cli/tests/coverage/gateway_tests.rs @@ -24,6 +24,9 @@ fn removes_hop_by_hop_headers() { assert!(!should_forward_request_header(&HeaderName::from_static( "host" ))); + assert!(!should_forward_request_header(&HeaderName::from_static( + crate::config::BOOTSTRAP_CLIENT_TOKEN_HEADER + ))); assert!(should_forward_request_header(&HeaderName::from_static( "authorization" ))); @@ -45,6 +48,30 @@ fn removes_hop_by_hop_headers() { ))); } +#[tokio::test] +async fn prepared_gateway_request_consumes_private_client_proof() { + let request = Request::builder() + .method(Method::POST) + .uri("/v1/responses") + .header( + crate::config::BOOTSTRAP_CLIENT_TOKEN_HEADER, + "hmac-sha256:private-proof", + ) + .body(Body::from(r#"{"model":"gpt-test"}"#)) + .unwrap(); + + let prepared = prepare_gateway_request(&GatewayConfig::default(), request, true) + .await + .unwrap(); + + assert!(prepared.allow_environment_provider_auth); + assert!( + !prepared + .headers + .contains_key(crate::config::BOOTSTRAP_CLIENT_TOKEN_HEADER) + ); +} + #[test] fn selects_provider_routes() { assert_eq!( @@ -435,6 +462,7 @@ fn build_llm_gateway_start_uses_alignment_identifiers_and_metadata() { body_bytes: axum::body::Bytes::new(), request_json: request_json.clone(), streaming: true, + allow_environment_provider_auth: true, }; let start = build_llm_gateway_start(&prepared); @@ -547,8 +575,7 @@ fn preserves_jwt_when_no_replacement_key_available() { #[test] fn injects_openai_bearer_when_inbound_has_no_auth() { - // NMF-86 mitigation: codex now sends no credentials, so the gateway must inject - // `Authorization: Bearer ${OPENAI_API_KEY}` on outbound forwards to api.openai.com. + // Foreground gateway mode retains the convenience of supplying its own provider key. let http = test_http_client(); let inbound = HeaderMap::new(); let env = |k: &str| match k { @@ -557,7 +584,7 @@ fn injects_openai_bearer_when_inbound_has_no_auth() { }; let builder = http.get("http://upstream/v1/responses"); let built = - inject_provider_auth_with_env(builder, ProviderRoute::OpenAiResponses, &inbound, env) + inject_provider_auth_with_env(builder, ProviderRoute::OpenAiResponses, &inbound, true, env) .build() .unwrap(); assert_eq!( @@ -575,10 +602,15 @@ fn injects_anthropic_x_api_key_for_anthropic_routes() { _ => None, }; let builder = http.post("http://upstream/v1/messages"); - let built = - inject_provider_auth_with_env(builder, ProviderRoute::AnthropicMessages, &inbound, env) - .build() - .unwrap(); + let built = inject_provider_auth_with_env( + builder, + ProviderRoute::AnthropicMessages, + &inbound, + true, + env, + ) + .build() + .unwrap(); assert_eq!(built.headers().get("x-api-key").unwrap(), "sk-ant-test"); // Anthropic uses `x-api-key`, not Authorization. The gateway must not duplicate the secret // into a Bearer header — that would defeat the purpose of using the provider's standard @@ -599,7 +631,7 @@ fn skips_injection_when_inbound_already_has_authorization() { let env = |_: &str| Some("sk-test-from-env".into()); let builder = http.post("http://upstream/v1/responses"); let built = - inject_provider_auth_with_env(builder, ProviderRoute::OpenAiResponses, &inbound, env) + inject_provider_auth_with_env(builder, ProviderRoute::OpenAiResponses, &inbound, true, env) .build() .unwrap(); // The builder doesn't carry inbound headers itself (forward_upstream_request adds them in a @@ -615,12 +647,31 @@ fn skips_injection_when_env_var_unset() { let env = |_: &str| None; let builder = http.post("http://upstream/v1/responses"); let built = - inject_provider_auth_with_env(builder, ProviderRoute::OpenAiResponses, &inbound, env) + inject_provider_auth_with_env(builder, ProviderRoute::OpenAiResponses, &inbound, true, env) .build() .unwrap(); assert!(built.headers().get("authorization").is_none()); } +#[test] +fn managed_sidecar_never_injects_forwarded_provider_credentials() { + let http = test_http_client(); + let inbound = HeaderMap::new(); + let env = |_: &str| Some("forwarded-secret".into()); + let builder = http.post("http://upstream/v1/responses"); + let built = inject_provider_auth_with_env( + builder, + ProviderRoute::OpenAiResponses, + &inbound, + false, + env, + ) + .build() + .unwrap(); + + assert!(built.headers().get("authorization").is_none()); +} + // --- ChatGPT backend routing tests --- #[test] diff --git a/crates/cli/tests/coverage/hermes_tests.rs b/crates/cli/tests/coverage/hermes_tests.rs index 44093658f..d9d5a49f9 100644 --- a/crates/cli/tests/coverage/hermes_tests.rs +++ b/crates/cli/tests/coverage/hermes_tests.rs @@ -84,7 +84,7 @@ fn hook_command_round_trips_paths_and_recognizes_owned_legacy_spellings() { Path::new(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"), true, ), - r#""C:\Program Files\NeMo 100%%\bin\nemo-relay.exe" hook-forward hermes --gateway-url http://127.0.0.1:47632"# + r#""C:\Program Files\NeMo 100%%cd:~,%%\bin\nemo-relay.exe" hook-forward hermes --gateway-url http://127.0.0.1:47632"# ); assert_eq!( crate::installer::transparent_hook_forward_command(relay, CodingAgent::Hermes), @@ -316,6 +316,10 @@ fn verification_rejects_relay_handlers_and_approvals_on_unexpected_events() { config["hooks"]["unexpected_event"] = json!([{"command": command, "timeout": 30}]); let error = verify_hook_definitions(&config, &command).unwrap_err(); assert!(error.contains("unexpected Relay hook")); + let mut malformed = persistent_config(None, &relay, &command, &generation, &[]).unwrap(); + malformed["hooks"]["unexpected_event"] = json!({"command": command}); + let error = verify_hook_definitions(&malformed, &command).unwrap_err(); + assert!(error.contains("must be an array")); let mut allowlist = trusted_hooks(None, &command, &relay, UNIX_EPOCH).unwrap(); allowlist["approvals"].as_array_mut().unwrap().push(json!({ @@ -327,6 +331,18 @@ fn verification_rejects_relay_handlers_and_approvals_on_unexpected_events() { std::fs::write(&path, serde_json::to_vec(&allowlist).unwrap()).unwrap(); let error = verify_trust(&path, &command).unwrap_err(); assert!(error.contains("unexpected Relay hook approval")); + + let mut missing_event = trusted_hooks(None, &command, &relay, UNIX_EPOCH).unwrap(); + missing_event["approvals"] + .as_array_mut() + .unwrap() + .push(json!({ + "command": command, + "approved_at": "1970-01-01T00:00:00.000000Z" + })); + std::fs::write(&path, serde_json::to_vec(&missing_event).unwrap()).unwrap(); + let error = verify_trust(&path, &command).unwrap_err(); + assert!(error.contains("missing its event")); } #[test] diff --git a/crates/cli/tests/coverage/install_generation_tests.rs b/crates/cli/tests/coverage/install_generation_tests.rs index 257c883c7..24a543c8e 100644 --- a/crates/cli/tests/coverage/install_generation_tests.rs +++ b/crates/cli/tests/coverage/install_generation_tests.rs @@ -35,6 +35,28 @@ fn retirement_without_invalidation_only_releases_the_lock() { assert_eq!(std::fs::read(&path).unwrap(), before); } +#[test] +fn active_generation_guard_fences_retirement_until_startup_finishes() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let generation = InstallGeneration::capture(path.clone()).unwrap(); + let guard = generation.guard_current().unwrap(); + + let error = match GenerationRetirement::acquire_with_timeout(&path, Duration::from_millis(20)) { + Err(error) => error, + Ok(_) => panic!("retirement must wait for the active startup guard"), + }; + assert!(error.contains("timed out waiting"), "{error}"); + + drop(guard); + assert!( + GenerationRetirement::acquire_with_timeout(&path, Duration::from_secs(1)) + .unwrap() + .is_some() + ); +} + #[test] fn rollback_can_restore_with_the_original_lock_still_held() { let dir = tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs index 6f2d4db14..3878b1a97 100644 --- a/crates/cli/tests/coverage/installer_tests.rs +++ b/crates/cli/tests/coverage/installer_tests.rs @@ -135,7 +135,7 @@ fn generated_hook_dispatch_covers_all_agents() { ); assert_eq!( transparent_hook_forward_command_for_platform(relay, CodingAgent::ClaudeCode, true), - r#""/opt/NeMo Relay's ^& tools/nemo-relay" hook-forward claude"# + r#""/opt/NeMo Relay's & tools/nemo-relay" hook-forward claude"# ); } diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index b60614830..f45982bbb 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -532,18 +532,6 @@ fn version_probe_preserves_known_wrappers_and_validates_opaque_ones() { ); } -#[test] -fn windows_agent_command_line_quotes_paths_and_metacharacters() { - let line = crate::agent_process::windows_command_line( - Path::new(r"C:\Program Files\Codex&Tools\npx.cmd"), - &["codex".into(), "--version".into(), "100%".into()], - ); - - assert!(line.contains(r#""C:\Program Files\Codex^&Tools\npx.cmd""#)); - assert!(line.contains("codex --version")); - assert!(line.contains(r#""100%%""#)); -} - #[cfg(unix)] #[tokio::test] async fn wrapped_agent_version_probe_runs_through_the_wrapper() { @@ -636,6 +624,9 @@ fn prepares_hermes_hook_environment() { std::fs::write(&hooks_path, "model:\n default: test\n").unwrap(); let state = hooks_path.parent().unwrap().join("state.db"); std::fs::write(&state, "state").unwrap(); + let cache = hooks_path.parent().unwrap().join("cache"); + std::fs::create_dir(&cache).unwrap(); + std::fs::write(cache.join("entry"), "cached").unwrap(); let resolved = ResolvedConfig { gateway: GatewayConfig::default(), agents: AgentConfigs { @@ -675,6 +666,15 @@ fn prepares_hermes_hook_environment() { let hooks = std::fs::read_to_string(overlay.join("config.yaml")).unwrap(); assert!(hooks.contains("hook-forward hermes")); assert!(overlay.join("state.db").exists()); + assert_eq!( + std::fs::read_to_string(overlay.join("cache/entry")).unwrap(), + "cached" + ); + std::fs::write(overlay.join("cache/through-overlay"), "live").unwrap(); + assert_eq!( + std::fs::read_to_string(cache.join("through-overlay")).unwrap(), + "live" + ); assert_eq!( std::fs::read_to_string(&hooks_path).unwrap(), "model:\n default: test\n" @@ -686,6 +686,20 @@ fn prepares_hermes_hook_environment() { assert!(!overlay.exists()); } +#[cfg(unix)] +#[test] +fn process_private_directories_are_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let parent = tempfile::tempdir().unwrap(); + let path = private_temp_dir(parent.path(), "relay-private").unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o700 + ); + std::fs::remove_dir(path).unwrap(); +} + #[test] fn concurrent_hermes_runs_use_independent_overlays_without_mutating_user_config() { let temp = tempfile::tempdir().unwrap(); @@ -960,12 +974,8 @@ fn exit_code_preserves_normal_and_shell_wrapped_codes() { assert_eq!(exit_code(status), ExitCode::from(44)); } -// This e2e test relies on argv[0] being a script literally named after a known agent (so -// `CodingAgent::infer` recognises the basename without an explicit `--agent`). On Windows the -// only practical way to invoke a `.cmd` / `.bat` shim is via `cmd.exe /C script.cmd`, which -// makes argv[0] = `cmd.exe` and breaks inference. Gating Unix-only keeps cross-platform CI -// green; real Windows agent-spawn coverage can come back with a `.exe` fake binary once the -// launcher grows Windows support. +// This e2e test uses Unix process exit semantics and a shell script named after the inferred host. +// Windows `.cmd` argv delivery is covered independently by `agent_process_tests`. #[cfg(unix)] #[tokio::test] async fn run_starts_gateway_injects_env_and_returns_agent_exit_code() { diff --git a/crates/cli/tests/coverage/mcp_gateway_tests.rs b/crates/cli/tests/coverage/mcp_gateway_tests.rs index a48fca766..ce5e93066 100644 --- a/crates/cli/tests/coverage/mcp_gateway_tests.rs +++ b/crates/cli/tests/coverage/mcp_gateway_tests.rs @@ -33,7 +33,7 @@ async fn production_heartbeat_recovers_after_one_thirty_second_interval() { "http://gateway".into(), Duration::from_secs(30), |_url| async { Ok(false) }, - move |address| { + move |address, _expected_instance| { let sender = restarted_tx.take(); async move { if let Some(sender) = sender { @@ -83,7 +83,7 @@ async fn concurrent_clients_consume_the_same_replacement_allowance() { } async move { Ok(current) } }, - move |address| { + move |address, _expected_instance| { let current = current_for_restart.clone(); let restart_count = restart_count.clone(); async move { @@ -149,7 +149,10 @@ async fn dropping_gateway_lease_aborts_its_monitor() { }); started_rx.await.unwrap(); - drop(GatewayLease { monitor }); + drop(GatewayLease { + monitor, + _endpoint_lease: None, + }); tokio::time::timeout(Duration::from_secs(1), dropped_rx) .await diff --git a/crates/cli/tests/coverage/mcp_tests.rs b/crates/cli/tests/coverage/mcp_tests.rs index c80f64656..ed04ff994 100644 --- a/crates/cli/tests/coverage/mcp_tests.rs +++ b/crates/cli/tests/coverage/mcp_tests.rs @@ -291,7 +291,7 @@ async fn heartbeat_keeps_a_compatible_gateway_session_alive() { }, { let restart_calls = restart_calls.clone(); - move |address| { + move |address, _expected_instance| { restart_calls.fetch_add(1, Ordering::SeqCst); async move { Ok(crate::sidecar::GatewayEndpoint { @@ -349,7 +349,7 @@ async fn heartbeat_performs_one_restart_and_tracks_the_recovered_gateway() { }, { let restart_calls = restart_calls.clone(); - move |address| { + move |address, _expected_instance| { let restart_calls = restart_calls.clone(); async move { restart_calls.fetch_add(1, Ordering::SeqCst); @@ -404,7 +404,7 @@ async fn heartbeat_ignores_isolated_transient_health_failures() { }, { let restart_calls = restart_calls.clone(); - move |address| { + move |address, _expected_instance| { restart_calls.fetch_add(1, Ordering::SeqCst); async move { Ok(crate::sidecar::GatewayEndpoint { @@ -438,7 +438,7 @@ async fn heartbeat_rediscovery_consumes_the_shared_restart_allowance() { |_url| async { Ok(false) }, { let restart_calls = restart_calls.clone(); - move |address| { + move |address, _expected_instance| { let restart_calls = restart_calls.clone(); async move { let attempt = restart_calls.fetch_add(1, Ordering::SeqCst); @@ -465,7 +465,9 @@ async fn heartbeat_exits_with_the_restart_failure() { "http://dead-gateway".into(), Duration::from_millis(1), |_url| async { Ok(false) }, - |_bind| async { Err(CliError::Launch("coordinated restart failed".into())) }, + |_bind, _expected_instance| async { + Err(CliError::Launch("coordinated restart failed".into())) + }, ) .await .unwrap_err(); @@ -483,7 +485,7 @@ async fn heartbeat_attempts_at_most_one_successful_restart() { |_url| async { Ok(false) }, { let restart_calls = restart_calls.clone(); - move |address| { + move |address, _expected_instance| { let restart_calls = restart_calls.clone(); async move { restart_calls.fetch_add(1, Ordering::SeqCst); @@ -532,7 +534,7 @@ async fn old_mcp_maintenance_loop_exits_when_install_generation_is_replaced() { }, { let restart_calls = restart_calls.clone(); - move |address| { + move |address, _expected_instance| { restart_calls.fetch_add(1, Ordering::SeqCst); async move { Ok(crate::sidecar::GatewayEndpoint { @@ -611,54 +613,6 @@ fn retired_install_generation_remains_retryable_but_not_adoptable() { assert!(error.contains("has been retired"), "{error}"); } -#[test] -fn retired_mcp_postcheck_does_not_stop_a_replacement_gateway() { - assert_retired_mcp_postcheck_preserves_gateway(2002, "replacement-token"); -} - -#[test] -fn retired_mcp_postcheck_does_not_stop_the_exact_reused_gateway() { - assert_retired_mcp_postcheck_preserves_gateway(1001, "retired-token"); -} - -fn assert_retired_mcp_postcheck_preserves_gateway(gateway_pid: u32, gateway_token: &str) { - let dir = tempfile::tempdir().unwrap(); - let generation_path = dir.path().join(GENERATION_FILE_NAME); - write_new_generation(&generation_path).unwrap(); - let generation = InstallGeneration::capture(generation_path.clone()).unwrap(); - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - listener.set_nonblocking(true).unwrap(); - let address = listener.local_addr().unwrap(); - let url = format!("http://{address}"); - let state = dir.path().join("bootstrap-state"); - std::fs::create_dir(&state).unwrap(); - let owner_path = crate::sidecar::sidecar_owner_path(&state, &url); - let pid_path = crate::sidecar::sidecar_pid_path(&state, &url); - crate::sidecar::write_sidecar_owner( - &owner_path, - gateway_pid, - &url, - gateway_token, - Some("same-bootstrap-fingerprint"), - ) - .unwrap(); - std::fs::write(&pid_path, gateway_pid.to_string()).unwrap(); - write_new_generation(&generation_path).unwrap(); - - let error = verify_bootstrap_generation(&generation).unwrap_err(); - - assert!(error.contains("has been retired"), "{error}"); - assert!(owner_path.exists()); - assert_eq!( - std::fs::read_to_string(pid_path).unwrap(), - gateway_pid.to_string() - ); - assert_eq!( - listener.accept().unwrap_err().kind(), - std::io::ErrorKind::WouldBlock - ); -} - #[test] fn default_mcp_gateway_uses_plugin_provider_port() { assert_eq!(default_mcp_bind().to_string(), "127.0.0.1:47632"); diff --git a/crates/cli/tests/coverage/plugin_host_tests.rs b/crates/cli/tests/coverage/plugin_host_tests.rs index 4634c370a..38bca6ccd 100644 --- a/crates/cli/tests/coverage/plugin_host_tests.rs +++ b/crates/cli/tests/coverage/plugin_host_tests.rs @@ -12,9 +12,10 @@ use std::time::{Duration, Instant}; use serde_json::{Value, json}; use tempfile::tempdir; -use toml_edit::{DocumentMut, Item}; +use toml_edit::{DocumentMut, Item, Value as TomlValue}; use super::*; +use crate::config::{BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey}; #[derive(Default)] struct FakeCodexHooksClient { @@ -1085,7 +1086,9 @@ fn codex_install_rolls_back_all_files_when_trust_activation_fails() { #[test] fn repeated_codex_install_does_not_overwrite_original_backup() { let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(&path, "model_provider = \"openai\"\n").unwrap(); install_codex_config(&path, DEFAULT_URL).unwrap(); @@ -1095,6 +1098,42 @@ fn repeated_codex_install_does_not_overwrite_original_backup() { fs::read_to_string(backup_path(&path)).unwrap(), "model_provider = \"openai\"\n" ); + let doc = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + let token = codex_provider_client_token(&doc).unwrap(); + assert!( + BootstrapChallengeKey::load() + .unwrap() + .verify_client_token(token) + ); +} + +#[test] +fn codex_upgrade_adds_client_proof_without_replacing_original_backup() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + let original_backup = fs::read(backup_path(&path)).unwrap(); + + let mut legacy = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + legacy["model_providers"]["nemo-relay-openai"] + .as_table_mut() + .unwrap() + .remove("http_headers"); + fs::write(&path, legacy.to_string()).unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + + assert_eq!(fs::read(backup_path(&path)).unwrap(), original_backup); + assert!(codex_provider_installed(DEFAULT_URL)); } #[test] @@ -1160,6 +1199,8 @@ supports_websockets = false fn codex_provider_installed_requires_active_managed_provider() { let dir = tempdir().unwrap(); let _home = HomeScope::enter(dir.path()); + let xdg = dir.path().join("xdg"); + let _xdg = EnvVarGuard::set_path("XDG_CONFIG_HOME", &xdg); let codex_dir = dir.path().join(".codex"); fs::create_dir_all(&codex_dir).unwrap(); let path = codex_dir.join("config.toml"); @@ -1178,6 +1219,26 @@ supports_websockets = false ) .unwrap(); + assert!(!codex_provider_installed(DEFAULT_URL)); + assert!( + !xdg.join("nemo-relay/bootstrap/fingerprint-hmac.key") + .exists(), + "read-only provider diagnosis must not create bootstrap state" + ); + install_codex_config(&path, DEFAULT_URL).unwrap(); + assert!(codex_provider_installed(DEFAULT_URL)); + let mut tampered = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + tampered["model_providers"]["nemo-relay-openai"]["http_headers"] + .as_inline_table_mut() + .unwrap() + .insert( + BOOTSTRAP_CLIENT_TOKEN_HEADER, + TomlValue::from("hmac-sha256:wrong"), + ); + fs::write(&path, tampered.to_string()).unwrap(); assert!(!codex_provider_installed(DEFAULT_URL)); install_codex_config(&path, DEFAULT_URL).unwrap(); assert!(codex_provider_installed(DEFAULT_URL)); @@ -2507,16 +2568,46 @@ fn codex_hook_command_uses_cmd_quoting_for_windows_paths() { assert_eq!( command, - r#""C:\Program Files\NeMo 100%%\bin\nemo-relay.exe" hook-forward codex --gateway-url http://127.0.0.1:47632"# + r#""C:\Program Files\NeMo 100%%cd:~,%%\bin\nemo-relay.exe" hook-forward codex --gateway-url http://127.0.0.1:47632"# ); assert_eq!( codex_plugin_hook_command_for_platform(&relay, true), - r#""C:\Program Files\NeMo 100%%\bin\nemo-relay.exe" hook-forward codex --gateway-url http://127.0.0.1:47632"# + r#""C:\Program Files\NeMo 100%%cd:~,%%\bin\nemo-relay.exe" hook-forward codex --gateway-url http://127.0.0.1:47632"# ); assert_eq!( shell_quote_arg_for_platform("foo&bar", true), - r#""foo^&bar""# + r#""foo&bar""# ); + assert_eq!(shell_quote_arg_for_platform("", true), r#""""#); +} + +#[cfg(windows)] +#[test] +fn generated_windows_hook_command_executes_exact_arguments() { + let temp = tempfile::tempdir().unwrap(); + let bin = temp.path().join("Relay & %USERPROFILE% Tools"); + std::fs::create_dir(&bin).unwrap(); + let relay = bin.join("nemo-relay.cmd"); + let marker = temp.path().join("hook-ran.txt"); + std::fs::write( + &relay, + "@echo off\r\n\ + @if not \"%~1\"==\"hook-forward\" exit /b 11\r\n\ + @if not \"%~2\"==\"codex\" exit /b 12\r\n\ + @if not \"%~3\"==\"--gateway-url\" exit /b 13\r\n\ + @if not \"%~4\"==\"http://127.0.0.1:47632\" exit /b 14\r\n\ + @echo ok>\"%NEMO_RELAY_HOOK_MARKER%\"\r\n", + ) + .unwrap(); + let command = codex_plugin_hook_command_for_platform(&relay, true); + let status = std::process::Command::new("cmd.exe") + .args(["/d", "/v:off", "/s", "/c", &command]) + .env("NEMO_RELAY_HOOK_MARKER", &marker) + .status() + .unwrap(); + + assert!(status.success(), "{command}"); + assert_eq!(std::fs::read_to_string(marker).unwrap().trim(), "ok"); } #[test] diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index 9f1e784a9..14be1e3f6 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -2695,6 +2695,32 @@ fn force_install_keeps_existing_registration_when_gateway_refresh_fails() { ); } +#[test] +fn uninstall_restores_mcp_generation_when_gateway_refresh_fails() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner { + failing_call: Some("refresh gateway".into()), + ..MockSetupRunner::default() + }; + let options = options(dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + + let error = + uninstall_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("refresh gateway failed")); + previous.verify_current().unwrap(); + assert!(layout.state_path.exists()); + assert!(layout.plugin_root.exists()); + assert_eq!(setup_runner.calls(), vec!["refresh gateway"]); + assert!(runner.commands().is_empty()); +} + #[test] fn force_install_restores_previous_install_after_doctor_failure() { let dir = tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index da47ef8eb..5604b07b1 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -416,6 +416,32 @@ async fn healthz_rejects_a_different_persistent_gateway_fingerprint() { assert!(body.get("bootstrap_fingerprint").is_none()); } +#[tokio::test] +async fn managed_sidecar_requires_private_client_proof_for_forwarded_credentials() { + let key = BootstrapChallengeKey::from_bytes(b"test challenge key"); + let state = AppState::new_with_bootstrap( + test_config(), + Some("expected-fingerprint".into()), + Some(key.clone()), + None, + ); + let mut headers = HeaderMap::new(); + assert!(!state.allows_environment_provider_auth(&headers)); + headers.insert( + crate::config::BOOTSTRAP_CLIENT_TOKEN_HEADER, + HeaderValue::from_static("hmac-sha256:wrong"), + ); + assert!(!state.allows_environment_provider_auth(&headers)); + headers.insert( + crate::config::BOOTSTRAP_CLIENT_TOKEN_HEADER, + HeaderValue::from_str(&key.client_token()).unwrap(), + ); + assert!(state.allows_environment_provider_auth(&headers)); + + let foreground = AppState::new(test_config()); + assert!(foreground.allows_environment_provider_auth(&HeaderMap::new())); +} + #[tokio::test] async fn healthz_only_refreshes_idle_activity_for_an_authenticated_heartbeat() { let challenge_key = BootstrapChallengeKey::from_bytes(b"test challenge key"); diff --git a/crates/cli/tests/coverage/setup_tests.rs b/crates/cli/tests/coverage/setup_tests.rs index 22f2da053..2deec4a2c 100644 --- a/crates/cli/tests/coverage/setup_tests.rs +++ b/crates/cli/tests/coverage/setup_tests.rs @@ -81,10 +81,8 @@ impl Drop for EnvScope { } } -// Stub-binary detection relies on the Unix executable bit. Windows-side agent presence checks -// use a different mechanism (e.g. `.exe` extension matching), so this lookup test is gated to -// Unix to keep cross-platform CI green; covering the Windows code path is left to a separate -// test once the launcher grows real Windows support. +// This stub-binary test specifically verifies Unix executable-bit handling. Platform-neutral +// PATH/PATHEXT resolution and Windows command-shim execution have separate focused coverage. #[cfg(unix)] #[test] fn detect_installed_agents_finds_binaries_on_path() { diff --git a/crates/cli/tests/coverage/sidecar_state_tests.rs b/crates/cli/tests/coverage/sidecar_state_tests.rs index 266867e63..5b7a2f15a 100644 --- a/crates/cli/tests/coverage/sidecar_state_tests.rs +++ b/crates/cli/tests/coverage/sidecar_state_tests.rs @@ -12,6 +12,65 @@ use tempfile::tempdir; use super::*; +#[test] +fn endpoint_leases_reset_recovery_only_after_the_last_client_closes() { + let runtime = tempfile::tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + let first = EndpointLease::acquire(runtime.path(), url).unwrap(); + assert!(first.fresh_epoch()); + let mut epoch = RecoveryEpoch::new(url, "gateway-1"); + epoch.restarts = 1; + write_recovery_epoch(runtime.path(), &epoch).unwrap(); + + let second = EndpointLease::acquire(runtime.path(), url).unwrap(); + assert!(!second.fresh_epoch()); + assert!(read_recovery_epoch(runtime.path(), url).unwrap().is_some()); + drop(first); + + let third = EndpointLease::acquire(runtime.path(), url).unwrap(); + assert!(!third.fresh_epoch()); + drop(second); + drop(third); + + let next_epoch = EndpointLease::acquire(runtime.path(), url).unwrap(); + assert!(next_epoch.fresh_epoch()); + assert!(read_recovery_epoch(runtime.path(), url).unwrap().is_none()); +} + +#[test] +fn recovery_epoch_rejects_identity_and_restart_count_drift() { + let runtime = tempfile::tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + let path = recovery_path(runtime.path(), url); + std::fs::write( + &path, + serde_json::to_vec(&serde_json::json!({ + "service": "foreign", + "bootstrap_protocol": BOOTSTRAP_PROTOCOL_VERSION, + "url": url, + "instance_id": "gateway-1", + "restarts": 0, + "pending": false, + })) + .unwrap(), + ) + .unwrap(); + assert!( + read_recovery_epoch(runtime.path(), url) + .unwrap_err() + .contains("incompatible") + ); + + let mut epoch = RecoveryEpoch::new(url, "gateway-1"); + epoch.restarts = 2; + write_recovery_epoch(runtime.path(), &epoch).unwrap(); + assert!( + read_recovery_epoch(runtime.path(), url) + .unwrap_err() + .contains("incompatible") + ); +} + const SHUTDOWN_TOKEN_ENV: &str = "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN"; struct Environment { @@ -333,6 +392,14 @@ fn authenticated_owned_sidecar_is_shut_down_and_cleaned_up() { "test-nonce", &key.proof("fingerprint", "test-nonce") )); + let client_token = key.client_token(); + assert!(reloaded_key.verify_client_token(&client_token)); + assert!(!reloaded_key.verify_client_token("hmac-sha256:00")); + assert_ne!( + client_token, + key.proof("fingerprint", "test-nonce"), + "health and client proofs must remain domain-separated" + ); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let owner = owner_path(dir.path(), &url); diff --git a/crates/cli/tests/coverage/sidecar_tests.rs b/crates/cli/tests/coverage/sidecar_tests.rs index 66f7b0666..a89d5869b 100644 --- a/crates/cli/tests/coverage/sidecar_tests.rs +++ b/crates/cli/tests/coverage/sidecar_tests.rs @@ -63,6 +63,24 @@ fn gateway_spec_is_the_complete_compatibility_contract() { assert_eq!(first.bind(), bind); } +#[test] +fn endpoint_recovery_epoch_rejects_a_staggered_second_replacement() { + let state = tempfile::tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + write_recovery_epoch(state.path(), &RecoveryEpoch::new(url, "gateway-1")).unwrap(); + + reconcile_gateway_epoch(state.path(), url, false, "gateway-2").unwrap(); + let recovered = read_recovery_epoch(state.path(), url).unwrap().unwrap(); + assert_eq!(recovered.instance_id, "gateway-2"); + assert_eq!(recovered.restarts, 1); + + let error = reconcile_gateway_epoch(state.path(), url, false, "gateway-3").unwrap_err(); + assert!(error.contains("replaced again"), "{error}"); + let unchanged = read_recovery_epoch(state.path(), url).unwrap().unwrap(); + assert_eq!(unchanged.instance_id, "gateway-2"); + assert_eq!(unchanged.restarts, 1); +} + #[test] fn gateway_spec_rejects_non_loopback_bind_before_launch() { let error = GatewaySpec::new(CodingAgent::Codex, "0.0.0.0:47632".parse().unwrap()) diff --git a/docs/nemo-relay-cli/hermes.mdx b/docs/nemo-relay-cli/hermes.mdx index 42f04b63b..c4a792ae8 100644 --- a/docs/nemo-relay-cli/hermes.mdx +++ b/docs/nemo-relay-cli/hermes.mdx @@ -89,10 +89,11 @@ the command performs one coordinated recovery and retries the original payload once. The payload is not reconstructed or replaced. Concurrent Hermes, Claude Code, and Codex MCP clients can share one compatible -gateway. Every open MCP client heartbeats the gateway. If the gateway becomes -unhealthy, a client performs one coordinated restart; a second failure exits -that MCP process with an error. After the last client closes, the gateway exits -after 300 idle seconds by default. Set +gateway. Every open MCP client heartbeats the gateway. An endpoint-scoped +recovery epoch permits one coordinated restart across all overlapping clients, +even when one client misses the replacement between heartbeat ticks. A second +failure exits those MCP clients with an error. After the last client closes, +the gateway exits after 300 idle seconds by default. Set `NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS` before setup and launch to choose another positive timeout. @@ -155,7 +156,9 @@ This mode starts a gateway on an OS-assigned loopback port and exports linking the rest of the active Hermes profile state. The user config is never rewritten, so concurrent transparent and bare Hermes processes cannot observe each other's temporary hook configuration. The overlay is removed after the -wrapped process exits. +wrapped process exits. On Windows, profile directories use non-privileged +directory junctions rather than symbolic links, so Developer Mode or elevation +is not required. Inspect the launch without changing files or starting Hermes: diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index caba4d2f4..66c758595 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -141,8 +141,11 @@ exits after 300 idle seconds by default. If the gateway exits while MCP stdio is open, the client performs one coordinated restart and fails if recovery does not succeed. A foreign process on the configured port is rejected with an actionable conflict instead of being accepted because it returned HTTP 200. -Health and readiness include a per-process instance ID, so every open MCP -client recognizes the same replacement and refuses a second replacement. +Health and readiness include a per-process instance ID. Process-held endpoint +leases and a private recovery epoch make the one-restart allowance global to +all overlapping MCP clients, including a client whose heartbeat misses the +first replacement. A new recovery epoch begins only after the last MCP client +closes. Codex marks the MCP server as required, so the captured turn waits for verified gateway readiness. Claude Code 2.1.121 or newer uses `alwaysLoad`, which blocks @@ -189,6 +192,15 @@ plugin MCP processes. Hermes config stores `${VARIABLE_NAME}` references for the provider credentials, currently defined approved variables, and explicit credential names referenced by user observability configuration. Rerun `nemo-relay install hermes --force` after adding one of those variables. +The long-lived managed sidecar substitutes a forwarded provider credential only +when a request carries provider authorization or Relay's private per-user +client proof. The Codex installer writes a domain-separated HMAC proof into the +managed provider's `http_headers`; Relay validates and removes that header +before middleware, observability, or upstream forwarding. The underlying HMAC +key remains in Relay's owner-only bootstrap state. Claude Code and Hermes send +their normal provider authorization. A foreign loopback caller cannot spend +the sidecar's forwarded credentials. Foreground `nemo-relay --bind` use retains +environment-key injection for explicit local proxy workflows. ## Diagnose diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 952480394..2c5e09a73 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -83,14 +83,17 @@ selected agent. Each plugin MCP entry—and the equivalent Hermes `mcp_servers` entry—starts `nemo-relay mcp`, a lightweight client that starts or reuses a native `nemo-relay --bind 127.0.0.1:47632` sidecar. Relay detaches the sidecar when -host policy permits. A restrictive Windows Job Object -keeps the sidecar scoped to that host job instead of failing bootstrap. The MCP +host policy permits. A restrictive Windows Job Object can limit the sidecar to +the host job; bootstrap fails actionably when nested assignment cannot provide +the required process-tree cleanup guarantee. The MCP process acquires the gateway immediately, before reading protocol frames, and returns its initialization response only after Relay identity, version, and bootstrap-protocol readiness are verified. Concurrent Codex, Claude Code, and Hermes processes share the gateway and heartbeat it while their MCP stdio connections remain open; the gateway exits after the final client's idle -timeout. Codex requires MCP initialization before the captured turn. Claude +timeout. A process-held endpoint epoch permits only one coordinated restart +across all overlapping clients, including staggered heartbeats. Codex requires +MCP initialization before the captured turn. Claude Code marks Relay MCP as `alwaysLoad`, so it also waits for the connection before session startup. Hermes starts MCP discovery asynchronously, so its command hook retains the same-gateway recovery path for an early hook. The MCP client @@ -102,7 +105,12 @@ therefore stable across projects. Codex's generated MCP manifest forwards approved provider, Relay, OpenTelemetry, AWS, proxy, certificate, and config-referenced credential environment names without storing their values; Claude Code supplies its normal MCP process environment. Use transparent -`nemo-relay run` for project-specific configuration. +`nemo-relay run` for project-specific configuration. The managed sidecar +injects a forwarded provider key only for a request with provider authorization +or Relay's private per-user client proof. Codex receives that derived proof in +its managed provider headers; Relay consumes it before middleware, telemetry, +or upstream forwarding. Claude Code and Hermes send their normal provider +authorization, so an unrelated loopback caller cannot spend forwarded keys. Install the persistent integrations with: diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index fa23a72f4..b46be16bc 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -92,6 +92,13 @@ to transparent `nemo-relay run` invocations. The MCP manifest forwards approved provider, Relay, OpenTelemetry, AWS, proxy, certificate, and config-referenced credential variable names without storing values. +The installer also derives a per-user HMAC proof from Relay's owner-only +bootstrap key and places the proof in the managed provider headers. The shared +sidecar requires that proof before it injects a forwarded provider credential, +then removes the proof before middleware, observability, and upstream +forwarding. This prevents an unrelated loopback caller from spending the +sidecar's credentials. + Plugin-owned hook commands pin `http://127.0.0.1:47632` explicitly, so an ambient `NEMO_RELAY_GATEWAY_URL` cannot split hook traffic from the required MCP-managed gateway. @@ -365,8 +372,12 @@ base_url = "http://127.0.0.1:47632" wire_api = "responses" requires_openai_auth = true supports_websockets = false +http_headers = { "x-nemo-relay-client-token" = "" } ``` +The proof is generated by `nemo-relay install codex`; do not copy the +placeholder value from this example. + Run read-only plugin checks: ```bash diff --git a/scripts/test-codex-plugin-e2e.sh b/scripts/test-codex-plugin-e2e.sh index 652e0a807..fb7368450 100755 --- a/scripts/test-codex-plugin-e2e.sh +++ b/scripts/test-codex-plugin-e2e.sh @@ -527,6 +527,7 @@ assert len(response_requests) == 12, ( f"all provider requests: {requests}" ) assert all(item["authorization"] == "Bearer relay-e2e-key-rotated" for item in response_requests), response_requests +assert all(item["relay_client_token"] is None for item in requests), requests provider_response_ids = {item.get("response_id") for item in response_requests} assert None not in provider_response_ids, response_requests assert len(provider_response_ids) == 12, ( diff --git a/scripts/test-support/codex_mock_provider.py b/scripts/test-support/codex_mock_provider.py index 84b1e67e7..cf0b25b44 100644 --- a/scripts/test-support/codex_mock_provider.py +++ b/scripts/test-support/codex_mock_provider.py @@ -244,6 +244,7 @@ def do_GET(self) -> None: # noqa: N802 "method": "GET", "path": self.path, "authorization": self.headers.get("authorization"), + "relay_client_token": self.headers.get("x-nemo-relay-client-token"), } ) if not path.endswith("/models"): @@ -278,6 +279,7 @@ def do_POST(self) -> None: # noqa: N802 "path": self.path, "authorization": self.headers.get("authorization"), "x_api_key": self.headers.get("x-api-key"), + "relay_client_token": self.headers.get("x-nemo-relay-client-token"), "model": request.get("model"), "response_id": (response_stream[-1]["response"]["id"] if response_stream else None), } From 82c05fc574131c68b94fe119cbc71bea381398ef Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sat, 11 Jul 2026 15:15:04 -0400 Subject: [PATCH 010/102] fix(cli): complete shared agent gateway bootstrap Signed-off-by: Will Killian --- .config/nextest.toml | 8 + Cargo.lock | 1 + crates/cli/Cargo.toml | 3 +- crates/cli/README.md | 12 + crates/cli/src/agent_process.rs | 3 + crates/cli/src/agent_process/supervision.rs | 123 ++ .../src/agent_process/supervision/fallback.rs | 31 + .../cli/src/agent_process/supervision/unix.rs | 365 +++++ .../src/agent_process/supervision/windows.rs | 215 +++ crates/cli/src/coding_agent.rs | 76 + crates/cli/src/config.rs | 90 +- crates/cli/src/file_io.rs | 574 +++++++- crates/cli/src/hermes.rs | 87 +- crates/cli/src/hermes/config.rs | 108 +- crates/cli/src/hermes/files.rs | 32 +- crates/cli/src/hermes/trust.rs | 9 +- crates/cli/src/install_generation.rs | 1057 ++++++++++++-- crates/cli/src/installer.rs | 837 +++++++++-- crates/cli/src/launcher.rs | 580 ++++++-- crates/cli/src/main.rs | 27 +- crates/cli/src/mcp.rs | 108 +- crates/cli/src/mcp/gateway.rs | 203 ++- crates/cli/src/mcp_environment.rs | 92 +- crates/cli/src/plugin_host/codex.rs | 552 +++++++- crates/cli/src/plugin_host/mod.rs | 38 +- crates/cli/src/plugin_host/shared.rs | 28 +- crates/cli/src/plugin_install/marketplace.rs | 134 +- crates/cli/src/plugin_install/mod.rs | 572 ++++++-- crates/cli/src/plugin_install/setup.rs | 84 +- crates/cli/src/plugin_install/state.rs | 6 +- crates/cli/src/server.rs | 31 +- crates/cli/src/sidecar.rs | 462 ++++--- crates/cli/src/sidecar/health.rs | 354 ++++- crates/cli/src/sidecar/process.rs | 187 ++- crates/cli/src/sidecar/state.rs | 251 +++- crates/cli/tests/cli_tests.rs | 1218 +++++++++++++++-- .../cli/tests/coverage/agent_process_tests.rs | 254 ++++ .../cli/tests/coverage/coding_agent_tests.rs | 20 + crates/cli/tests/coverage/config_tests.rs | 274 +++- crates/cli/tests/coverage/doctor_tests.rs | 4 +- crates/cli/tests/coverage/file_io_tests.rs | 180 +++ crates/cli/tests/coverage/gateway_tests.rs | 2 + crates/cli/tests/coverage/hermes_tests.rs | 384 +++++- crates/cli/tests/coverage/hook_assertions.rs | 62 + .../coverage/install_generation_tests.rs | 985 ++++++++++++- crates/cli/tests/coverage/installer_tests.rs | 371 ++++- crates/cli/tests/coverage/launcher_tests.rs | 485 ++++++- crates/cli/tests/coverage/main_tests.rs | 19 +- .../cli/tests/coverage/mcp_gateway_tests.rs | 239 +++- crates/cli/tests/coverage/mcp_tests.rs | 155 ++- .../cli/tests/coverage/plugin_host_tests.rs | 821 ++++++++++- .../coverage/plugin_install_setup_tests.rs | 106 ++ .../tests/coverage/plugin_install_tests.rs | 1018 +++++++++++++- .../tests/coverage/plugins_lifecycle_tests.rs | 544 ++++++++ crates/cli/tests/coverage/server_tests.rs | 13 + .../cli/tests/coverage/sidecar_state_tests.rs | 197 ++- crates/cli/tests/coverage/sidecar_tests.rs | 136 +- .../cli/tests/fixtures/windows_hook_relay.rs | 41 + docs/nemo-relay-cli/about.mdx | 7 + docs/nemo-relay-cli/basic-usage.mdx | 23 +- docs/nemo-relay-cli/claude-code.mdx | 38 +- docs/nemo-relay-cli/codex.mdx | 38 +- docs/nemo-relay-cli/hermes.mdx | 32 +- docs/nemo-relay-cli/plugin-installation.mdx | 86 +- integrations/coding-agents/README.md | 52 +- .../coding-agents/claude-code/.mcp.json | 4 +- .../coding-agents/claude-code/README.md | 27 +- .../claude-code/hooks/hooks.json | 28 +- integrations/coding-agents/codex/.mcp.json | 19 +- integrations/coding-agents/codex/README.md | 34 +- .../coding-agents/codex/hooks/hooks.json | 20 +- scripts/test-claude-plugin-e2e.sh | 98 +- scripts/test-codex-plugin-e2e.sh | 117 +- scripts/test-hermes-mcp-e2e.sh | 5 +- 74 files changed, 13843 insertions(+), 1653 deletions(-) create mode 100644 crates/cli/src/agent_process/supervision.rs create mode 100644 crates/cli/src/agent_process/supervision/fallback.rs create mode 100644 crates/cli/src/agent_process/supervision/unix.rs create mode 100644 crates/cli/src/agent_process/supervision/windows.rs create mode 100644 crates/cli/tests/coverage/hook_assertions.rs create mode 100644 crates/cli/tests/fixtures/windows_hook_relay.rs diff --git a/.config/nextest.toml b/.config/nextest.toml index caf149a06..8d7e04f1c 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -3,3 +3,11 @@ [profile.ci] junit = { path = "rust_junit_report.xml" } + +[[profile.ci.overrides]] +filter = 'test(windows_supervision_assigns_before_a_wrapper_can_spawn_a_descendant)' +slow-timeout = { period = "20s", terminate-after = 2 } + +[[profile.ci.overrides]] +filter = 'test(cli_hook_recovery_consumes_the_same_restart_as_an_overlapping_mcp)' +slow-timeout = { period = "20s", terminate-after = 2 } diff --git a/Cargo.lock b/Cargo.lock index 073e67e9a..96e154eb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1571,6 +1571,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2", + "shell-words", "strum", "subtle", "tempfile", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 9a778bd1f..26615e19b 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -64,11 +64,12 @@ uuid = { workspace = true, features = ["serde", "v7"] } libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_SystemInformation", "Win32_System_Threading"] } [dev-dependencies] opentelemetry = { workspace = true, features = ["trace"] } opentelemetry_sdk = { workspace = true, features = ["trace", "testing"] } +shell-words = "1" tempfile = "3" tokio = { version = "1", features = ["test-util"] } tower = { version = "0.5", features = ["util"] } diff --git a/crates/cli/README.md b/crates/cli/README.md index b5436a5d6..05632028f 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -49,6 +49,12 @@ with the installed `nemo-relay` command rather than link against the crate. CLI overrides for deterministic non-interactive use. - **Hook forwarding server**: A local gateway accepts agent hook events and provider-shaped OpenAI or Anthropic requests. +- **Persistent agent integration**: `nemo-relay install` configures Codex, + Claude Code, or Hermes Agent with one generated MCP bootstrap and the host's + canonical lifecycle hooks. +- **Shared gateway lifecycle**: Every persistent integration launches the same + host-neutral `nemo-relay mcp` client. Concurrent clients share one native + gateway on `127.0.0.1:47632`. ## Installation Options @@ -101,6 +107,12 @@ nemo-relay codex nemo-relay claude -- "summarize this repository" ``` +Install persistent integrations for the supported agent CLIs on `PATH`: + +```bash +nemo-relay install all +``` + Use `run --dry-run` to inspect resolved config without spawning the agent: ```bash diff --git a/crates/cli/src/agent_process.rs b/crates/cli/src/agent_process.rs index 87427f332..0c4b35036 100644 --- a/crates/cli/src/agent_process.rs +++ b/crates/cli/src/agent_process.rs @@ -122,6 +122,9 @@ pub(crate) fn tokio_command(argv: &[String]) -> tokio::process::Command { command } +mod supervision; +pub(crate) use supervision::SupervisedChild; + #[cfg(test)] #[path = "../tests/coverage/agent_process_tests.rs"] mod tests; diff --git a/crates/cli/src/agent_process/supervision.rs b/crates/cli/src/agent_process/supervision.rs new file mode 100644 index 000000000..fedd4cd53 --- /dev/null +++ b/crates/cli/src/agent_process/supervision.rs @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Ownership and cleanup for coding-agent wrapper process trees. + +use std::process::ExitStatus; + +#[cfg(not(any(unix, windows)))] +mod fallback; +#[cfg(not(any(unix, windows)))] +use fallback as platform; +#[cfg(unix)] +mod unix; +#[cfg(unix)] +use unix as platform; +#[cfg(windows)] +mod windows; +#[cfg(windows)] +use windows as platform; + +/// A coding-agent process whose complete wrapper tree is owned by Relay. +/// +/// Transparent runs accept wrapper commands such as `npx codex`. Killing only the immediate +/// wrapper can leave the real agent running after its private gateway and hook configuration have +/// been removed, so the platform implementation owns and cleans up the entire tree. +pub(crate) struct SupervisedChild { + child: tokio::process::Child, + tree: platform::ProcessTree, + tree_active: bool, +} + +impl SupervisedChild { + /// Spawns a command into an independently terminable process tree. + pub(crate) async fn spawn(command: &mut tokio::process::Command) -> std::io::Result { + command.kill_on_drop(true); + let (child, tree) = platform::spawn(command).await?; + Ok(Self { + child, + tree, + tree_active: true, + }) + } + + /// Waits for the wrapper and terminates any descendants it left behind. + pub(crate) async fn wait(&mut self) -> std::io::Result { + let status = match platform::wait(&mut self.tree, &mut self.child).await { + Ok(status) => status, + Err(wait_error) => return Err(self.clean_up_wait_error(wait_error).await), + }; + let terminal_result = self.tree.restore_terminal(); + let tree_result = self.tree.terminate(&mut self.child); + if tree_result.is_ok() { + self.tree_active = false; + } + combine_cleanup_results([ + ("restore foreground terminal", terminal_result), + ("terminate remaining coding-agent descendants", tree_result), + ])?; + Ok(status) + } + + #[cfg(all(test, unix))] + pub(super) async fn inject_wait_error_for_test( + &mut self, + error: std::io::Error, + ) -> std::io::Result { + Err(self.clean_up_wait_error(error).await) + } + + async fn clean_up_wait_error(&mut self, error: std::io::Error) -> std::io::Error { + let cleanup_error = self.terminate().await.err(); + let detail = cleanup_error.map_or_else(String::new, |cleanup_error| { + format!("; additionally failed to terminate the coding-agent tree: {cleanup_error}") + }); + std::io::Error::new( + error.kind(), + format!("failed while supervising the coding-agent tree: {error}{detail}"), + ) + } + + /// Terminates and reaps the complete supervised process tree. + pub(crate) async fn terminate(&mut self) -> std::io::Result<()> { + let terminal_result = self.tree.restore_terminal(); + let tree_result = self.tree.terminate(&mut self.child); + if tree_result.is_err() { + // Preserve direct-child cleanup even if the platform tree primitive failed. The + // original tree error remains authoritative because descendants may still be alive. + let _ = self.child.start_kill(); + } + let wait_result = self.child.wait().await.map(|_| ()); + if tree_result.is_ok() { + self.tree_active = false; + } + combine_cleanup_results([ + ("restore foreground terminal", terminal_result), + ("terminate coding-agent process tree", tree_result), + ("reap coding-agent wrapper", wait_result), + ]) + } +} + +impl Drop for SupervisedChild { + fn drop(&mut self) { + let _ = self.tree.restore_terminal(); + if self.tree_active { + let _ = self.tree.terminate(&mut self.child); + } + } +} + +pub(super) fn combine_cleanup_results( + results: [(&str, std::io::Result<()>); N], +) -> std::io::Result<()> { + let errors = results + .into_iter() + .filter_map(|(operation, result)| result.err().map(|error| format!("{operation}: {error}"))) + .collect::>(); + if errors.is_empty() { + Ok(()) + } else { + Err(std::io::Error::other(errors.join("; "))) + } +} diff --git a/crates/cli/src/agent_process/supervision/fallback.rs b/crates/cli/src/agent_process/supervision/fallback.rs new file mode 100644 index 000000000..a0c37709c --- /dev/null +++ b/crates/cli/src/agent_process/supervision/fallback.rs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Direct-child fallback for platforms without process-group or Job Object support. + +use std::process::ExitStatus; + +pub(super) struct ProcessTree; + +pub(super) async fn spawn( + command: &mut tokio::process::Command, +) -> std::io::Result<(tokio::process::Child, ProcessTree)> { + command.spawn().map(|child| (child, ProcessTree)) +} + +pub(super) async fn wait( + _tree: &mut ProcessTree, + child: &mut tokio::process::Child, +) -> std::io::Result { + child.wait().await +} + +impl ProcessTree { + pub(super) fn restore_terminal(&mut self) -> std::io::Result<()> { + Ok(()) + } + + pub(super) fn terminate(&mut self, child: &mut tokio::process::Child) -> std::io::Result<()> { + child.start_kill() + } +} diff --git a/crates/cli/src/agent_process/supervision/unix.rs b/crates/cli/src/agent_process/supervision/unix.rs new file mode 100644 index 000000000..e4101da18 --- /dev/null +++ b/crates/cli/src/agent_process/supervision/unix.rs @@ -0,0 +1,365 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Unix process-group ownership with foreground terminal job control. + +use std::process::ExitStatus; +use std::time::Duration; + +use super::combine_cleanup_results; + +const TERMINATION_GRACE_PERIOD: Duration = Duration::from_secs(5); + +pub(super) struct ProcessTree { + process_group: i32, + terminal: Option, + signals: TerminationSignals, +} + +pub(super) async fn spawn( + command: &mut tokio::process::Command, +) -> std::io::Result<(tokio::process::Child, ProcessTree)> { + // Register before spawning so a signal cannot terminate Relay in the interval between child + // creation and supervision. Tokio retains the OS handlers process-wide, which is appropriate: + // a transparent run exits immediately after this child finishes. + let signals = TerminationSignals::new()?; + let terminal_owner = terminal_foreground_owner()?; + command.process_group(0); + let mut child = command.spawn()?; + let process_group = child.id().ok_or_else(|| { + std::io::Error::other("coding-agent process exited before Relay could supervise it") + })? as i32; + let terminal = match terminal_owner { + Some(owner) => match TerminalForeground::acquire(owner, process_group) { + Ok(terminal) => Some(terminal), + Err(error) => { + terminate_group(process_group, &mut child); + let _ = child.wait().await; + return Err(std::io::Error::new( + error.kind(), + format!( + "failed to give the coding agent foreground terminal ownership: {error}" + ), + )); + } + }, + None => None, + }; + Ok(( + child, + ProcessTree { + process_group, + terminal, + signals, + }, + )) +} + +pub(super) async fn wait( + tree: &mut ProcessTree, + child: &mut tokio::process::Child, +) -> std::io::Result { + let mut termination_deadline = None; + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if tree.resume_foreground_child()? { + // SIGCONT changes the child group out of a stopped state in the kernel. Yield once so + // a following WNOWAIT probe cannot observe the stop transition being cleared. + tokio::task::yield_now().await; + continue; + } + if tree.terminal.is_some() && child_is_stopped(tree.process_group)? { + tree.restore_terminal()?; + tree.stop_supervisor_group()?; + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if let Some(terminal) = tree.terminal.as_mut() { + terminal.resume_after_supervisor()?; + } + } + if termination_deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) { + tree.terminate(child)?; + return child.wait().await; + } + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(25)) => {} + signal = tree.signals.recv() => { + let signal = signal?; + if termination_deadline.is_some() { + tree.terminate(child)?; + return child.wait().await; + } + tree.forward_signal(signal, child)?; + termination_deadline = Some(tokio::time::Instant::now() + TERMINATION_GRACE_PERIOD); + } + } + } +} + +impl ProcessTree { + pub(super) fn restore_terminal(&mut self) -> std::io::Result<()> { + self.terminal + .as_mut() + .map_or(Ok(()), TerminalForeground::restore) + } + + pub(super) fn terminate(&mut self, child: &mut tokio::process::Child) -> std::io::Result<()> { + // SAFETY: The child was spawned with `process_group(0)`, so its PID is the process-group + // ID. A negative PID targets the complete group and does not dereference memory. + if unsafe { libc::kill(-self.process_group, libc::SIGKILL) } == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + // The group can disappear between the wrapper exit and cleanup. If the wrapper moved + // itself out of the group, retain the direct-child guarantee as a safe fallback. + let _ = child.start_kill(); + Ok(()) + } else { + Err(error) + } + } + + fn forward_signal( + &mut self, + signal: i32, + child: &mut tokio::process::Child, + ) -> std::io::Result<()> { + // SAFETY: The child was placed in this independently owned process group before it ran. + if unsafe { libc::kill(-self.process_group, signal) } == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + // If a wrapper moved itself out of the group, retain the stronger no-orphan guarantee. + child.start_kill() + } else { + Err(error) + } + } + + fn stop_supervisor_group(&self) -> std::io::Result<()> { + let Some(terminal) = &self.terminal else { + return Ok(()); + }; + // The shell owns and resumes foreground jobs by process group. Stopping only the Relay PID + // would leave a non-exec wrapper or pipeline sibling running and prevent correct job-state + // reporting by the shell. + // SAFETY: `owner_process_group` was read from getpgrp for this live foreground job. + if unsafe { libc::kill(-terminal.owner_process_group, libc::SIGSTOP) } == -1 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + } + + fn resume_foreground_child(&mut self) -> std::io::Result { + let Some(terminal) = self.terminal.as_mut() else { + return Ok(false); + }; + if terminal.active || terminal_process_group()? != terminal.owner_process_group { + return Ok(false); + } + // A background agent does not have to read the terminal. If a shell later runs `fg` while + // that agent is still running, Relay receives no new SIGCONT to wake a dedicated handler; + // observe the terminal handoff here and complete it for the child group. + terminal.activate()?; + Ok(true) + } +} + +struct TerminalForeground { + owner_process_group: i32, + child_process_group: i32, + active: bool, +} + +impl TerminalForeground { + fn acquire(owner_process_group: i32, child_process_group: i32) -> std::io::Result { + let mut terminal = Self { + owner_process_group, + child_process_group, + active: false, + }; + terminal.activate()?; + Ok(terminal) + } + + fn activate(&mut self) -> std::io::Result<()> { + set_terminal_process_group(self.child_process_group)?; + self.active = true; + // The child can race to read before the parent transfers the terminal and stop with + // SIGTTIN. Continuing the whole group after the transfer closes that standard job-control + // race and also resumes a user-stopped agent after Relay itself is continued. + if let Err(error) = self.continue_child() { + return combine_cleanup_results([ + ("continue foreground coding-agent group", Err(error)), + ("restore foreground terminal", self.restore()), + ]); + } + Ok(()) + } + + fn resume_after_supervisor(&mut self) -> std::io::Result<()> { + // A shell's `fg` first returns Relay's group to the foreground and then continues it. `bg` + // only continues Relay while the shell stays foreground. Preserve that distinction: a + // background agent may run, or stop naturally with SIGTTIN if it attempts terminal input. + if terminal_process_group()? == self.owner_process_group { + self.activate() + } else { + self.continue_child() + } + } + + fn continue_child(&self) -> std::io::Result<()> { + // SAFETY: A negative PID targets the process group created for this child. + if unsafe { libc::kill(-self.child_process_group, libc::SIGCONT) } == -1 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::ESRCH) { + return Err(error); + } + } + Ok(()) + } + + fn restore(&mut self) -> std::io::Result<()> { + if !self.active { + return Ok(()); + } + set_terminal_process_group(self.owner_process_group)?; + self.active = false; + Ok(()) + } +} + +struct TerminationSignals { + hangup: tokio::signal::unix::Signal, + interrupt: tokio::signal::unix::Signal, + quit: tokio::signal::unix::Signal, + terminate: tokio::signal::unix::Signal, +} + +impl TerminationSignals { + fn new() -> std::io::Result { + use tokio::signal::unix::{SignalKind, signal}; + + Ok(Self { + hangup: signal(SignalKind::hangup())?, + interrupt: signal(SignalKind::interrupt())?, + quit: signal(SignalKind::quit())?, + terminate: signal(SignalKind::terminate())?, + }) + } + + async fn recv(&mut self) -> std::io::Result { + let signal = tokio::select! { + signal = self.hangup.recv() => signal.map(|()| libc::SIGHUP), + signal = self.interrupt.recv() => signal.map(|()| libc::SIGINT), + signal = self.quit.recv() => signal.map(|()| libc::SIGQUIT), + signal = self.terminate.recv() => signal.map(|()| libc::SIGTERM), + }; + signal.ok_or_else(|| std::io::Error::other("transparent-run signal receiver closed")) + } +} + +fn terminal_foreground_owner() -> std::io::Result> { + // SAFETY: STDIN_FILENO is a process-owned descriptor. `isatty` does not modify it. + if unsafe { libc::isatty(libc::STDIN_FILENO) } == 0 { + return Ok(None); + } + // SAFETY: `getpgrp` has no preconditions and cannot fail. + let owner_process_group = unsafe { libc::getpgrp() }; + let foreground_process_group = terminal_process_group()?; + if foreground_process_group != owner_process_group { + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "Relay is not the terminal foreground process; bring the transparent run to the foreground or redirect its standard input", + )); + } + Ok(Some(owner_process_group)) +} + +fn terminal_process_group() -> std::io::Result { + // SAFETY: STDIN_FILENO was verified as a terminal during process-tree preparation. + let foreground_process_group = unsafe { libc::tcgetpgrp(libc::STDIN_FILENO) }; + if foreground_process_group == -1 { + Err(std::io::Error::last_os_error()) + } else { + Ok(foreground_process_group) + } +} + +fn set_terminal_process_group(process_group: i32) -> std::io::Result<()> { + let mut blocked = std::mem::MaybeUninit::::uninit(); + let mut previous = std::mem::MaybeUninit::::uninit(); + // SAFETY: Both pointers reference valid sigset_t storage. Blocking SIGTTOU on this thread lets + // the background supervisor reclaim the foreground terminal without stopping itself. + let mask_result = unsafe { + libc::sigemptyset(blocked.as_mut_ptr()); + libc::sigaddset(blocked.as_mut_ptr(), libc::SIGTTOU); + libc::pthread_sigmask(libc::SIG_BLOCK, blocked.as_ptr(), previous.as_mut_ptr()) + }; + if mask_result != 0 { + return Err(std::io::Error::from_raw_os_error(mask_result)); + } + // SAFETY: The descriptor is a controlling terminal checked during acquisition, and the target + // is either the original foreground group or the supervised child group in the same session. + let foreground_result = unsafe { libc::tcsetpgrp(libc::STDIN_FILENO, process_group) }; + let foreground_error = (foreground_result == -1).then(std::io::Error::last_os_error); + // SAFETY: `previous` was initialized by the successful pthread_sigmask call above. + let restore_result = unsafe { + libc::pthread_sigmask( + libc::SIG_SETMASK, + previous.assume_init_ref(), + std::ptr::null_mut(), + ) + }; + combine_cleanup_results([ + ( + "set terminal foreground process group", + foreground_error.map_or(Ok(()), Err), + ), + ( + "restore supervisor signal mask", + if restore_result == 0 { + Ok(()) + } else { + Err(std::io::Error::from_raw_os_error(restore_result)) + }, + ), + ]) +} + +fn child_is_stopped(pid: i32) -> std::io::Result { + let mut info = std::mem::MaybeUninit::::zeroed(); + // SAFETY: `info` points to writable siginfo_t storage. WNOWAIT observes only stop state and + // leaves the eventual exit status for Tokio to reap. + let result = unsafe { + libc::waitid( + libc::P_PID, + pid as _, + info.as_mut_ptr(), + libc::WSTOPPED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if result == -1 { + let error = std::io::Error::last_os_error(); + return if error.raw_os_error() == Some(libc::ECHILD) { + Ok(false) + } else { + Err(error) + }; + } + // SAFETY: waitid initialized `info` on success; a zero si_pid means no state was available. + Ok(unsafe { info.assume_init().si_pid() } == pid) +} + +fn terminate_group(process_group: i32, child: &mut tokio::process::Child) { + // SAFETY: The negative PID targets the child process group and does not dereference memory. + if unsafe { libc::kill(-process_group, libc::SIGKILL) } == -1 { + let _ = child.start_kill(); + } +} diff --git a/crates/cli/src/agent_process/supervision/windows.rs b/crates/cli/src/agent_process/supervision/windows.rs new file mode 100644 index 000000000..759d7a312 --- /dev/null +++ b/crates/cli/src/agent_process/supervision/windows.rs @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Race-free Windows Job Object ownership for coding-agent wrapper trees. + +use std::process::ExitStatus; + +pub(super) struct ProcessTree { + job: AgentJob, +} + +pub(super) async fn spawn( + command: &mut tokio::process::Command, +) -> std::io::Result<(tokio::process::Child, ProcessTree)> { + let job = AgentJob::create()?; + command.creation_flags(windows_sys::Win32::System::Threading::CREATE_SUSPENDED); + let mut child = command.spawn()?; + if let Err(error) = job.assign(&child) { + abort_spawn(&job, &mut child).await; + return Err(error); + } + if let Err(error) = resume_suspended_process(child.id().ok_or_else(|| { + std::io::Error::other("coding-agent process exited before Relay could resume it") + })?) { + abort_spawn(&job, &mut child).await; + return Err(error); + } + Ok((child, ProcessTree { job })) +} + +pub(super) async fn wait( + _tree: &mut ProcessTree, + child: &mut tokio::process::Child, +) -> std::io::Result { + child.wait().await +} + +impl ProcessTree { + pub(super) fn restore_terminal(&mut self) -> std::io::Result<()> { + Ok(()) + } + + pub(super) fn terminate(&mut self, _child: &mut tokio::process::Child) -> std::io::Result<()> { + self.job.terminate() + } +} + +struct AgentJob { + handle: windows_sys::Win32::Foundation::HANDLE, +} + +const WINDOWS_JOB_OBJECT_LIMIT_KILL_ON_CLOSE: u32 = 0x0000_2000; + +// SAFETY: Job Object handles can be used from any thread, and this wrapper uniquely owns it. +unsafe impl Send for AgentJob {} +// SAFETY: Windows Job Object operations are thread-safe for a live kernel handle. +unsafe impl Sync for AgentJob {} + +impl AgentJob { + fn create() -> std::io::Result { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::{ + CreateJobObjectW, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JobObjectExtendedLimitInformation, SetInformationJobObject, + }; + + // SAFETY: Null security attributes and name request a private, unnamed Job Object. + let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if handle.is_null() { + return Err(last_windows_error( + "failed to create coding-agent Job Object", + )); + } + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = WINDOWS_JOB_OBJECT_LIMIT_KILL_ON_CLOSE; + // SAFETY: `handle` is live and `limits` is correctly sized for the requested class. + let configured = unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + std::ptr::from_ref(&limits).cast(), + std::mem::size_of::() as u32, + ) + }; + if configured == 0 { + let error = last_windows_error("failed to configure coding-agent Job Object cleanup"); + // SAFETY: `handle` was created above and has not been transferred. + unsafe { CloseHandle(handle) }; + return Err(error); + } + Ok(Self { handle }) + } + + fn assign(&self, child: &tokio::process::Child) -> std::io::Result<()> { + use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; + + let child_handle = child.raw_handle().ok_or_else(|| { + std::io::Error::other("coding-agent process exited before Job Object assignment") + })?; + // SAFETY: Both handles are live kernel handles owned by this process. + if unsafe { AssignProcessToJobObject(self.handle, child_handle.cast()) } == 0 { + Err(last_windows_error(&format!( + "failed to assign coding-agent process {} to its Job Object; the current Windows Job Object may reject nested assignment", + child.id().unwrap_or_default() + ))) + } else { + Ok(()) + } + } + + fn terminate(&self) -> std::io::Result<()> { + use windows_sys::Win32::System::JobObjects::TerminateJobObject; + + // SAFETY: This handle owns the Job Object assigned to the coding-agent process tree. + if unsafe { TerminateJobObject(self.handle, 1) } == 0 { + Err(last_windows_error( + "failed to terminate coding-agent Job Object", + )) + } else { + Ok(()) + } + } +} + +async fn abort_spawn(job: &AgentJob, child: &mut tokio::process::Child) { + // The process is still suspended when assignment or resume fails, so no descendant can escape + // before the Job Object and direct-child fallbacks terminate it. + let _ = job.terminate(); + let _ = child.start_kill(); + let _ = child.wait().await; +} + +fn resume_suspended_process(process_id: u32) -> std::io::Result<()> { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, + }; + use windows_sys::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}; + + // SAFETY: A system-wide thread snapshot does not borrow caller memory. + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Err(last_windows_error( + "failed to enumerate the suspended coding-agent thread", + )); + } + let mut entry = THREADENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..THREADENTRY32::default() + }; + // SAFETY: `snapshot` is live and `entry` is correctly sized writable storage. + let mut has_entry = unsafe { Thread32First(snapshot, &mut entry) } != 0; + while has_entry { + if entry.th32OwnerProcessID == process_id { + // SAFETY: The snapshot supplied this live thread identifier and only resume access is + // requested. + let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) }; + if thread.is_null() { + let error = + last_windows_error("failed to open the suspended coding-agent primary thread"); + // SAFETY: `snapshot` is uniquely owned and closed exactly once on this path. + unsafe { CloseHandle(snapshot) }; + return Err(error); + } + // CREATE_SUSPENDED starts the primary thread with a suspend count of one. Resume until + // that count reaches zero, while rejecting a zero count that would imply the process + // had already run before Job Object assignment. + // SAFETY: `thread` is live and was opened with THREAD_SUSPEND_RESUME. + let mut previous_count = unsafe { ResumeThread(thread) }; + while previous_count > 1 && previous_count != u32::MAX { + // SAFETY: The same live thread handle remains owned by this function. + previous_count = unsafe { ResumeThread(thread) }; + } + let resume_error = if previous_count == u32::MAX { + Some(last_windows_error( + "failed to resume the Job-owned coding-agent process", + )) + } else if previous_count == 0 { + Some(std::io::Error::other( + "coding-agent primary thread was not suspended before Job Object assignment", + )) + } else { + None + }; + // SAFETY: Both handles are uniquely owned and closed exactly once on this path. + unsafe { + CloseHandle(thread); + CloseHandle(snapshot); + } + return resume_error.map_or(Ok(()), Err); + } + // SAFETY: `snapshot` and `entry` remain valid for the next enumeration result. + has_entry = unsafe { Thread32Next(snapshot, &mut entry) } != 0; + } + // SAFETY: `snapshot` is uniquely owned and closed exactly once on this path. + unsafe { CloseHandle(snapshot) }; + Err(std::io::Error::other(format!( + "could not find the suspended primary thread for coding-agent process {process_id}" + ))) +} + +fn last_windows_error(context: &str) -> std::io::Error { + let source = std::io::Error::last_os_error(); + std::io::Error::new(source.kind(), format!("{context}: {source}")) +} + +impl Drop for AgentJob { + fn drop(&mut self) { + use windows_sys::Win32::Foundation::CloseHandle; + + // SAFETY: `handle` is uniquely owned by this wrapper and closed exactly once. The Job + // Object's kill-on-close limit provides a final descendant-cleanup guarantee. + unsafe { CloseHandle(self.handle) }; + } +} diff --git a/crates/cli/src/coding_agent.rs b/crates/cli/src/coding_agent.rs index 46b6e667a..90fdc473b 100644 --- a/crates/cli/src/coding_agent.rs +++ b/crates/cli/src/coding_agent.rs @@ -20,12 +20,15 @@ pub(crate) enum CodingAgent { #[derive(Debug, Clone, Copy)] struct AgentDescriptor { argument: &'static str, + install_argument: &'static str, label: &'static str, executable: &'static str, hook_path: &'static str, version_product: &'static str, minimum_version: (u64, u64, u64), version_format: VersionFormat, + hook_events: &'static [&'static str], + direct_hook_entries: bool, } #[derive(Debug, Clone, Copy)] @@ -35,34 +38,92 @@ enum VersionFormat { Hermes, } +// Claude Code validates plugin hooks.json against a strict event-name whitelist. Every event in +// this descriptor must therefore exist in the prescribed minimum Claude Code release. +const CLAUDE_CODE_HOOK_EVENTS: &[&str] = &[ + "SessionStart", + "UserPromptSubmit", + "UserPromptExpansion", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PermissionRequest", + "SubagentStart", + "SubagentStop", + "Notification", + "Stop", + "PreCompact", + "PostCompact", + "SessionEnd", +]; + +const CODEX_HOOK_EVENTS: &[&str] = &[ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "SubagentStart", + "SubagentStop", + "Stop", + "PreCompact", + "PostCompact", +]; + +const HERMES_HOOK_EVENTS: &[&str] = &[ + "on_session_start", + "on_session_end", + "on_session_finalize", + "on_session_reset", + "pre_llm_call", + "post_llm_call", + "pre_api_request", + "post_api_request", + // Observer-only failure telemetry closes failed provider attempts. + "api_request_error", + "pre_tool_call", + "post_tool_call", + "subagent_start", + "subagent_stop", +]; + const CLAUDE_CODE: AgentDescriptor = AgentDescriptor { argument: "claude", + install_argument: "claude-code", label: "Claude Code", executable: "claude", hook_path: "/hooks/claude-code", version_product: "Claude Code", minimum_version: (2, 1, 121), version_format: VersionFormat::ClaudeCode, + hook_events: CLAUDE_CODE_HOOK_EVENTS, + direct_hook_entries: false, }; const CODEX: AgentDescriptor = AgentDescriptor { argument: "codex", + install_argument: "codex", label: "Codex", executable: "codex", hook_path: "/hooks/codex", version_product: "codex-cli", minimum_version: (0, 143, 0), version_format: VersionFormat::Codex, + hook_events: CODEX_HOOK_EVENTS, + direct_hook_entries: false, }; const HERMES: AgentDescriptor = AgentDescriptor { argument: "hermes", + install_argument: "hermes", label: "Hermes Agent", executable: "hermes", hook_path: "/hooks/hermes", version_product: "Hermes Agent", minimum_version: (0, 18, 2), version_format: VersionFormat::Hermes, + hook_events: HERMES_HOOK_EVENTS, + direct_hook_entries: true, }; impl CodingAgent { @@ -81,6 +142,11 @@ impl CodingAgent { self.descriptor().argument } + /// Canonical spelling accepted by persistent integration commands. + pub(crate) const fn install_arg(self) -> &'static str { + self.descriptor().install_argument + } + /// Human-readable product name used in diagnostics. pub(crate) const fn label(self) -> &'static str { self.descriptor().label @@ -96,6 +162,16 @@ impl CodingAgent { self.descriptor().hook_path } + /// Complete lifecycle event set installed for this host. + pub(crate) const fn hook_events(self) -> &'static [&'static str] { + self.descriptor().hook_events + } + + /// Hermes stores direct command entries; plugin hosts use nested command-hook groups. + pub(crate) const fn uses_direct_hook_entries(self) -> bool { + self.descriptor().direct_hook_entries + } + pub(crate) fn minimum_version(self) -> Version { let (major, minor, patch) = self.descriptor().minimum_version; Version::new(major, minor, patch) diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index e76486972..3dfb6cc42 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -35,6 +35,8 @@ use crate::plugins::policy::DynamicPluginHostPolicy; pub(crate) const BOOTSTRAP_FINGERPRINT_ENV: &str = "NEMO_RELAY_BOOTSTRAP_FINGERPRINT"; pub(crate) const PLUGIN_IDLE_TIMEOUT_ENV: &str = "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS"; +pub(crate) const RELAY_PLUGIN_ID: &str = "nemo-relay-plugin@nemo-relay-local"; +pub(crate) const RELAY_SOURCE_PLUGIN_ID: &str = "nemo-relay-plugin@nemo-relay"; /// Maximum regular-file size hashed into persistent gateway identity (512 MiB). pub(crate) const MAX_BOOTSTRAP_IDENTITY_FILE_BYTES: u64 = 512 * 1024 * 1024; @@ -101,10 +103,9 @@ pub(crate) enum Command { advertises no MCP tools.", after_help = "Examples:\n \ nemo-relay mcp\n \ - nemo-relay mcp --agent hermes\n \ nemo-relay --bind 127.0.0.1:4041 mcp # explicit standalone/test bind" )] - Mcp(McpCommand), + Mcp, /// Run the interactive setup (writes `.nemo-relay/config.toml`) Config(ConfigCommand), /// Create or edit plugin configuration (writes `plugins.toml`) @@ -128,14 +129,6 @@ pub(crate) enum Command { HookForward(HookForwardCommand), } -/// Host identity for the lifecycle-bound MCP client. -#[derive(Debug, Clone, Args)] -pub(crate) struct McpCommand { - /// Coding-agent host that launched this MCP client. - #[arg(long, value_enum, default_value = "codex")] - pub(crate) agent: CodingAgent, -} - /// Args for `nemo-relay doctor`. `--json` is on this command (rather than as a global flag) /// so it doesn't pollute the help output of subcommands where it has no meaning. #[derive(Debug, Clone, Args)] @@ -498,6 +491,8 @@ impl ServerArgs { pub(crate) const DEFAULT_MAX_HOOK_PAYLOAD_BYTES: usize = 20 * 1024 * 1024; pub(crate) const DEFAULT_MAX_PASSTHROUGH_BODY_BYTES: usize = 100 * 1024 * 1024; +pub(crate) const GATEWAY_URL_ENV: &str = "NEMO_RELAY_GATEWAY_URL"; +pub(crate) const TRANSPARENT_RUN_ENV: &str = "NEMO_RELAY_TRANSPARENT_RUN"; #[derive(Debug, Clone)] pub(crate) struct GatewayConfig { @@ -516,6 +511,25 @@ pub(crate) struct HookForwardCommand { pub(crate) agent: CodingAgent, #[arg(long)] pub(crate) gateway_url: Option, + /// Private install-generation fence used by generated persistent hooks. + #[arg(long, hide = true)] + pub(crate) generation_file: Option, + /// Immutable generation identity paired with `generation_file` by installed hooks. + #[arg(long, hide = true)] + pub(crate) generation_token: Option, + /// Forward to an existing gateway without starting or recovering Relay. + #[arg( + long, + conflicts_with_all = ["generation_file", "generation_token"] + )] + pub(crate) forward_only: bool, + /// Marks the process-private hook source injected by `nemo-relay run`. + #[arg( + long, + hide = true, + conflicts_with_all = ["generation_file", "generation_token", "forward_only"] + )] + pub(crate) transparent_run: bool, #[arg(long)] pub(crate) profile: Option, #[arg(long)] @@ -582,11 +596,9 @@ impl IntegrationHost { } pub(crate) const fn as_arg(self) -> &'static str { - match self { - Self::Codex => "codex", - Self::ClaudeCode => "claude-code", - Self::Hermes => "hermes", - Self::All => "all", + match self.agent() { + Some(agent) => agent.install_arg(), + None => "all", } } @@ -1176,12 +1188,27 @@ const BOOTSTRAP_HMAC_KEY_BYTES: usize = 32; const BOOTSTRAP_HMAC_LOCK_TIMEOUT: Duration = Duration::from_secs(5); const BOOTSTRAP_CHALLENGE_DOMAIN: &[u8] = b"nemo-relay/bootstrap-health/v1\0"; const BOOTSTRAP_CLIENT_TOKEN_DOMAIN: &[u8] = b"nemo-relay/bootstrap-client/v1\0"; +const TRANSPARENT_GATEWAY_DOMAIN: &[u8] = b"nemo-relay/transparent-gateway/v1\0"; const PYTHON_ENVIRONMENT_ATTESTATION_DOMAIN: &[u8] = b"nemo-relay/python-environment-attestation/v1\0"; /// Private proof installed into supported coding-agent provider configuration. pub(crate) const BOOTSTRAP_CLIENT_TOKEN_HEADER: &str = "x-nemo-relay-client-token"; +/// Stable health-proof context shared by a transparent wrapper and plugin-owned MCP client. +pub(crate) fn transparent_gateway_fingerprint(gateway_url: &str) -> String { + let mut context = digest::Context::new(&digest::SHA256); + context.update(TRANSPARENT_GATEWAY_DOMAIN); + context.update(gateway_url.as_bytes()); + let encoded = context + .finish() + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("transparent-sha256:{encoded}") +} + /// Per-user secret used to authenticate a managed bootstrap listener without exposing key bytes. #[derive(Clone)] pub(crate) struct BootstrapChallengeKey(hmac::Key); @@ -1420,6 +1447,13 @@ fn load_or_create_bootstrap_hmac_key_at_with_timeout( parent.display() )) })?; + #[cfg(windows)] + crate::file_io::protect_private_windows_path(parent).map_err(|error| { + CliError::Config(format!( + "failed to protect bootstrap state directory {}: {error}", + parent.display() + )) + })?; #[cfg(unix)] fs::set_permissions(parent, { use std::os::unix::fs::PermissionsExt; @@ -1432,19 +1466,29 @@ fn load_or_create_bootstrap_hmac_key_at_with_timeout( )) })?; - let mut options = OpenOptions::new(); - options.create(true).truncate(false).read(true).write(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options.open(path).map_err(|error| { + #[cfg(windows)] + let mut file = crate::file_io::open_private_windows_file(path).map_err(|error| { CliError::Config(format!( "failed to open bootstrap HMAC key {}: {error}", path.display() )) })?; + #[cfg(not(windows))] + let mut file = { + let mut options = OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path).map_err(|error| { + CliError::Config(format!( + "failed to open bootstrap HMAC key {}: {error}", + path.display() + )) + })? + }; let lock_deadline = Instant::now() + lock_timeout; loop { match try_lock_exclusive(&file) { diff --git a/crates/cli/src/file_io.rs b/crates/cli/src/file_io.rs index 34b04a281..c4b9b6733 100644 --- a/crates/cli/src/file_io.rs +++ b/crates/cli/src/file_io.rs @@ -3,10 +3,10 @@ //! Small, platform-aware filesystem primitives shared by CLI subsystems. -use std::fs::{self, File}; -use std::io; +use std::fs::{self, File, OpenOptions, Permissions}; +use std::io::{self, Write}; use std::path::Path; -#[cfg(windows)] +#[cfg(test)] use std::path::PathBuf; use fs2::FileExt; @@ -28,6 +28,11 @@ pub(crate) fn try_lock_shared(file: &File) -> io::Result { normalize_lock_attempt(FileExt::try_lock_shared(file)) } +/// Release an advisory lock acquired through the helpers above. +pub(crate) fn unlock_file(file: &File) -> io::Result<()> { + FileExt::unlock(file) +} + fn normalize_lock_attempt(result: io::Result<()>) -> io::Result { match result { Ok(()) => Ok(LockAttempt::Acquired), @@ -52,83 +57,532 @@ fn lock_is_contended(error: &io::Error) -> bool { /// Atomically replace `path` with `bytes`, creating its parent directory when needed. pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { + let permissions = fs::metadata(path) + .ok() + .filter(|metadata| metadata.is_file()) + .map(|metadata| metadata.permissions()); + atomic_write_with_permissions(path, bytes, permissions.as_ref()) +} + +/// Atomically replace a secret-bearing file with owner-only access. +/// +/// The restriction is applied to the temporary file at creation, before its name is visible to +/// another process. This avoids both a permissive umask on Unix and inherited broad directory +/// access-control entries on Windows. +pub(crate) fn atomic_write_private(path: &Path, bytes: &[u8]) -> Result<(), String> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + atomic_write_impl( + path, + bytes, + Some(&Permissions::from_mode(0o600)), + AtomicWritePrivacy::Private, + None, + ) + } + #[cfg(windows)] + { + atomic_write_impl(path, bytes, None, AtomicWritePrivacy::Private, None) + } + #[cfg(not(any(unix, windows)))] + { + atomic_write_impl(path, bytes, None, AtomicWritePrivacy::Standard, None) + } +} + +/// Atomically replace `path` while applying `permissions` before the new bytes become visible. +pub(crate) fn atomic_write_with_permissions( + path: &Path, + bytes: &[u8], + permissions: Option<&Permissions>, +) -> Result<(), String> { + atomic_write_impl(path, bytes, permissions, AtomicWritePrivacy::Standard, None) +} + +/// Atomically restore bytes with an exact Windows discretionary access-control descriptor. +#[cfg(windows)] +pub(crate) fn atomic_write_with_windows_dacl( + path: &Path, + bytes: &[u8], + dacl: &[u8], +) -> Result<(), String> { + atomic_write_impl(path, bytes, None, AtomicWritePrivacy::Standard, Some(dacl)) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum AtomicWritePrivacy { + Standard, + Private, +} + +fn atomic_write_impl( + path: &Path, + bytes: &[u8], + permissions: Option<&Permissions>, + privacy: AtomicWritePrivacy, + windows_dacl: Option<&[u8]>, +) -> Result<(), String> { + #[cfg(test)] + if take_injected_atomic_write_failure(path) { + return Err(format!( + "failed to write {}: injected test failure", + path.display() + )); + } if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; } - let tmp = path.with_extension(format!( - "{}tmp", - path.extension() - .and_then(|value| value.to_str()) - .map(|value| format!("{value}.")) - .unwrap_or_default() - )); - fs::write(&tmp, bytes) - .map_err(|error| format!("failed to write {}: {error}", tmp.display()))?; - replace_file(&tmp, path) + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("nemo-relay"); + let tmp = path.with_file_name(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7())); + let result = (|| { + let mut file = open_atomic_temp(&tmp, path, permissions, privacy, windows_dacl) + .map_err(|error| format!("failed to create {}: {error}", tmp.display()))?; + file.write_all(bytes) + .map_err(|error| format!("failed to write {}: {error}", tmp.display()))?; + file.sync_all() + .map_err(|error| format!("failed to sync {}: {error}", tmp.display()))?; + if let Some(permissions) = permissions { + fs::set_permissions(&tmp, permissions.clone()).map_err(|error| { + format!("failed to set permissions on {}: {error}", tmp.display()) + })?; + } + drop(file); + replace_file(&tmp, path) + })(); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result } -#[cfg(not(windows))] -fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { - fs::rename(tmp, path).map_err(|error| format!("failed to replace {}: {error}", path.display())) +#[cfg(unix)] +fn open_atomic_temp( + tmp: &Path, + _target: &Path, + permissions: Option<&Permissions>, + _privacy: AtomicWritePrivacy, + _windows_dacl: Option<&[u8]>, +) -> io::Result { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + if let Some(permissions) = permissions { + options.mode(permissions.mode() & 0o7777); + } + options.open(tmp) } #[cfg(windows)] -fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { - if !path.exists() { - return fs::rename(tmp, path) - .map_err(|error| format!("failed to replace {}: {error}", path.display())); - } - - let backup = replace_backup_path(path); - match fs::remove_file(&backup) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(format!( - "failed to remove stale replacement backup {}: {error}", - backup.display() - )); - } +fn open_atomic_temp( + tmp: &Path, + target: &Path, + _permissions: Option<&Permissions>, + privacy: AtomicWritePrivacy, + windows_dacl: Option<&[u8]>, +) -> io::Result { + if let Some(descriptor) = windows_dacl { + return create_windows_file(tmp, descriptor.as_ptr().cast_mut().cast()); + } + if privacy == AtomicWritePrivacy::Private { + return create_private_windows_file(tmp); } + if target.exists() { + let mut descriptor = read_windows_dacl(target)?; + return create_windows_file(tmp, descriptor.as_mut_ptr().cast()); + } + OpenOptions::new().create_new(true).write(true).open(tmp) +} + +#[cfg(not(any(unix, windows)))] +fn open_atomic_temp( + tmp: &Path, + _target: &Path, + _permissions: Option<&Permissions>, + _privacy: AtomicWritePrivacy, + _windows_dacl: Option<&[u8]>, +) -> io::Result { + OpenOptions::new().create_new(true).write(true).open(tmp) +} + +#[cfg(windows)] +fn create_private_windows_file(path: &Path) -> io::Result { + with_private_windows_descriptor(|descriptor| create_windows_file(path, descriptor)) +} + +/// Opens or creates a secret-bearing file without inheriting a broad Windows DACL. +/// +/// The protected owner/System descriptor is applied by `CreateFileW` when the file is created and +/// repaired before an existing file is returned to the caller. The containing directory must be +/// protected separately before this function is called. +#[cfg(windows)] +pub(crate) fn open_private_windows_file(path: &Path) -> io::Result { + use windows_sys::Win32::Foundation::{GENERIC_READ, GENERIC_WRITE}; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_ALWAYS, + }; - match fs::rename(path, &backup) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return fs::rename(tmp, path) - .map_err(|error| format!("failed to replace {}: {error}", path.display())); + let file = with_private_windows_descriptor(|descriptor| { + open_windows_file( + path, + descriptor, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + OPEN_ALWAYS, + ) + })?; + protect_private_windows_path(path)?; + Ok(file) +} + +/// Applies and verifies the protected owner/System DACL used for secret-bearing Windows paths. +#[cfg(windows)] +pub(crate) fn protect_private_windows_path(path: &Path) -> io::Result<()> { + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, SetFileSecurityW, + }; + + if !windows_path_owned_by_current_user(path)? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{} is not owned by the current user", path.display()), + )); + } + let path_wide = windows_wide(path.as_os_str()); + with_private_windows_descriptor(|descriptor| { + // SAFETY: The path and descriptor remain valid for the duration of the call. + if unsafe { + SetFileSecurityW( + path_wide.as_ptr(), + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + descriptor, + ) + } == 0 + { + return Err(io::Error::last_os_error()); } - Err(error) => { - return Err(format!( - "failed to prepare replacement for {}: {error}", + Ok(()) + })?; + if !windows_path_is_private(path)? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "failed to verify protected owner/System access on {}", path.display() - )); - } + ), + )); + } + Ok(()) +} + +#[cfg(windows)] +fn with_private_windows_descriptor( + operation: impl FnOnce(windows_sys::Win32::Security::PSECURITY_DESCRIPTOR) -> io::Result, +) -> io::Result { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::PSECURITY_DESCRIPTOR; + + let descriptor_sddl = windows_wide("D:P(A;;FA;;;OW)(A;;FA;;;SY)"); + let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: The SDDL string is NUL-terminated and `descriptor` points to writable storage. The + // returned allocation is released with LocalFree below. + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + descriptor_sddl.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let result = operation(descriptor); + // SAFETY: `descriptor` was allocated by ConvertStringSecurityDescriptor... and has not been + // freed or transferred. + unsafe { LocalFree(descriptor.cast()) }; + result +} + +#[cfg(windows)] +fn windows_path_owned_by_current_user(path: &Path) -> io::Result { + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::Security::{ + EqualSid, GetSecurityDescriptorOwner, GetTokenInformation, OWNER_SECURITY_INFORMATION, + PSID, TOKEN_QUERY, TOKEN_USER, TokenUser, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut descriptor = read_windows_security_descriptor(path, OWNER_SECURITY_INFORMATION)?; + let mut owner: PSID = std::ptr::null_mut(); + let mut defaulted = 0; + // SAFETY: The self-relative descriptor buffer is valid and both outputs point to writable + // storage for the duration of the call. + if unsafe { + GetSecurityDescriptorOwner(descriptor.as_mut_ptr().cast(), &mut owner, &mut defaulted) + } == 0 + || owner.is_null() + { + return Err(io::Error::last_os_error()); } - match fs::rename(tmp, path) { - Ok(()) => { - let _ = fs::remove_file(&backup); - Ok(()) + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: GetCurrentProcess returns a valid pseudo-handle and `token` is writable. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let result = (|| { + let mut required = 0; + // SAFETY: This sizing call intentionally supplies a null output buffer. + unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut required) }; + if required == 0 { + return Err(io::Error::last_os_error()); } - Err(error) => match fs::rename(&backup, path) { - Ok(()) => Err(format!("failed to replace {}: {error}", path.display())), - Err(restore_error) => Err(format!( - "failed to replace {}: {error}; additionally failed to restore {}: {restore_error}", - path.display(), - backup.display() - )), - }, + let word = std::mem::size_of::(); + let mut buffer = vec![0_usize; (required as usize).div_ceil(word)]; + // SAFETY: The aligned buffer has at least `required` writable bytes. + if unsafe { + GetTokenInformation( + token, + TokenUser, + buffer.as_mut_ptr().cast(), + required, + &mut required, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: GetTokenInformation initialized a TOKEN_USER at the aligned buffer address. + let user = unsafe { &*buffer.as_ptr().cast::() }; + // SAFETY: Both SID pointers remain valid while their backing buffers are alive. + Ok(unsafe { EqualSid(owner, user.User.Sid) != 0 }) + })(); + // SAFETY: `token` is an owned handle returned by OpenProcessToken. + unsafe { CloseHandle(token) }; + result +} + +#[cfg(windows)] +pub(crate) fn windows_path_is_private(path: &Path) -> io::Result { + use windows_sys::Win32::Security::{DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION}; + + if !windows_path_owned_by_current_user(path)? { + return Ok(false); } + let mut actual = read_windows_security_descriptor( + path, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + )?; + let actual = windows_dacl_sddl(actual.as_mut_ptr().cast())?; + with_private_windows_descriptor(|expected| Ok(actual == windows_dacl_sddl(expected)?)) } #[cfg(windows)] -fn replace_backup_path(path: &Path) -> PathBuf { - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("config"); - path.with_file_name(format!(".{file_name}.nemo-relay-replace.tmp")) +fn windows_dacl_sddl( + descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, +) -> io::Result { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; + + let mut rendered = std::ptr::null_mut(); + let mut rendered_len = 0; + // SAFETY: The descriptor is valid and both output pointers reference writable storage. + if unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor, + SDDL_REVISION_1, + DACL_SECURITY_INFORMATION, + &mut rendered, + &mut rendered_len, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: The API returned `rendered_len` initialized UTF-16 code units. + let value = String::from_utf16_lossy(unsafe { + std::slice::from_raw_parts(rendered, rendered_len as usize) + }) + .trim_end_matches('\0') + .to_string(); + // SAFETY: `rendered` was allocated by ConvertSecurityDescriptor... above. + unsafe { LocalFree(rendered.cast()) }; + Ok(value) +} + +#[cfg(windows)] +pub(crate) fn read_windows_dacl(path: &Path) -> io::Result> { + use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; + + read_windows_security_descriptor(path, DACL_SECURITY_INFORMATION) +} + +#[cfg(windows)] +fn read_windows_security_descriptor( + path: &Path, + information: windows_sys::Win32::Security::OBJECT_SECURITY_INFORMATION, +) -> io::Result> { + use windows_sys::Win32::Security::GetFileSecurityW; + + let path = windows_wide(path.as_os_str()); + let mut required = 0; + // SAFETY: This sizing call intentionally supplies a null output buffer and valid length + // pointer, as required by GetFileSecurityW. + unsafe { + GetFileSecurityW( + path.as_ptr(), + information, + std::ptr::null_mut(), + 0, + &mut required, + ) + }; + if required == 0 { + return Err(io::Error::last_os_error()); + } + let mut descriptor = vec![0_u8; required as usize]; + // SAFETY: The path is NUL-terminated and the allocated output buffer is `required` bytes. + if unsafe { + GetFileSecurityW( + path.as_ptr(), + information, + descriptor.as_mut_ptr().cast(), + required, + &mut required, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(descriptor) +} + +#[cfg(windows)] +fn create_windows_file( + path: &Path, + descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, +) -> io::Result { + use windows_sys::Win32::Foundation::GENERIC_WRITE; + use windows_sys::Win32::Storage::FileSystem::CREATE_NEW; + + open_windows_file(path, descriptor, GENERIC_WRITE, 0, CREATE_NEW) +} + +#[cfg(windows)] +fn open_windows_file( + path: &Path, + descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, + desired_access: u32, + share_mode: u32, + creation_disposition: u32, +) -> io::Result { + use std::os::windows::io::FromRawHandle; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; + use windows_sys::Win32::Storage::FileSystem::{CreateFileW, FILE_ATTRIBUTE_NORMAL}; + + let path = windows_wide(path.as_os_str()); + let attributes = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: descriptor, + bInheritHandle: 0, + }; + // SAFETY: The path and security descriptor remain valid for the call, and a successful owned + // handle is transferred to File. + let handle = unsafe { + CreateFileW( + path.as_ptr(), + desired_access, + share_mode, + &attributes, + creation_disposition, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: `handle` is a newly created, valid, owned file handle. + Ok(unsafe { File::from_raw_handle(handle) }) +} + +#[cfg(windows)] +fn windows_wide(value: impl AsRef) -> Vec { + use std::os::windows::ffi::OsStrExt; + + value.as_ref().encode_wide().chain(Some(0)).collect() +} + +#[cfg(test)] +pub(crate) fn fail_next_atomic_write(path: &Path) { + injected_atomic_write_failures() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(path.to_path_buf()); +} + +#[cfg(test)] +fn take_injected_atomic_write_failure(path: &Path) -> bool { + injected_atomic_write_failures() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .remove(path) +} + +#[cfg(test)] +fn injected_atomic_write_failures() -> &'static std::sync::Mutex> +{ + use std::collections::HashSet; + use std::sync::{Mutex, OnceLock}; + + static FAILURES: OnceLock>> = OnceLock::new(); + FAILURES.get_or_init(Default::default) +} + +#[cfg(not(windows))] +fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { + fs::rename(tmp, path).map_err(|error| format!("failed to replace {}: {error}", path.display())) +} + +#[cfg(windows)] +fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + + let tmp = windows_wide(tmp.as_os_str()); + let path_wide = windows_wide(path.as_os_str()); + // SAFETY: Both paths are NUL-terminated and remain valid for the call. The files share a + // directory, so Windows performs one replace-existing rename without a missing-target window. + if unsafe { + MoveFileExW( + tmp.as_ptr(), + path_wide.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } == 0 + { + return Err(format!( + "failed to replace {}: {}", + path.display(), + io::Error::last_os_error() + )); + } + Ok(()) } #[cfg(test)] diff --git a/crates/cli/src/hermes.rs b/crates/cli/src/hermes.rs index 712d0eade..0167f9d14 100644 --- a/crates/cli/src/hermes.rs +++ b/crates/cli/src/hermes.rs @@ -26,12 +26,14 @@ use self::files::{ acquire_install_lock, read_optional_utf8, remove_optional_file, replace_optional_file, }; use self::trust::{json_bytes, parse_json_object, trusted_hooks, verify_trust}; +use crate::config::CodingAgent; use crate::error::CliError; use crate::file_io::atomic_write; #[cfg(test)] use crate::install_generation::GENERATION_FILE_NAME; -use crate::install_generation::{GENERATION_FILE_ENV, GenerationRetirement}; -use crate::installer::HERMES_HOOK_EVENTS; +use crate::install_generation::{ + GENERATION_FILE_ENV, GENERATION_TOKEN_ENV, GenerationRetirement, InstallGeneration, +}; use crate::sidecar::DEFAULT_BIND; pub(crate) use config::{persistent_hook_command, transparent_config}; @@ -60,11 +62,12 @@ pub(crate) fn install_persistent(config: &Path, relay: &Path) -> Result>(); let mut retirement = retire_generation_before_gateway_stop(&paths)?; - let result = install_persistent_with( + let result = install_persistent_with_generation( paths, &relay, &environment, plugin_config.as_ref(), + retirement.as_ref(), SystemTime::now(), atomic_write, ); @@ -124,7 +127,12 @@ fn finish_generation_mutation( operation: &str, ) -> Result { match result { - Ok(value) => Ok(value), + Ok(value) => { + if let Some(retirement) = retirement { + retirement.commit_replacement(); + } + Ok(value) + } Err(error) => { let Some(retirement) = retirement else { return Err(error); @@ -197,7 +205,8 @@ pub(crate) fn diagnose_persistent(config_path: &Path) -> Result relay.display() )); } - let command = persistent_hook_command(&relay); + let generation = InstallGeneration::capture(paths.generation.clone())?; + let command = persistent_hook_command(&relay, &paths.generation, generation.token())?; verify_hook_definitions(&config, &command)?; verify_trust(&paths.allowlist, &command)?; @@ -217,10 +226,12 @@ pub(crate) fn diagnose_persistent(config_path: &Path) -> Result if Path::new(configured_generation) != paths.generation { return Err("Hermes Relay MCP generation fence points at the wrong file".into()); } - let generation = fs::read_to_string(&paths.generation) - .map_err(|error| format!("failed to read {}: {error}", paths.generation.display()))?; - if generation.trim().is_empty() || generation.trim().starts_with("retired:") { - return Err("Hermes Relay MCP generation fence is not active".into()); + let configured_token = mcp_env + .get(GENERATION_TOKEN_ENV) + .and_then(Value::as_str) + .ok_or_else(|| "Hermes Relay MCP expected generation identity is missing".to_string())?; + if configured_token != generation.token() { + return Err("Hermes Relay MCP expected generation identity is stale".into()); } let plugin_config = crate::config::user_plugin_runtime_config().map_err(|e| e.to_string())?; @@ -239,7 +250,7 @@ pub(crate) fn diagnose_persistent(config_path: &Path) -> Result } Ok(format!( "MCP lifecycle and {} hooks trusted at {}", - HERMES_HOOK_EVENTS.len(), + CodingAgent::Hermes.hook_events().len(), paths.config.display() )) } @@ -264,10 +275,13 @@ pub(crate) fn configured_relay_executable(config_path: &Path) -> Result Result { let server = config - .pointer("/mcp_servers/nemo-relay") - .ok_or_else(|| "Hermes MCP server `nemo-relay` is missing".to_string())?; + .get("mcp_servers") + .and_then(|servers| servers.get(MCP_SERVER_NAME)) + .ok_or_else(|| format!("Hermes MCP server `{MCP_SERVER_NAME}` is missing"))?; if !is_managed_mcp_server(server) { - return Err("Hermes MCP server `nemo-relay` is not a managed Relay MCP client".into()); + return Err(format!( + "Hermes MCP server `{MCP_SERVER_NAME}` is not a managed Relay MCP client" + )); } Ok(PathBuf::from( server @@ -277,12 +291,28 @@ fn relay_executable_from_config(config: &Value) -> Result { )) } +#[cfg(test)] fn install_persistent_with( paths: PersistentPaths, relay: &Path, environment: &[String], plugin_config: Option<&Value>, now: SystemTime, + write: W, +) -> Result, CliError> +where + W: FnMut(&Path, &[u8]) -> Result<(), String>, +{ + install_persistent_with_generation(paths, relay, environment, plugin_config, None, now, write) +} + +fn install_persistent_with_generation( + paths: PersistentPaths, + relay: &Path, + environment: &[String], + plugin_config: Option<&Value>, + generation_transaction: Option<&GenerationRetirement>, + now: SystemTime, mut write: W, ) -> Result, CliError> where @@ -295,14 +325,16 @@ where .collect::, _>>()?; let existing_config = read_optional_utf8(&paths.config)?; let existing_allowlist = read_optional_utf8(&paths.allowlist)?; - let command = persistent_hook_command(relay); let environment = forwarded_environment_names(environment, plugin_config); let token = uuid::Uuid::now_v7().to_string(); + let command = + persistent_hook_command(relay, &paths.generation, &token).map_err(CliError::Install)?; let config = persistent_config( existing_config.as_deref(), relay, &command, &paths.generation, + &token, &environment, )?; let allowlist = trusted_hooks(existing_allowlist.as_deref(), &command, relay, now)?; @@ -316,7 +348,14 @@ where write(&paths.generation, &generation)?; write(&paths.allowlist, &allowlist)?; write(&paths.config, &config)?; - verify_install(&paths, relay, &command, &environment, &token) + verify_install( + &paths, + relay, + &command, + &environment, + &token, + generation_transaction, + ) })(); if let Err(error) = result { return rollback_error("install", error, &snapshots, &mut write); @@ -427,11 +466,12 @@ fn verify_install( command: &str, environment: &[String], token: &str, + generation_transaction: Option<&GenerationRetirement>, ) -> Result<(), String> { let raw = fs::read_to_string(&paths.config) .map_err(|error| format!("failed to verify {}: {error}", paths.config.display()))?; let config = parse_yaml_object(Some(&raw), "Hermes config").map_err(|e| e.to_string())?; - let expected = expected_mcp_server(relay, &paths.generation, environment); + let expected = expected_mcp_server(relay, &paths.generation, token, environment); if config.pointer("/mcp_servers/nemo-relay") != Some(&expected) { return Err("Hermes MCP server did not persist exactly".into()); } @@ -453,16 +493,20 @@ fn verify_install( return Err("stale Hermes Relay hook approvals remain".into()); } - let actual = fs::read_to_string(&paths.generation) - .map_err(|error| format!("failed to verify {}: {error}", paths.generation.display()))?; - if actual.trim() != token { + let actual_token = match generation_transaction { + Some(transaction) => transaction.active_visible_token()?, + None => InstallGeneration::capture(paths.generation.clone())? + .token() + .to_owned(), + }; + if actual_token != token { return Err("Hermes MCP generation did not persist exactly".into()); } Ok(()) } fn verify_hook_definitions(config: &Value, command: &str) -> Result<(), String> { - for event in HERMES_HOOK_EVENTS { + for event in CodingAgent::Hermes.hook_events() { let groups = config .pointer(&format!("/hooks/{event}")) .and_then(Value::as_array) @@ -501,7 +545,8 @@ fn verify_hook_definitions(config: &Value, command: &str) -> Result<(), String> } } managed.sort_unstable(); - let mut expected = HERMES_HOOK_EVENTS + let mut expected = CodingAgent::Hermes + .hook_events() .iter() .map(|event| (*event, command)) .collect::>(); diff --git a/crates/cli/src/hermes/config.rs b/crates/cli/src/hermes/config.rs index 8279c7389..8228b913a 100644 --- a/crates/cli/src/hermes/config.rs +++ b/crates/cli/src/hermes/config.rs @@ -3,18 +3,14 @@ //! Pure Hermes YAML generation, migration, and ownership recognition. -use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use serde_json::{Map, Value, json}; use crate::error::CliError; -use crate::install_generation::GENERATION_FILE_ENV; -use crate::installer::{hermes_hooks, merge_hooks}; -use crate::sidecar::DEFAULT_BIND; +use crate::installer::{generated_hooks, merge_hooks}; -pub(super) const MCP_SERVER_NAME: &str = "nemo-relay"; -const ALWAYS_FORWARDED_CREDENTIALS: &[&str] = &["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]; +pub(super) use crate::mcp::SERVER_NAME as MCP_SERVER_NAME; pub(super) fn user_config_path_with_override( default_home: &Path, @@ -29,26 +25,52 @@ pub(super) fn user_config_path_with_override( /// Rewrites the Relay-owned portion of a Hermes config for a transparent run. The fixed MCP /// client is removed because the wrapper already owns a dynamic gateway. -pub(crate) fn transparent_config(existing: &str, relay: &Path) -> Result { +pub(crate) fn transparent_config( + existing: &str, + relay: &Path, + gateway_url: &str, +) -> Result { let mut root = parse_yaml_object(Some(existing), "Hermes config")?; strip_managed_hooks(&mut root)?; remove_managed_mcp(&mut root)?; let command = crate::installer::transparent_hook_forward_command( relay, crate::config::CodingAgent::Hermes, - ); - let root = merge_hooks(root, hermes_hooks(&command))?; + gateway_url, + ) + .map_err(CliError::Install)?; + let root = merge_hooks( + root, + generated_hooks(crate::config::CodingAgent::Hermes, &command), + )?; serde_yaml::to_string(&root).map_err(|error| CliError::Install(error.to_string())) } -pub(crate) fn persistent_hook_command(relay: &Path) -> String { - persistent_hook_command_for_platform(relay, cfg!(windows)) +pub(crate) fn persistent_hook_command( + relay: &Path, + generation: &Path, + generation_token: &str, +) -> Result { + crate::installer::persistent_hook_forward_command( + relay, + crate::config::CodingAgent::Hermes, + generation, + generation_token, + ) } -pub(super) fn persistent_hook_command_for_platform(relay: &Path, windows: bool) -> String { +#[cfg(test)] +pub(super) fn persistent_hook_command_for_platform( + relay: &Path, + generation: &Path, + generation_token: &str, + windows: bool, +) -> String { crate::installer::persistent_hook_forward_command_for_platform( relay, crate::config::CodingAgent::Hermes, + generation, + generation_token, windows, ) } @@ -58,6 +80,7 @@ pub(super) fn persistent_config( relay: &Path, command: &str, generation: &Path, + generation_token: &str, environment: &[String], ) -> Result { let mut root = parse_yaml_object(existing, "Hermes config")?; @@ -69,11 +92,14 @@ pub(super) fn persistent_config( ))); } strip_managed_hooks(&mut root)?; - root = merge_hooks(root, hermes_hooks(command))?; + root = merge_hooks( + root, + generated_hooks(crate::config::CodingAgent::Hermes, command), + )?; let servers = object_field_mut(&mut root, "mcp_servers", "mcp_servers")?; servers.insert( MCP_SERVER_NAME.into(), - expected_mcp_server(relay, generation, environment), + expected_mcp_server(relay, generation, generation_token, environment), ); Ok(root) } @@ -81,41 +107,25 @@ pub(super) fn persistent_config( pub(super) fn expected_mcp_server( relay: &Path, generation: &Path, + generation_token: &str, environment: &[String], ) -> Value { - let mut forwarded = Map::from_iter([ - ("NEMO_RELAY_GATEWAY_BIND".into(), json!(DEFAULT_BIND)), - ( - GENERATION_FILE_ENV.into(), - json!(generation.display().to_string()), - ), - ]); + let mut server = crate::mcp::persistent_server(relay, generation, generation_token); + let forwarded = server + .get_mut("env") + .and_then(Value::as_object_mut) + .expect("persistent MCP server environment is an object"); for name in environment { forwarded.insert(name.clone(), json!(format!("${{{name}}}"))); } - json!({ - "command": relay.display().to_string(), - "args": ["mcp", "--agent", "hermes"], - "env": forwarded, - }) + server } pub(super) fn forwarded_environment_names( environment: &[String], plugin_config: Option<&Value>, ) -> Vec { - let present = environment.iter().cloned().collect::>(); - let referenced = crate::mcp_environment::config_referenced_names(plugin_config) - .into_iter() - .collect::>(); crate::mcp_environment::forwarded_names(environment.iter().cloned(), plugin_config) - .into_iter() - .filter(|name| { - present.contains(name) - || referenced.contains(name) - || ALWAYS_FORWARDED_CREDENTIALS.contains(&name.as_str()) - }) - .collect() } pub(super) fn strip_managed_hooks(root: &mut Value) -> Result<(), CliError> { @@ -176,14 +186,28 @@ pub(super) fn remove_managed_mcp(root: &mut Value) -> Result<(), CliError> { } pub(super) fn is_managed_mcp_server(server: &Value) -> bool { - server - .get("command") - .and_then(Value::as_str) - .is_some_and(is_relay_executable) - && server.get("args") == Some(&json!(["mcp", "--agent", "hermes"])) + crate::mcp::is_managed_server(server, is_relay_executable) } pub(crate) fn is_managed_hook_command(command: &str) -> bool { + if let Some(arguments) = crate::installer::decode_windows_hook_command(command) { + if arguments.len() < 3 + || !is_relay_executable(&arguments[0]) + || arguments[1] != "hook-forward" + || arguments[2] != "hermes" + { + return false; + } + let options = &arguments[3..]; + return options.is_empty() + || (options.len() == 3 + && options[0] == "--gateway-url" + && options[2] == "--transparent-run") + || (options.len() == 6 + && options[0] == "--gateway-url" + && options[2] == "--generation-file" + && options[4] == "--generation-token"); + } [" hook-forward hermes", " plugin-shim hook hermes"] .into_iter() .any(|separator| { diff --git a/crates/cli/src/hermes/files.rs b/crates/cli/src/hermes/files.rs index ffd631e92..6f0dee0cc 100644 --- a/crates/cli/src/hermes/files.rs +++ b/crates/cli/src/hermes/files.rs @@ -153,18 +153,31 @@ pub(super) fn remove_optional_file(path: &Path) -> Result<(), String> { pub(super) struct FileSnapshot { path: PathBuf, bytes: Option>, + permissions: Option, } impl FileSnapshot { pub(super) fn capture(path: &Path) -> Result { match fs::read(path) { - Ok(bytes) => Ok(Self { - path: path.to_path_buf(), - bytes: Some(bytes), - }), + Ok(bytes) => { + let permissions = fs::metadata(path) + .map(|metadata| metadata.permissions()) + .map_err(|error| { + CliError::Install(format!( + "failed to snapshot permissions on {}: {error}", + path.display() + )) + })?; + Ok(Self { + path: path.to_path_buf(), + bytes: Some(bytes), + permissions: Some(permissions), + }) + } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self { path: path.to_path_buf(), bytes: None, + permissions: None, }), Err(error) => Err(CliError::Install(format!( "failed to snapshot {}: {error}", @@ -178,7 +191,16 @@ impl FileSnapshot { W: FnMut(&Path, &[u8]) -> Result<(), String>, { if let Some(bytes) = self.bytes.as_deref() { - return write(&self.path, bytes); + write(&self.path, bytes)?; + if let Some(permissions) = self.permissions.as_ref() { + fs::set_permissions(&self.path, permissions.clone()).map_err(|error| { + format!( + "failed to restore permissions on {}: {error}", + self.path.display() + ) + })?; + } + return Ok(()); } remove_optional_file(&self.path) } diff --git a/crates/cli/src/hermes/trust.rs b/crates/cli/src/hermes/trust.rs index 179739ece..ed704c806 100644 --- a/crates/cli/src/hermes/trust.rs +++ b/crates/cli/src/hermes/trust.rs @@ -11,8 +11,8 @@ use chrono::{DateTime, SecondsFormat, Utc}; use serde_json::{Value, json}; use super::config::is_managed_hook_command; +use crate::config::CodingAgent; use crate::error::CliError; -use crate::installer::HERMES_HOOK_EVENTS; pub(super) fn trusted_hooks( existing: Option<&str>, @@ -41,7 +41,7 @@ pub(super) fn trusted_hooks( .and_then(|metadata| metadata.modified()) .ok() .map(timestamp); - approvals.extend(HERMES_HOOK_EVENTS.iter().map(|event| { + approvals.extend(CodingAgent::Hermes.hook_events().iter().map(|event| { json!({ "event": event, "command": command, @@ -65,7 +65,7 @@ pub(super) fn verify_trust(allowlist_path: &Path, command: &str) -> Result<(), S .get("approvals") .and_then(Value::as_array) .ok_or_else(|| "Hermes shell-hook approvals are missing".to_string())?; - for event in HERMES_HOOK_EVENTS { + for event in CodingAgent::Hermes.hook_events() { let matching = approvals .iter() .filter(|entry| { @@ -94,7 +94,8 @@ pub(super) fn verify_trust(allowlist_path: &Path, command: &str) -> Result<(), S managed.push((event, candidate)); } managed.sort_unstable(); - let mut expected = HERMES_HOOK_EVENTS + let mut expected = CodingAgent::Hermes + .hook_events() .iter() .map(|event| (*event, command)) .collect::>(); diff --git a/crates/cli/src/install_generation.rs b/crates/cli/src/install_generation.rs index b8846283e..c44187cc4 100644 --- a/crates/cli/src/install_generation.rs +++ b/crates/cli/src/install_generation.rs @@ -7,22 +7,35 @@ use std::env; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; -use crate::file_io::{LockAttempt, try_lock_exclusive, try_lock_shared}; +use base64::Engine; + +use crate::file_io::{LockAttempt, atomic_write, try_lock_exclusive, try_lock_shared, unlock_file}; pub(crate) const GENERATION_FILE_ENV: &str = "NEMO_RELAY_MCP_GENERATION_FILE"; +pub(crate) const GENERATION_TOKEN_ENV: &str = "NEMO_RELAY_MCP_GENERATION"; pub(crate) const GENERATION_FILE_NAME: &str = ".nemo-relay-generation"; const MAX_GENERATION_TOKEN_BYTES: usize = 128; +const MAX_GENERATION_MARKER_BYTES: usize = 16 * 1024; +const MAX_GENERATION_LOCK_ID_BYTES: usize = 64; const RETIRED_GENERATION_PREFIX: &str = "retired:"; +const GENERATION_LOCK_PATH_PREFIX: &str = "lock-path:"; +const GENERATION_LOCK_SUFFIX: &str = ".lock"; const DEFAULT_GENERATION_LOCK_TIMEOUT: Duration = Duration::from_secs(5); const GENERATION_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25); -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug)] pub(crate) struct InstallGeneration { path: PathBuf, - token: String, + marker: GenerationMarker, + lock_id: String, + // The lock lives outside movable plugin trees while the marker remains plugin-owned. + // Retaining this handle preserves fencing across replacement and rollback without preventing + // Windows from moving the marketplace that contains the marker. + file: Arc, } /// Shared generation lock held across one gateway adoption or startup. @@ -31,80 +44,326 @@ pub(crate) struct InstallGeneration { /// and stop an endpoint until every startup that observed the old marker has either published a /// ready gateway or failed. pub(crate) struct ActiveGenerationGuard { - _lock: File, + lock: Arc, +} + +impl Drop for ActiveGenerationGuard { + fn drop(&mut self) { + let _ = unlock_file(&self.lock); + } } impl InstallGeneration { - pub(crate) fn capture_from_env() -> Result, String> { - env::var_os(GENERATION_FILE_ENV) - .map(PathBuf::from) - .map(Self::capture) - .transpose() + pub(crate) fn capture_guarded_from_env() -> Result, String> + { + match ( + env::var_os(GENERATION_FILE_ENV), + env::var_os(GENERATION_TOKEN_ENV), + ) { + (None, None) => Ok(None), + (Some(path), Some(expected)) => { + let expected = expected.into_string().map_err(|_| { + format!("{GENERATION_TOKEN_ENV} is not valid Unicode; reinstall the plugin") + })?; + Self::capture_guarded_expected(PathBuf::from(path), &expected).map(Some) + } + (Some(_), None) => Err(format!( + "{GENERATION_TOKEN_ENV} is required with {GENERATION_FILE_ENV}; reinstall the plugin" + )), + (None, Some(_)) => Err(format!( + "{GENERATION_FILE_ENV} is required with {GENERATION_TOKEN_ENV}; reinstall the plugin" + )), + } } pub(crate) fn capture(path: PathBuf) -> Result { - let file = open_generation(&path)?; - lock_shared_with_timeout(&file, &path, DEFAULT_GENERATION_LOCK_TIMEOUT)?; - let token = read_generation_file(&file, &path)?; - let current = read_generation_path(&path)?; - if token != current { + let (generation, guard) = Self::capture_guarded(path)?; + drop(guard); + Ok(generation) + } + + pub(crate) fn capture_guarded(path: PathBuf) -> Result<(Self, ActiveGenerationGuard), String> { + // Open the marker first. A force install can replace the entire plugin tree while this + // process waits on the external lock named by that marker. The retained marker handle + // lets us reject an old-marker/new-tree pairing after the wait. + let marker = open_generation(&path)?; + Self::capture_guarded_open_files(path, marker) + } + + pub(crate) fn capture_guarded_expected( + path: PathBuf, + expected: &str, + ) -> Result<(Self, ActiveGenerationGuard), String> { + let (generation, guard) = Self::capture_guarded(path)?; + if generation.token() != expected { + drop(guard); + return Err(retired_generation_error(&generation.path)); + } + Ok((generation, guard)) + } + + pub(crate) fn token(&self) -> &str { + self.marker.token() + } + + fn capture_guarded_open_files( + path: PathBuf, + marker: File, + ) -> Result<(Self, ActiveGenerationGuard), String> { + let observed = read_generation_marker(&marker, &path)?; + let lock_path = observed.lock_path().to_owned(); + let file = open_marker_generation_lock(&path, &lock_path)?; + Self::capture_guarded_open_files_with_lock(path, marker, file, observed) + } + + fn capture_guarded_open_files_with_lock( + path: PathBuf, + marker: File, + file: File, + observed: GenerationMarker, + ) -> Result<(Self, ActiveGenerationGuard), String> { + let lock_path = observed.lock_path().to_owned(); + let file = Arc::new(file); + let lock_id = + lock_shared_with_identity(&file, &path, &lock_path, DEFAULT_GENERATION_LOCK_TIMEOUT)?; + let locked_marker = read_generation_marker(&marker, &path)?; + let visible_marker = read_generation_marker_path(&path)?; + let visible_lock_matches = visible_generation_lock_matches(&file, &lock_path, &lock_id)?; + if observed != locked_marker + || locked_marker != visible_marker + || !visible_lock_matches + || locked_marker.is_retired() + { + let _ = unlock_file(&file); return Err(retired_generation_error(&path)); } - Ok(Self { path, token }) + Ok(( + Self { + path, + marker: visible_marker, + lock_id, + file: file.clone(), + }, + ActiveGenerationGuard { lock: file }, + )) } + #[cfg(test)] pub(crate) fn verify_current(&self) -> Result<(), String> { - self.guard_current().map(|_| ()) + loop { + if self.try_verify_current()? { + return Ok(()); + } + thread::sleep(GENERATION_LOCK_RETRY_INTERVAL); + } + } + + /// Check one lifecycle snapshot without waiting when an installer owns the transaction lock. + pub(crate) fn try_verify_current(&self) -> Result { + match try_lock_shared(&self.file) { + Ok(LockAttempt::Contended) => return Ok(false), + Ok(LockAttempt::Acquired) => {} + Err(_) => return Err(retired_generation_error(&self.path)), + } + let result = self.try_validate_locked(); + let _ = unlock_file(&self.file); + result + .map(|()| true) + .map_err(|_| retired_generation_error(&self.path)) } pub(crate) fn guard_current(&self) -> Result { - let file = open_generation(&self.path).map_err(|_| retired_generation_error(&self.path))?; - lock_shared_with_timeout(&file, &self.path, DEFAULT_GENERATION_LOCK_TIMEOUT) + lock_shared_with_timeout(&self.file, &self.path, DEFAULT_GENERATION_LOCK_TIMEOUT) .map_err(|_| retired_generation_error(&self.path))?; - let locked = read_generation_file(&file, &self.path) - .map_err(|_| retired_generation_error(&self.path))?; - let current = - read_generation_path(&self.path).map_err(|_| retired_generation_error(&self.path))?; - if locked != self.token || current != self.token { + if self.try_validate_locked().is_err() { + let _ = unlock_file(&self.file); return Err(retired_generation_error(&self.path)); } - Ok(ActiveGenerationGuard { _lock: file }) + Ok(ActiveGenerationGuard { + lock: self.file.clone(), + }) + } + + fn try_validate_locked(&self) -> Result<(), String> { + let current_marker = read_generation_marker_path(&self.path)?; + let current_lock_matches = + visible_generation_lock_matches(&self.file, self.marker.lock_path(), &self.lock_id)?; + if current_marker == self.marker && current_lock_matches && !current_marker.is_retired() { + Ok(()) + } else { + Err(retired_generation_error(&self.path)) + } } } pub(crate) struct GenerationRetirement { lock: Option, + lock_id: String, path: PathBuf, original: GenerationMarker, changed: bool, + committed: bool, + lock_released_for_tree_mutation: bool, +} + +pub(crate) struct VisibleGenerationMarker(GenerationMarker); + +impl Drop for GenerationRetirement { + fn drop(&mut self) { + if self.changed && !self.committed { + let _ = self.restore_after_rollback(); + } + } } #[derive(Clone, Debug, PartialEq, Eq)] enum GenerationMarker { - Active(String), - Retired(String), + Active { token: String, lock_path: PathBuf }, + Retired { token: String, lock_path: PathBuf }, } impl GenerationMarker { + fn active(token: impl Into, lock_path: impl Into) -> Self { + Self::Active { + token: token.into(), + lock_path: lock_path.into(), + } + } + fn token(&self) -> &str { match self { - Self::Active(token) | Self::Retired(token) => token, + Self::Active { token, .. } | Self::Retired { token, .. } => token, } } - fn encoded(&self) -> String { + fn lock_path(&self) -> &Path { match self { - Self::Active(token) => format!("{token}\n"), - Self::Retired(token) => format!("{RETIRED_GENERATION_PREFIX}{token}\n"), + Self::Active { lock_path, .. } | Self::Retired { lock_path, .. } => lock_path, + } + } + + fn retired(&self) -> Self { + Self::Retired { + token: self.token().to_owned(), + lock_path: self.lock_path().to_owned(), } } + fn encoded(&self) -> String { + let token = match self { + Self::Active { token, .. } => token.to_owned(), + Self::Retired { token, .. } => format!("{RETIRED_GENERATION_PREFIX}{token}"), + }; + format!( + "{token}\n{GENERATION_LOCK_PATH_PREFIX}{}\n", + encode_lock_path(self.lock_path()) + ) + } + fn is_retired(&self) -> bool { - matches!(self, Self::Retired(_)) + matches!(self, Self::Retired { .. }) } } impl GenerationRetirement { + pub(crate) fn lock_path(&self) -> &Path { + self.original.lock_path() + } + + pub(crate) fn marker_path(&self) -> &Path { + &self.path + } + + pub(crate) fn uses_lock_path(&self, path: &Path) -> Result { + let path = absolute_lock_path(path)?; + if !inspected_path_exists(&path, "MCP install generation lock")? { + return Ok(false); + } + let lock = self.lock.as_ref().ok_or_else(|| { + format!( + "MCP install generation {} has no transaction lock", + self.path.display() + ) + })?; + visible_generation_lock_matches(lock, &path, &self.lock_id) + } + + /// Follow an unchanged staged marker after its marketplace is promoted to the live path. + /// + /// The external lock remains held throughout the rename, so no MCP can adopt the replacement + /// between staging and host registration. + pub(crate) fn retarget_promoted_marker(&mut self, path: &Path) -> Result<(), String> { + if self.changed || self.committed { + return Err(format!( + "cannot retarget mutated MCP install generation {}", + self.path.display() + )); + } + if !self.visible_lock_identity_matches()? { + return Err(format!( + "failed to adopt promoted MCP install generation {} because its lock identity changed", + path.display() + )); + } + let visible = read_generation_marker_path(path)?; + if visible != self.original { + return Err(format!( + "failed to adopt promoted MCP install generation {} because its marker changed", + path.display() + )); + } + self.path = path.to_owned(); + Ok(()) + } + + /// Read the active marker protected by this already-held transaction lock. + pub(crate) fn active_visible_token(&self) -> Result { + let visible = read_generation_marker_path(&self.path)?; + if visible.is_retired() || !self.uses_lock_path(visible.lock_path())? { + return Err(retired_generation_error(&self.path)); + } + Ok(visible.token().to_owned()) + } + + pub(crate) fn visible_marker_uses_transaction_lock(&self) -> Result { + let visible = read_generation_marker_path(&self.path)?; + self.uses_lock_path(visible.lock_path()) + } + + /// Release a legacy sibling lock before Windows moves or removes its containing plugin tree. + /// + /// The marker must already be retired. A rollback reacquires this exact lock identity after + /// the tree is restored and before the old active marker is republished. + pub(crate) fn release_legacy_lock_for_tree_mutation(&mut self) -> Result<(), String> { + if self.lock_released_for_tree_mutation { + return Ok(()); + } + if !self.uses_lock_path(&generation_lock_path(&self.path))? { + return Ok(()); + } + if !self.original.is_retired() && !self.changed { + return Err(format!( + "cannot release active MCP install generation lock {}", + self.original.lock_path().display() + )); + } + let Some(file) = self.lock.take() else { + return Err(format!( + "MCP install generation {} is not locked", + self.path.display() + )); + }; + if let Err(error) = unlock_file(&file) { + self.lock = Some(file); + return Err(format!( + "failed to release MCP install generation lock {} before moving its plugin tree: {error}", + self.original.lock_path().display() + )); + } + self.lock_released_for_tree_mutation = true; + Ok(()) + } + pub(crate) fn acquire(path: &Path) -> Result, String> { Self::acquire_with_timeout(path, DEFAULT_GENERATION_LOCK_TIMEOUT) } @@ -113,78 +372,250 @@ impl GenerationRetirement { path: &Path, timeout: Duration, ) -> Result, String> { - let file = match OpenOptions::new().read(true).write(true).open(path) { - Ok(file) => file, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => { - return Err(format!( - "failed to open MCP install generation {}: {error}", - path.display() - )); - } - }; + if !inspected_path_exists(path, "MCP install generation")? { + return Ok(None); + } + // Open the marker before its external lock so validation can detect a plugin-tree swap + // between the two opens. The immutable lock UUID additionally detects lock replacement. + let marker = open_generation(path)?; + let observed = read_generation_marker(&marker, path)?; + let lock_path = observed.lock_path().to_owned(); + let file = open_marker_generation_lock(path, &lock_path)?; lock_exclusive_with_timeout(&file, path, timeout)?; - let original = read_generation_marker(&file, path)?; + let lock_id = if is_legacy_sibling_lock(path, &lock_path)? { + ensure_generation_lock_identity_locked(&file, &lock_path)? + } else { + read_generation_lock_identity(&file, &lock_path)? + .ok_or_else(|| empty_generation_lock_error(&lock_path))? + }; + let original = read_generation_marker(&marker, path)?; + let visible = read_generation_marker_path(path)?; + let visible_lock_matches = visible_generation_lock_matches(&file, &lock_path, &lock_id)?; + if observed != original || original != visible || !visible_lock_matches { + let _ = unlock_file(&file); + return Err(retired_generation_error(path)); + } Ok(Some(Self { lock: Some(file), + lock_id, path: path.to_owned(), original, changed: false, + committed: false, + lock_released_for_tree_mutation: false, })) } - /// Persistently invalidate this generation and release its exclusive lock before moving the - /// plugin tree. A retired marker cannot be adopted by an old or newly launched MCP process, - /// but remains recognizable so an interrupted uninstall can resume. + /// Persistently invalidate this generation while retaining its exclusive transaction lock. + /// + /// Existing MCPs retain the stable generation lock while the marker can be atomically + /// replaced or moved. Call [`Self::commit_replacement`] to make retirement permanent; + /// otherwise dropping the transaction restores the token before releasing the lock. pub(crate) fn invalidate_for_replacement(&mut self) -> Result<(), String> { - if self.original.is_retired() || self.changed { - self.lock = None; + self.invalidate_with(|path, retired| replace_generation_marker(path, retired, "invalidate")) + } + + fn invalidate_with( + &mut self, + write_retired: impl FnOnce(&Path, &GenerationMarker) -> Result<(), String>, + ) -> Result<(), String> { + if self.original.is_retired() { + return Ok(()); + } + if self.changed { return Ok(()); } - let retired = GenerationMarker::Retired(self.original.token().to_owned()); - let file = self.lock.as_mut().ok_or_else(|| { + let retired = self.original.retired(); + self.lock.as_ref().ok_or_else(|| { format!( "MCP install generation {} is not locked", self.path.display() ) })?; self.changed = true; - replace_generation_marker(file, &self.path, &retired, "invalidate")?; - self.lock = None; + if let Err(error) = write_retired(&self.path, &retired) { + let restore_error = replace_generation_marker( + &self.path, + &self.original, + "restore after failed invalidation", + ) + .err(); + if restore_error.is_none() { + self.changed = false; + } + return match restore_error { + Some(restore_error) => Err(format!("{error}; additionally {restore_error}")), + None => Err(error), + }; + } Ok(()) } + /// Commit the retired marker while retaining the transaction lock until this value is dropped. + pub(crate) fn commit_replacement(&mut self) { + self.committed = true; + } + + /// Retire a promoted marker while this transaction still owns its external lock. + /// + /// Force-install rollback uses this after a staged tree is promoted. The old and replacement + /// markers intentionally share one external transaction lock, so reacquiring a second + /// retirement would deadlock. Returning the active marker lets a failed gateway refresh + /// restore the promoted generation without exposing it during destructive rollback. + pub(crate) fn retire_visible_replacement(&mut self) -> Result { + let visible = read_generation_marker_path(&self.path)?; + if visible.is_retired() { + return Err(format!( + "replacement MCP install generation {} is already retired", + self.path.display() + )); + } + if !self.uses_lock_path(visible.lock_path())? { + return Err(format!( + "failed to retire replacement MCP install generation {} because its lock identity changed", + self.path.display() + )); + } + let retired = visible.retired(); + replace_generation_marker(&self.path, &retired, "retire replacement")?; + self.verify_visible_state_for_rollback(&retired)?; + Ok(VisibleGenerationMarker(visible)) + } + + pub(crate) fn restore_visible_replacement( + &mut self, + visible: VisibleGenerationMarker, + ) -> Result<(), String> { + self.verify_visible_state_for_rollback(&visible.0.retired())?; + replace_generation_marker(&self.path, &visible.0, "restore replacement")?; + self.verify_visible_state_for_rollback(&visible.0) + } + /// Restore an invalidated marker before a rolled-back plugin is registered again. pub(crate) fn restore_after_rollback(&mut self) -> Result<(), String> { if !self.changed { self.lock = None; + self.lock_released_for_tree_mutation = false; return Ok(()); } - let mut file = match self.lock.take() { - Some(file) => file, - None => OpenOptions::new() - .write(true) - .open(&self.path) - .map_err(|error| { - format!( - "failed to reopen MCP install generation {} for rollback: {error}", - self.path.display() - ) - })?, - }; - replace_generation_marker(&mut file, &self.path, &self.original, "restore")?; + self.reacquire_transaction_lock()?; + // Never publish the retired generation's token through a replacement tree. A failed + // filesystem rollback can leave the promoted tree visible at the same path while this + // transaction still owns the shared external lock. + self.verify_visible_state_for_rollback(&self.original.retired())?; + replace_generation_marker(&self.path, &self.original, "restore")?; + // Retain the post-write check so an unexpected path swap during restoration is still + // reported before the old generation is considered active again. + self.verify_visible_state_for_rollback(&self.original)?; self.changed = false; + self.committed = false; + self.lock = None; + self.lock_released_for_tree_mutation = false; + Ok(()) + } + + fn verify_visible_state_for_rollback( + &self, + expected_marker: &GenerationMarker, + ) -> Result<(), String> { + if !self.visible_lock_identity_matches()? { + return Err(format!( + "failed to restore MCP install generation {} because its marker or lock identity changed", + self.path.display() + )); + } + let visible_marker = read_generation_marker_path(&self.path)?; + if &visible_marker != expected_marker { + return Err(format!( + "failed to restore MCP install generation {} because its marker or lock identity changed", + self.path.display() + )); + } + Ok(()) + } + + fn visible_lock_identity_matches(&self) -> Result { + let lock = self.lock.as_ref().ok_or_else(|| { + format!( + "MCP install generation {} has no transaction lock for rollback", + self.path.display() + ) + })?; + visible_generation_lock_matches(lock, self.original.lock_path(), &self.lock_id) + } + + fn reacquire_transaction_lock(&mut self) -> Result<(), String> { + if self.lock.is_some() { + return Ok(()); + } + if !self.lock_released_for_tree_mutation { + return Err(format!( + "MCP install generation {} has no transaction lock for rollback", + self.path.display() + )); + } + let lock_path = self.original.lock_path(); + let file = open_existing_generation_lock_path(lock_path)?; + lock_exclusive_with_timeout(&file, &self.path, DEFAULT_GENERATION_LOCK_TIMEOUT)?; + let visible_identity = read_generation_lock_identity(&file, lock_path)? + .ok_or_else(|| empty_generation_lock_error(lock_path))?; + if visible_identity != self.lock_id { + let _ = unlock_file(&file); + return Err(format!( + "failed to reacquire MCP install generation lock {} because its identity changed", + lock_path.display() + )); + } + self.lock = Some(file); + self.lock_released_for_tree_mutation = false; Ok(()) } } +/// Distinguishes an absent path from Windows reporting `NotFound` when an intermediate component +/// is a file. Treating the latter as absent would silently skip retirement or lock validation. +fn inspected_path_exists(path: &Path, description: &str) -> Result { + match fs::metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let mut ancestor = path.parent(); + while let Some(parent) = ancestor { + match fs::metadata(parent) { + Ok(metadata) if metadata.is_dir() => return Ok(false), + Ok(_) => { + return Err(format!( + "failed to inspect {description} {}: parent {} is not a directory", + path.display(), + parent.display() + )); + } + Err(parent_error) if parent_error.kind() == std::io::ErrorKind::NotFound => { + ancestor = parent.parent(); + } + Err(parent_error) => { + return Err(format!( + "failed to inspect {description} {}: failed to inspect parent {}: {parent_error}", + path.display(), + parent.display() + )); + } + } + } + Ok(false) + } + Err(error) => Err(format!( + "failed to inspect {description} {}: {error}", + path.display() + )), + } +} + fn replace_generation_marker( - file: &mut File, path: &Path, marker: &GenerationMarker, operation: &str, ) -> Result<(), String> { - write_generation_marker(file, marker).map_err(|error| { + atomic_write(path, marker.encoded().as_bytes()).map_err(|error| { format!( "failed to {operation} MCP install generation {}: {error}", path.display() @@ -192,17 +623,59 @@ fn replace_generation_marker( }) } -fn write_generation_marker(file: &mut File, marker: &GenerationMarker) -> std::io::Result<()> { - file.set_len(0)?; - file.seek(SeekFrom::Start(0))?; - file.write_all(marker.encoded().as_bytes())?; - file.sync_all() -} - fn lock_shared_with_timeout(file: &File, path: &Path, timeout: Duration) -> Result<(), String> { lock_with_timeout(file, path, timeout, false) } +/// Acquire a shared generation lock whose immutable inode identity is initialized and readable. +/// +/// A zero-length legacy sibling lock can remain after an interrupted first install. Initialization +/// briefly upgrades through an exclusive lock, then reacquires the shared lock and lets the +/// caller's marker/visible-path validation close the upgrade gap. Explicit external locks are +/// never initialized through a marker because their paths are marker-controlled. +fn lock_shared_with_identity( + file: &File, + path: &Path, + lock_path: &Path, + timeout: Duration, +) -> Result { + lock_shared_with_timeout(file, path, timeout)?; + match read_generation_lock_identity(file, lock_path) { + Ok(Some(identity)) => return Ok(identity), + Ok(None) if !is_legacy_sibling_lock(path, lock_path)? => { + let _ = unlock_file(file); + return Err(empty_generation_lock_error(lock_path)); + } + Ok(None) => { + let _ = unlock_file(file); + } + Err(error) => { + let _ = unlock_file(file); + return Err(error); + } + } + + lock_exclusive_with_timeout(file, path, timeout)?; + let initialized = ensure_generation_lock_identity_locked(file, lock_path); + let _ = unlock_file(file); + initialized?; + + lock_shared_with_timeout(file, path, timeout)?; + read_generation_lock_identity(file, lock_path)?.ok_or_else(|| { + format!( + "MCP install generation lock {} remained empty after initialization", + lock_path.display() + ) + }) +} + +fn empty_generation_lock_error(lock_path: &Path) -> String { + format!( + "MCP install generation lock {} is empty", + lock_path.display() + ) +} + fn lock_exclusive_with_timeout(file: &File, path: &Path, timeout: Duration) -> Result<(), String> { lock_with_timeout(file, path, timeout, true) } @@ -241,13 +714,311 @@ fn lock_with_timeout( } } +#[cfg(test)] pub(crate) fn write_new_generation(path: &Path) -> Result<(), String> { + write_new_generation_with_token(path).map(|_| ()) +} + +#[cfg(test)] +pub(crate) fn write_new_generation_with_token(path: &Path) -> Result { + let lock_path = generation_lock_path(path); + write_new_generation_with_token_at(path, &lock_path) +} + +#[cfg(test)] +pub(crate) fn write_legacy_generation(path: &Path, token: &str) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + let lock_path = generation_lock_path(path); + let file = open_generation_lock_path(&lock_path)?; + lock_exclusive_with_timeout(&file, path, DEFAULT_GENERATION_LOCK_TIMEOUT)?; + if let Err(error) = ensure_generation_lock_identity_locked(&file, &lock_path) { + let _ = unlock_file(&file); + return Err(error); + } + let result = atomic_write(path, format!("{token}\n").as_bytes()); + let _ = unlock_file(&file); + result +} + +pub(crate) fn write_new_generation_with_token_at( + path: &Path, + lock_path: &Path, +) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + if let Some(parent) = lock_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + let file = open_generation_lock_path(lock_path)?; + lock_exclusive_with_timeout(&file, path, DEFAULT_GENERATION_LOCK_TIMEOUT)?; + if let Err(error) = ensure_generation_lock_identity_locked(&file, lock_path) { + let _ = unlock_file(&file); + return Err(error); + } + let token = uuid::Uuid::now_v7().to_string(); + let marker = GenerationMarker::active(&token, absolute_lock_path(lock_path)?); + let result = atomic_write(path, marker.encoded().as_bytes()); + let _ = unlock_file(&file); + result.map(|()| token) +} + +pub(crate) fn write_staged_generation_with_token( + path: &Path, + active_lock_path: &Path, +) -> Result { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; } - fs::write(path, format!("{}\n", uuid::Uuid::now_v7())) - .map_err(|error| format!("failed to write {}: {error}", path.display())) + let token = uuid::Uuid::now_v7().to_string(); + let marker = GenerationMarker::active(&token, absolute_lock_path(active_lock_path)?); + atomic_write(path, marker.encoded().as_bytes()).map(|()| token) +} + +fn absolute_lock_path(path: &Path) -> Result { + if path.is_absolute() { + return Ok(path.to_owned()); + } + env::current_dir() + .map(|current| current.join(path)) + .map_err(|error| { + format!( + "failed to resolve generation lock {}: {error}", + path.display() + ) + }) +} + +fn generation_lock_path(path: &Path) -> PathBuf { + let mut lock = path.as_os_str().to_os_string(); + lock.push(GENERATION_LOCK_SUFFIX); + PathBuf::from(lock) +} + +fn is_legacy_sibling_lock(marker_path: &Path, lock_path: &Path) -> Result { + Ok(absolute_lock_path(&generation_lock_path(marker_path))? == absolute_lock_path(lock_path)?) +} + +#[cfg(test)] +fn open_generation_lock(path: &Path) -> Result { + let lock_path = generation_lock_path(path); + open_generation_lock_path(&lock_path) +} + +fn open_generation_lock_path(lock_path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(lock_path).map_err(|error| { + format!( + "failed to open MCP install generation lock {}: {error}", + lock_path.display() + ) + }) +} + +fn open_marker_generation_lock(marker_path: &Path, lock_path: &Path) -> Result { + // Legacy one-line markers derive a sibling lock. Creating only that deterministic path keeps + // old installs and Hermes upgrades compatible without allowing a marker to create an + // arbitrary external file. New plugin markers always point at a pre-initialized state lock. + if is_legacy_sibling_lock(marker_path, lock_path)? { + open_generation_lock_path(lock_path) + } else { + open_existing_generation_lock_path(lock_path) + } +} + +fn open_existing_generation_lock_path(lock_path: &Path) -> Result { + OpenOptions::new() + .read(true) + .open(lock_path) + .map_err(|error| { + format!( + "failed to open MCP install generation lock {}: {error}", + lock_path.display() + ) + }) +} + +fn ensure_generation_lock_identity_locked(file: &File, lock_path: &Path) -> Result { + if let Some(identity) = read_generation_lock_identity(file, lock_path)? { + return Ok(identity); + } + let identity = uuid::Uuid::now_v7().to_string(); + write_generation_lock_identity(file, lock_path, &identity)?; + Ok(identity) +} + +fn write_generation_lock_identity( + file: &File, + lock_path: &Path, + identity: &str, +) -> Result<(), String> { + let mut writer = file; + writer.seek(SeekFrom::Start(0)).map_err(|error| { + format!( + "failed to seek MCP install generation lock {}: {error}", + lock_path.display() + ) + })?; + file.set_len(0).map_err(|error| { + format!( + "failed to truncate MCP install generation lock {}: {error}", + lock_path.display() + ) + })?; + writer + .write_all(format!("{identity}\n").as_bytes()) + .map_err(|error| { + format!( + "failed to write MCP install generation lock {}: {error}", + lock_path.display() + ) + })?; + writer.sync_all().map_err(|error| { + format!( + "failed to sync MCP install generation lock {}: {error}", + lock_path.display() + ) + }) +} + +#[cfg(test)] +fn read_generation_lock_identity_path(path: &Path) -> Result { + let marker = read_generation_marker_path(path)?; + let lock_path = marker.lock_path(); + let file = open_existing_generation_lock_path(lock_path)?; + read_generation_lock_identity(&file, lock_path)?.ok_or_else(|| { + format!( + "MCP install generation lock {} is empty", + lock_path.display() + ) + }) +} + +/// Verify that the visible lock path still names the locked generation and retains its UUID. +/// +/// Windows byte-range locks reject reads through every other handle, including handles opened by +/// the locking process. Compare file identities there, then read the UUID through the owning +/// handle. Unix locks are advisory, so reading the visible path preserves the same check directly. +fn visible_generation_lock_matches( + locked: &File, + lock_path: &Path, + expected_identity: &str, +) -> Result { + #[cfg(windows)] + { + let visible = open_existing_generation_lock_path(lock_path)?; + if windows_file_identity(locked, lock_path)? != windows_file_identity(&visible, lock_path)? + { + return Ok(false); + } + return read_generation_lock_identity(locked, lock_path) + .map(|identity| identity.as_deref() == Some(expected_identity)); + } + #[cfg(not(windows))] + { + let visible = open_existing_generation_lock_path(lock_path)?; + #[cfg(unix)] + if unix_file_identity(locked, lock_path)? != unix_file_identity(&visible, lock_path)? { + return Ok(false); + } + read_generation_lock_identity(&visible, lock_path) + .map(|identity| identity.as_deref() == Some(expected_identity)) + } +} + +#[cfg(unix)] +fn unix_file_identity(file: &File, lock_path: &Path) -> Result<(u64, u64), String> { + use std::os::unix::fs::MetadataExt; + + file.metadata() + .map(|metadata| (metadata.dev(), metadata.ino())) + .map_err(|error| { + format!( + "failed to identify MCP install generation lock {}: {error}", + lock_path.display() + ) + }) +} + +#[cfg(windows)] +fn windows_file_identity(file: &File, lock_path: &Path) -> Result<(u64, [u8; 16]), String> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ID_INFO, FileIdInfo, GetFileInformationByHandleEx, + }; + + let mut information = FILE_ID_INFO::default(); + // SAFETY: `file` owns a live handle and `information` is writable for the duration of the + // synchronous call. + if unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileIdInfo, + (&raw mut information).cast(), + std::mem::size_of::() as u32, + ) + } == 0 + { + return Err(format!( + "failed to identify MCP install generation lock {}: {}", + lock_path.display(), + std::io::Error::last_os_error() + )); + } + Ok(( + information.VolumeSerialNumber, + information.FileId.Identifier, + )) +} + +fn read_generation_lock_identity(file: &File, lock_path: &Path) -> Result, String> { + let mut raw = String::new(); + let mut reader = file; + reader.seek(SeekFrom::Start(0)).map_err(|error| { + format!( + "failed to seek MCP install generation lock {}: {error}", + lock_path.display() + ) + })?; + reader + .take(MAX_GENERATION_LOCK_ID_BYTES.saturating_add(1) as u64) + .read_to_string(&mut raw) + .map_err(|error| { + format!( + "failed to read MCP install generation lock {}: {error}", + lock_path.display() + ) + })?; + if raw.len() > MAX_GENERATION_LOCK_ID_BYTES { + return Err(format!( + "MCP install generation lock {} exceeds the {MAX_GENERATION_LOCK_ID_BYTES}-byte limit", + lock_path.display() + )); + } + let identity = raw.trim(); + if identity.is_empty() { + return Ok(None); + } + uuid::Uuid::parse_str(identity) + .map(|identity| Some(identity.to_string())) + .map_err(|error| { + format!( + "MCP install generation lock {} has an invalid identity: {error}", + lock_path.display() + ) + }) } fn open_generation(path: &Path) -> Result { @@ -259,21 +1030,22 @@ fn open_generation(path: &Path) -> Result { }) } -fn read_generation_path(path: &Path) -> Result { +fn read_generation_marker_path(path: &Path) -> Result { let file = open_generation(path)?; - read_generation_file(&file, path) -} - -fn read_generation_file(file: &File, path: &Path) -> Result { - match read_generation_marker(file, path)? { - GenerationMarker::Active(token) => Ok(token), - GenerationMarker::Retired(_) => Err(retired_generation_error(path)), - } + read_generation_marker(&file, path) } fn read_generation_marker(file: &File, path: &Path) -> Result { let mut raw = String::new(); - file.take(MAX_GENERATION_TOKEN_BYTES.saturating_add(1) as u64) + let mut reader = file; + reader.seek(SeekFrom::Start(0)).map_err(|error| { + format!( + "failed to seek MCP install generation {}: {error}", + path.display() + ) + })?; + reader + .take(MAX_GENERATION_MARKER_BYTES.saturating_add(1) as u64) .read_to_string(&mut raw) .map_err(|error| { format!( @@ -281,27 +1053,124 @@ fn read_generation_marker(file: &File, path: &Path) -> Result MAX_GENERATION_TOKEN_BYTES { + if raw.len() > MAX_GENERATION_MARKER_BYTES { return Err(format!( - "MCP install generation {} exceeds the {MAX_GENERATION_TOKEN_BYTES}-byte limit", + "MCP install generation {} exceeds the {MAX_GENERATION_MARKER_BYTES}-byte limit", path.display() )); } - let token = raw.trim(); + let mut lines = raw.lines(); + let token = lines.next().unwrap_or_default().trim(); if token.is_empty() { return Err(format!( "MCP install generation {} is empty", path.display() )); } - match token.strip_prefix(RETIRED_GENERATION_PREFIX) { - Some("") => Err(format!( - "MCP install generation {} has a retired marker without a token", + let (retired, token) = match token.strip_prefix(RETIRED_GENERATION_PREFIX) { + Some("") => { + return Err(format!( + "MCP install generation {} has a retired marker without a token", + path.display() + )); + } + Some(token) => (true, token), + None => (false, token), + }; + if token.len() > MAX_GENERATION_TOKEN_BYTES { + return Err(format!( + "MCP install generation token in {} exceeds the {MAX_GENERATION_TOKEN_BYTES}-byte limit", path.display() - )), - Some(token) => Ok(GenerationMarker::Retired(token.to_owned())), - None => Ok(GenerationMarker::Active(token.to_owned())), + )); + } + let lock_path = match lines.next() { + Some(encoded) => { + let encoded = encoded + .strip_prefix(GENERATION_LOCK_PATH_PREFIX) + .ok_or_else(|| { + format!( + "MCP install generation {} has an invalid lock-path record", + path.display() + ) + })?; + if lines.next().is_some() { + return Err(format!( + "MCP install generation {} has unexpected trailing records", + path.display() + )); + } + let lock_path = decode_lock_path(encoded).map_err(|error| { + format!( + "MCP install generation {} has an invalid lock path: {error}", + path.display() + ) + })?; + if !lock_path.is_absolute() { + return Err(format!( + "MCP install generation {} has a non-absolute external lock path", + path.display() + )); + } + lock_path + } + None => absolute_lock_path(&generation_lock_path(path))?, + }; + Ok(if retired { + GenerationMarker::Retired { + token: token.to_owned(), + lock_path, + } + } else { + GenerationMarker::active(token, lock_path) + }) +} + +fn encode_lock_path(path: &Path) -> String { + #[cfg(unix)] + let bytes = { + use std::os::unix::ffi::OsStrExt; + path.as_os_str().as_bytes().to_vec() + }; + #[cfg(windows)] + let bytes = { + use std::os::windows::ffi::OsStrExt; + path.as_os_str() + .encode_wide() + .flat_map(u16::to_le_bytes) + .collect::>() + }; + #[cfg(not(any(unix, windows)))] + let bytes = path.to_string_lossy().as_bytes().to_vec(); + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn decode_lock_path(encoded: &str) -> Result { + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|error| error.to_string())?; + #[cfg(unix)] + let path = { + use std::os::unix::ffi::OsStringExt; + PathBuf::from(std::ffi::OsString::from_vec(bytes)) + }; + #[cfg(windows)] + let path = { + use std::os::windows::ffi::OsStringExt; + let pairs = bytes.chunks_exact(2); + if !pairs.remainder().is_empty() { + return Err("UTF-16 lock path has an odd byte length".into()); + } + let wide = pairs + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(); + PathBuf::from(std::ffi::OsString::from_wide(&wide)) + }; + #[cfg(not(any(unix, windows)))] + let path = PathBuf::from(String::from_utf8(bytes).map_err(|error| error.to_string())?); + if path.as_os_str().is_empty() { + return Err("lock path is empty".into()); } + Ok(path) } fn retired_generation_error(path: &Path) -> String { diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 2d16a71b2..899187e71 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -3,92 +3,97 @@ use std::io::Read; use std::path::Path; -use std::time::Duration; +use std::time::{Duration, Instant}; +use base64::Engine; use futures_util::StreamExt; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; use serde_json::{Value, json}; -use crate::config::{CodingAgent, GatewayMode, HookForwardCommand}; +use crate::config::{ + CodingAgent, GATEWAY_URL_ENV, GatewayMode, HookForwardCommand, TRANSPARENT_RUN_ENV, +}; use crate::error::CliError; - -// Claude Code validates plugin hooks.json against a strict event-name whitelist — one unknown -// event rejects the entire plugin's hooks (no hooks register, silently). Both Claude vectors -// (the transparent-run temp plugin and the marketplace plugin) are plugin hooks.json, so every -// event here must exist in the minimum Claude Code release prescribed by `coding_agent`. -// Codex receives a separate event schema because ignored unknown events would make generated -// hooks impossible to discover and trust exhaustively. -const CLAUDE_HOOK_EVENTS: &[&str] = &[ - "SessionStart", - "UserPromptSubmit", - "UserPromptExpansion", - "PreToolUse", - "PostToolUse", - "PostToolUseFailure", - "PermissionRequest", - "SubagentStart", - "SubagentStop", - "Notification", - "Stop", - "PreCompact", - "PostCompact", - "SessionEnd", -]; - -const CODEX_HOOK_EVENTS: &[&str] = &[ - "SessionStart", - "UserPromptSubmit", - "PreToolUse", - "PostToolUse", - "PermissionRequest", - "SubagentStart", - "SubagentStop", - "Stop", - "PreCompact", - "PostCompact", -]; +use crate::install_generation::{ActiveGenerationGuard, InstallGeneration}; const HOOK_FORWARD_TIMEOUT: Duration = Duration::from_secs(2); +const HOOK_GATEWAY_RETRY_TIMEOUT: Duration = Duration::from_secs(20); +const HOOK_GATEWAY_RETRY_INTERVAL: Duration = Duration::from_millis(50); const MAX_HOOK_RESPONSE_BYTES: usize = 1024 * 1024; -pub(crate) const HERMES_HOOK_EVENTS: &[&str] = &[ - "on_session_start", - "on_session_end", - "on_session_finalize", - "on_session_reset", - "pre_llm_call", - "post_llm_call", - "pre_api_request", - "post_api_request", - // Observer-only failure telemetry. Older Hermes versions ignore unknown hook names during - // install, while newer versions use this to close failed provider attempts. - "api_request_error", - "pre_tool_call", - "post_tool_call", - "subagent_start", - "subagent_stop", -]; - /// Forwards a hook payload from an installed shell command to a running gateway. /// /// Empty stdin is normalized to `{}` so hooks that provide no payload still generate observable /// marks. Delivery failures are fail-open by default to avoid blocking coding agents, but /// `--fail-closed` converts missing URLs, HTTP failures, and upstream errors into process errors. pub(crate) async fn hook_forward(command: HookForwardCommand) -> Result<(), CliError> { + // A transparent wrapper can coexist with any installed Relay plugin. Its process marker makes + // persistent plugin hooks inert, while only the wrapper-owned command carries + // `--transparent-run` and forwards to the process-private gateway. This avoids rewriting host + // plugin settings and works for both installer and source-marketplace plugin identities. + if transparent_run_active() && !command.transparent_run { + return Ok(()); + } validate_optional_json("session metadata", command.session_metadata.as_deref())?; let fail_closed = command.fail_closed || std::env::var("NEMO_RELAY_FAIL_CLOSED").ok().as_deref() == Some("1"); let destination = hook_destination(&command); - let recovery = match destination - .recover - .then(|| recovery_plan(command.agent, &destination.gateway_url)) + let persistent = match (destination.lifecycle != HookGatewayLifecycle::Transparent) + .then(|| recovery_plan(&destination.gateway_url)) + .transpose() + { + Ok(persistent) => persistent, + Err(error) => return handle_hook_error(error, fail_closed), + }; + let transparent_gateway = match command + .transparent_run + .then(|| transparent_gateway_spec(&destination.gateway_url)) .transpose() { - Ok(recovery) => recovery, + Ok(gateway) => gateway, Err(error) => return handle_hook_error(error, fail_closed), }; + let mut generation_guard = if destination.lifecycle == HookGatewayLifecycle::Recover { + let install_host = command.agent.install_arg(); + let Some(generation_file) = command.generation_file.clone() else { + return handle_hook_error( + CliError::Launch(format!( + "persistent {} hook is missing its install-generation fence; run `nemo-relay install {install_host} --force`", + command.agent.label() + )), + fail_closed, + ); + }; + let Some(generation_token) = command.generation_token.as_deref() else { + return handle_hook_error( + CliError::Launch(format!( + "persistent {} hook is missing its expected install-generation identity; run `nemo-relay install {install_host} --force`", + command.agent.label() + )), + fail_closed, + ); + }; + match InstallGeneration::capture_guarded_expected(generation_file, generation_token) { + Ok((_generation, guard)) => Some(guard), + Err(error) => return handle_hook_error(CliError::Launch(error), fail_closed), + } + } else { + None + }; + if destination.lifecycle == HookGatewayLifecycle::Existing { + let gateway = persistent + .as_ref() + .expect("existing persistent destinations resolve a gateway") + .gateway + .clone(); + if let Err(error) = + wait_for_existing_gateway(gateway, destination.gateway_url.clone()).await + { + return handle_hook_error(error, fail_closed); + } + } let input = match read_hook_payload( - recovery + persistent .as_ref() .map_or(crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, |launch| { launch.max_hook_payload_bytes @@ -97,37 +102,76 @@ pub(crate) async fn hook_forward(command: HookForwardCommand) -> Result<(), CliE Ok(input) => input, Err(error) => return handle_hook_error(error, fail_closed), }; + // Keep this short-lived lease through delivery. Hooks therefore share the same endpoint + // recovery cohort as overlapping MCP clients instead of creating unaccounted replacements. + let mut gateway_acquisition = if destination.lifecycle == HookGatewayLifecycle::Recover { + let launch = persistent + .as_ref() + .expect("recoverable destinations resolve a gateway"); + let generation_guard = generation_guard + .take() + .expect("recoverable destinations have an install generation"); + match acquire_hook_gateway(launch.gateway.clone(), generation_guard).await { + Ok(acquisition) => Some(acquisition), + Err(error) => return handle_hook_error(error, fail_closed), + } + } else { + None + }; + let verified_gateway = persistent + .as_ref() + .map(|launch| &launch.gateway) + .or(transparent_gateway.as_ref()); + if let Some(gateway) = verified_gateway { + let mut response = send_verified_hook_forward_request( + &command, + gateway, + &destination.gateway_url, + input.clone(), + ) + .await?; + if response + .as_ref() + .is_err_and(crate::sidecar::VerifiedHttpError::is_retryable) + && destination.lifecycle == HookGatewayLifecycle::Recover + { + let acquisition = gateway_acquisition + .as_mut() + .expect("recoverable destinations hold a gateway acquisition"); + if let Err(start_error) = recover_hook_gateway(acquisition).await { + let transport_error = response + .as_ref() + .expect_err("recovery only follows a retryable transport error"); + let error = format!( + "nemo-relay hook forward failed: {transport_error}; sidecar recovery failed: {start_error}" + ); + eprintln!("{error}"); + return if fail_closed { + Err(CliError::Install(error)) + } else { + Ok(()) + }; + } + let recovered_gateway = persistent + .as_ref() + .expect("recoverable destinations resolve a persistent gateway"); + response = send_verified_hook_forward_request( + &command, + &recovered_gateway.gateway, + &destination.gateway_url, + input, + ) + .await?; + } + return handle_verified_hook_forward_response(response, fail_closed); + } + let url = format!( "{}{}", destination.gateway_url.trim_end_matches('/'), command.agent.hook_path() ); - if let Some(launch) = recovery.as_ref() - && let Err(error) = recover_gateway(launch.gateway.clone()).await - { - return handle_hook_error(error, fail_closed); - } - let mut response = send_hook_forward_request(&command, &url, input.clone()).await?; - if response.as_ref().is_err_and(reqwest::Error::is_connect) && destination.recover { - let launch = recovery - .as_ref() - .expect("recoverable destinations have a recovery plan"); - if let Err(start_error) = recover_gateway(launch.gateway.clone()).await { - let transport_error = response - .as_ref() - .expect_err("recovery only follows a transport error"); - let error = format!( - "nemo-relay hook forward failed: {transport_error}; sidecar recovery failed: {start_error}" - ); - eprintln!("{error}"); - return if fail_closed { - Err(CliError::Install(error)) - } else { - Ok(()) - }; - } - response = send_hook_forward_request(&command, &url, input).await?; - } + let response = send_hook_forward_request(&command, &url, input).await?; handle_hook_forward_response(response, fail_closed).await } @@ -163,7 +207,17 @@ fn read_hook_payload_from(reader: impl Read, limit: usize) -> Result HookDestination { resolve_hook_destination( command.gateway_url.clone(), - std::env::var("NEMO_RELAY_GATEWAY_URL").ok(), + std::env::var(GATEWAY_URL_ENV).ok(), + command.forward_only, + command.transparent_run, ) } +fn transparent_run_active() -> bool { + std::env::var(TRANSPARENT_RUN_ENV).ok().as_deref() == Some("1") +} + fn resolve_hook_destination( command_url: Option, environment_url: Option, + forward_only: bool, + transparent_run: bool, ) -> HookDestination { + if transparent_run { + return HookDestination { + gateway_url: command_url + .or(environment_url) + .unwrap_or_else(|| crate::sidecar::DEFAULT_URL.into()), + lifecycle: HookGatewayLifecycle::Transparent, + }; + } + if forward_only { + return HookDestination { + gateway_url: command_url.unwrap_or_else(|| crate::sidecar::DEFAULT_URL.into()), + lifecycle: HookGatewayLifecycle::Existing, + }; + } if let Some(gateway_url) = command_url { return HookDestination { gateway_url, - recover: true, + lifecycle: HookGatewayLifecycle::Recover, }; } if let Some(gateway_url) = environment_url { return HookDestination { gateway_url, - recover: false, + lifecycle: HookGatewayLifecycle::Transparent, }; } HookDestination { gateway_url: crate::sidecar::DEFAULT_URL.into(), - recover: true, + lifecycle: HookGatewayLifecycle::Recover, } } -fn recovery_plan( - agent: CodingAgent, - gateway_url: &str, -) -> Result { +async fn wait_for_existing_gateway( + gateway: crate::sidecar::GatewaySpec, + gateway_url: String, +) -> Result<(), CliError> { + tokio::task::spawn_blocking(move || { + let deadline = Instant::now() + HOOK_GATEWAY_RETRY_TIMEOUT; + loop { + match gateway.existing_healthy_instance(&gateway_url) { + Ok(Some(_instance_id)) => return Ok(()), + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(50)); + } + Ok(None) => { + return Err(format!( + "no compatible Relay gateway became ready at {gateway_url}; start the plugin's `nemo-relay mcp` bootstrap before using --forward-only" + )); + } + Err(error) => return Err(error), + } + } + }) + .await + .map_err(|error| CliError::Launch(format!("hook gateway verification task failed: {error}")))? + .map_err(CliError::Launch) +} + +fn recovery_plan(gateway_url: &str) -> Result { + let bind = crate::sidecar::loopback_bind(gateway_url).map_err(CliError::Install)?; + crate::sidecar::resolve_plugin_gateway(&Default::default(), bind) +} + +fn transparent_gateway_spec(gateway_url: &str) -> Result { let bind = crate::sidecar::loopback_bind(gateway_url).map_err(CliError::Install)?; - crate::sidecar::resolve_plugin_gateway(agent, &Default::default(), bind) + Ok(crate::sidecar::GatewaySpec::new(bind) + .with_fingerprint(crate::config::transparent_gateway_fingerprint(gateway_url))) +} + +async fn acquire_hook_gateway( + gateway: crate::sidecar::GatewaySpec, + generation_guard: ActiveGenerationGuard, +) -> Result { + tokio::task::spawn_blocking(move || { + let (gateway, cohort_guard) = acquire_pinned_hook_gateway(&gateway)?; + Ok(HookGatewayAcquisition { + gateway, + cohort_guard: Some(cohort_guard), + _generation_guard: generation_guard, + }) + }) + .await + .map_err(|error| CliError::Launch(format!("hook acquisition task failed: {error}")))? + .map_err(CliError::Launch) +} + +fn gateway_cohort_was_retired(error: &str) -> bool { + error.contains("was retired by an integration update") +} + +fn acquire_pinned_hook_gateway( + gateway: &crate::sidecar::GatewaySpec, +) -> Result< + ( + crate::sidecar::GatewayAcquisition, + crate::sidecar::GatewayCohortGuard, + ), + String, +> { + retry_retired_gateway_cohort(|| { + let acquisition = gateway.acquire()?; + let guard = acquisition.guard_cohort()?; + Ok((acquisition, guard)) + }) } -async fn recover_gateway(gateway: crate::sidecar::GatewaySpec) -> Result<(), CliError> { - tokio::task::spawn_blocking(move || gateway.ensure()) - .await - .map_err(|error| CliError::Launch(format!("hook recovery task failed: {error}")))? - .map(|_| ()) - .map_err(CliError::Launch) +fn retry_retired_gateway_cohort( + operation: impl FnMut() -> Result, +) -> Result { + retry_retired_gateway_cohort_until(Instant::now() + HOOK_GATEWAY_RETRY_TIMEOUT, operation) +} + +fn retry_retired_gateway_cohort_until( + deadline: Instant, + operation: impl FnMut() -> Result, +) -> Result { + retry_retired_gateway_cohort_with_clock(deadline, Instant::now, std::thread::sleep, operation) +} + +fn retry_retired_gateway_cohort_with_clock( + deadline: Instant, + mut now: impl FnMut() -> Instant, + mut sleep: impl FnMut(Duration), + mut operation: impl FnMut() -> Result, +) -> Result { + loop { + match operation() { + Ok(value) => return Ok(value), + Err(error) if gateway_cohort_was_retired(&error) => { + let current = now(); + if current >= deadline { + return Err(error); + } + let remaining = deadline.saturating_duration_since(current); + sleep(HOOK_GATEWAY_RETRY_INTERVAL.min(remaining)); + } + Err(error) => return Err(error), + } + } +} + +struct HookGatewayAcquisition { + gateway: crate::sidecar::GatewayAcquisition, + // The shared endpoint guard blocks replacement while allowing concurrent deliveries; the + // generation guard separately prevents this host's installed command from being retired. + // Both remain held until the HTTP response is accepted or delivery fails. + cohort_guard: Option, + _generation_guard: ActiveGenerationGuard, +} + +#[cfg(test)] +fn hook_generation_guard(generation: &InstallGeneration) -> Result { + generation.guard_current() +} + +async fn recover_hook_gateway(acquisition: &mut HookGatewayAcquisition) -> Result<(), CliError> { + // Recovery itself needs the endpoint transaction lock. Release the delivery guard, then + // reacquire and validate the same cohort before the caller retries the HTTP request. + drop(acquisition.cohort_guard.take()); + let gateway = acquisition.gateway.spec.clone(); + let expected_instance = acquisition.gateway.endpoint.instance_id.clone(); + let gateway_url = acquisition.gateway.endpoint.url.clone(); + let cohort_id = acquisition.gateway.lease.cohort_id().to_string(); + let refreshed = tokio::task::spawn_blocking(move || { + let targeted = gateway + .recover(&expected_instance, &cohort_id) + .and_then(|endpoint| { + crate::sidecar::guard_gateway_cohort(&gateway_url, &cohort_id) + .map(|guard| (endpoint, guard)) + }); + match targeted { + Ok((endpoint, guard)) => Ok(HookGatewayRefresh::Recovered(endpoint, guard)), + Err(error) if gateway_cohort_was_retired(&error) => { + let (acquisition, guard) = acquire_pinned_hook_gateway(&gateway)?; + Ok(HookGatewayRefresh::Reacquired(acquisition, guard)) + } + Err(error) => Err(error), + } + }) + .await + .map_err(|error| CliError::Launch(format!("hook recovery task failed: {error}")))? + .map_err(CliError::Launch)?; + match refreshed { + HookGatewayRefresh::Recovered(endpoint, guard) => { + acquisition.gateway.endpoint = endpoint; + acquisition.cohort_guard = Some(guard); + } + HookGatewayRefresh::Reacquired(gateway, guard) => { + acquisition.gateway = gateway; + acquisition.cohort_guard = Some(guard); + } + } + Ok(()) +} + +enum HookGatewayRefresh { + Recovered( + crate::sidecar::GatewayEndpoint, + crate::sidecar::GatewayCohortGuard, + ), + Reacquired( + crate::sidecar::GatewayAcquisition, + crate::sidecar::GatewayCohortGuard, + ), +} + +async fn send_verified_hook_forward_request( + command: &HookForwardCommand, + gateway: &crate::sidecar::GatewaySpec, + gateway_url: &str, + input: String, +) -> Result, CliError> +{ + let headers = gateway_headers( + command.profile.as_deref(), + command.session_metadata.as_deref(), + command.gateway_mode, + )? + .iter() + .map(|(name, value)| { + value + .to_str() + .map(|value| (name.as_str().to_string(), value.to_string())) + .map_err(|error| { + CliError::Install(format!( + "hook header {name} is not valid HTTP text: {error}" + )) + }) + }) + .collect::, _>>()?; + let gateway = gateway.clone(); + let gateway_url = gateway_url.to_string(); + let path = command.agent.hook_path().to_string(); + tokio::task::spawn_blocking(move || { + gateway.post_verified( + &gateway_url, + &path, + &headers, + input.as_bytes(), + HOOK_FORWARD_TIMEOUT, + MAX_HOOK_RESPONSE_BYTES, + ) + }) + .await + .map_err(|error| CliError::Launch(format!("verified hook request task failed: {error}"))) } // Sends the hook payload with gateway-specific headers translated from CLI flags. The reqwest @@ -255,22 +530,7 @@ async fn handle_hook_forward_response( return Ok(()); } }; - if !status.is_success() { - if let Some(reason) = guardrail_rejection_reason(&body) { - return Err(CliError::GuardrailRejected(reason)); - } - eprintln!("nemo-relay hook forward failed with HTTP {status}"); - if fail_closed { - return Err(CliError::Install(format!( - "hook forward failed with HTTP {status}" - ))); - } - return Ok(()); - } - if !body.is_empty() { - println!("{body}"); - } - Ok(()) + handle_hook_forward_status(status, body, fail_closed) } Err(error) => { eprintln!("nemo-relay hook forward failed: {error}"); @@ -283,6 +543,59 @@ async fn handle_hook_forward_response( } } +fn handle_verified_hook_forward_response( + response: Result, + fail_closed: bool, +) -> Result<(), CliError> { + match response { + Ok(response) => { + let status = reqwest::StatusCode::from_u16(response.status).map_err(|error| { + CliError::Install(format!( + "verified hook response had an invalid status: {error}" + )) + })?; + handle_hook_forward_status( + status, + String::from_utf8_lossy(&response.body).into_owned(), + fail_closed, + ) + } + Err(error) => { + eprintln!("nemo-relay hook forward failed: {error}"); + if fail_closed { + Err(CliError::Install(format!( + "verified hook forward failed: {error}" + ))) + } else { + Ok(()) + } + } + } +} + +fn handle_hook_forward_status( + status: reqwest::StatusCode, + body: String, + fail_closed: bool, +) -> Result<(), CliError> { + if !status.is_success() { + if let Some(reason) = guardrail_rejection_reason(&body) { + return Err(CliError::GuardrailRejected(reason)); + } + eprintln!("nemo-relay hook forward failed with HTTP {status}"); + if fail_closed { + return Err(CliError::Install(format!( + "hook forward failed with HTTP {status}" + ))); + } + return Ok(()); + } + if !body.is_empty() { + println!("{body}"); + } + Ok(()) +} + async fn read_hook_response(response: reqwest::Response) -> Result { let mut stream = response.bytes_stream(); let mut body = Vec::new(); @@ -317,61 +630,289 @@ fn guardrail_rejection_reason(body: &str) -> Option { /// The returned value always has a top-level `hooks` object. Claude/Codex use command hook /// groups with optional tool matchers, while Hermes uses direct command entries. pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value { - match agent { - CodingAgent::ClaudeCode => claude_hooks(command), - CodingAgent::Codex => codex_hooks(command), - CodingAgent::Hermes => hermes_hooks(command), + if agent.uses_direct_hook_entries() { + direct_hooks(agent.hook_events(), command) + } else { + grouped_hooks(agent.hook_events(), command) } } /// Canonical persistent hook command used by every supported host. -pub(crate) fn persistent_hook_forward_command(relay: &Path, agent: CodingAgent) -> String { - persistent_hook_forward_command_for_platform(relay, agent, cfg!(windows)) +pub(crate) fn persistent_hook_forward_command( + relay: &Path, + agent: CodingAgent, + generation_file: &Path, + generation_token: &str, +) -> Result { + hook_command( + relay, + &persistent_hook_arguments(agent, generation_file, generation_token), + ) } -/// Canonical transparent hook command. The launched agent receives its dynamic gateway through -/// `NEMO_RELAY_GATEWAY_URL`, so the command must not persist a fixed endpoint. -pub(crate) fn transparent_hook_forward_command(relay: &Path, agent: CodingAgent) -> String { - transparent_hook_forward_command_for_platform(relay, agent, cfg!(windows)) +/// Canonical transparent hook command. It embeds the process-private dynamic gateway so hook hosts +/// that filter inherited environment variables cannot redirect delivery to the fixed endpoint. +pub(crate) fn transparent_hook_forward_command( + relay: &Path, + agent: CodingAgent, + gateway_url: &str, +) -> Result { + hook_command(relay, &transparent_hook_arguments(agent, gateway_url)) } +#[cfg(test)] pub(crate) fn transparent_hook_forward_command_for_platform( relay: &Path, agent: CodingAgent, + gateway_url: &str, windows: bool, ) -> String { - format!( - "{} hook-forward {}", - crate::plugin_host::shell_quote_for_platform(relay, windows), - agent.as_arg() + hook_command_for_platform( + relay, + &transparent_hook_arguments(agent, gateway_url), + windows, ) } +#[cfg(test)] pub(crate) fn persistent_hook_forward_command_for_platform( relay: &Path, agent: CodingAgent, + generation_file: &Path, + generation_token: &str, windows: bool, ) -> String { - format!( - "{} hook-forward {} --gateway-url {}", - crate::plugin_host::shell_quote_for_platform(relay, windows), - agent.as_arg(), - crate::plugin_host::shell_quote_arg_for_platform(crate::sidecar::DEFAULT_URL, windows) + hook_command_for_platform( + relay, + &persistent_hook_arguments(agent, generation_file, generation_token), + windows, ) } -fn claude_hooks(command: &str) -> Value { - hooks_for_events(CLAUDE_HOOK_EVENTS, command, true) +fn transparent_hook_arguments(agent: CodingAgent, gateway_url: &str) -> Vec { + vec![ + "hook-forward".into(), + agent.as_arg().into(), + "--gateway-url".into(), + gateway_url.into(), + "--transparent-run".into(), + ] +} + +fn persistent_hook_arguments( + agent: CodingAgent, + generation_file: &Path, + generation_token: &str, +) -> Vec { + vec![ + "hook-forward".into(), + agent.as_arg().into(), + "--gateway-url".into(), + crate::sidecar::DEFAULT_URL.into(), + "--generation-file".into(), + generation_file.display().to_string(), + "--generation-token".into(), + generation_token.into(), + ] +} + +fn hook_command(relay: &Path, arguments: &[String]) -> Result { + #[cfg(windows)] + { + return encoded_windows_hook_command(&windows_powershell_launcher()?, relay, arguments); + } + #[cfg(not(windows))] + { + Ok(posix_hook_command(relay, arguments)) + } +} + +#[cfg(test)] +fn hook_command_for_platform(relay: &Path, arguments: &[String], windows: bool) -> String { + if windows { + return encoded_windows_hook_command( + "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", + relay, + arguments, + ) + .expect("test hook command must fit within the Windows command-line limit"); + } + posix_hook_command(relay, arguments) +} + +#[cfg(any(not(windows), test))] +fn posix_hook_command(relay: &Path, arguments: &[String]) -> String { + std::iter::once(relay.display().to_string()) + .chain(arguments.iter().cloned()) + .map(|argument| crate::plugin_host::shell_quote_arg_for_platform(&argument, false)) + .collect::>() + .join(" ") +} + +// `cmd.exe` accepts at most 8,191 characters. Leave room for `/C` and the executable path added +// by the hook host instead of generating a command that will be truncated at runtime. +const MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS: usize = 8_000; + +/// Encode a native Relay invocation so Windows hook hosts can pass it through `cmd.exe /C` as one +/// argument without corrupting quotes in canonical paths. Windows PowerShell is part of the +/// supported Windows platform; it only launches the Rust binary and preserves its standard I/O. +#[cfg(any(windows, test))] +fn encoded_windows_hook_command( + powershell: &str, + relay: &Path, + arguments: &[String], +) -> Result { + const PREFIX: &str = "$ErrorActionPreference='Stop'; & "; + const SUFFIX: &str = "; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE"; + + let invocation = std::iter::once(relay.display().to_string()) + .chain(arguments.iter().cloned()) + .map(|argument| format!("'{}'", argument.replace('\'', "''"))) + .collect::>() + .join(" "); + let script = format!("{PREFIX}{invocation}{SUFFIX}"); + let bytes = script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); + let command = + format!("{powershell} -NoLogo -NoProfile -NonInteractive -EncodedCommand {encoded}"); + if command.encode_utf16().count() > MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS { + return Err(format!( + "generated Windows coding-agent hook command exceeds the {MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS}-character safety limit; shorten the Relay or plugin installation path" + )); + } + Ok(command) } -fn codex_hooks(command: &str) -> Value { - hooks_for_events(CODEX_HOOK_EVENTS, command, true) +#[cfg(windows)] +fn windows_powershell_launcher() -> Result { + let powershell = windows_powershell_path()?; + if !Path::new(&powershell).is_file() { + return Err(format!( + "trusted Windows PowerShell launcher is missing at {powershell}; install Windows PowerShell before configuring coding-agent hooks" + )); + } + Ok(powershell) } -// Generates Hermes YAML-compatible hook groups. Hermes expects direct command entries rather than -// the nested `type = command` group format used by Claude and Codex. -pub(crate) fn hermes_hooks(command: &str) -> Value { - let hooks: serde_json::Map = HERMES_HOOK_EVENTS +#[cfg(windows)] +fn windows_powershell_path() -> Result { + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW; + + let mut buffer = vec![0_u16; 260]; + let length = loop { + // SAFETY: `buffer` is writable for its declared length and remains live for the call. + let length = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) }; + if length == 0 { + return Err(format!( + "failed to resolve the trusted Windows system directory: {}", + std::io::Error::last_os_error() + )); + } + if (length as usize) < buffer.len() { + break length as usize; + } + buffer.resize(length as usize + 1, 0); + }; + let system = std::path::PathBuf::from(std::ffi::OsString::from_wide(&buffer[..length])); + let powershell = system.join("WindowsPowerShell/v1.0/powershell.exe"); + let powershell = powershell + .into_os_string() + .into_string() + .map_err(|_| "trusted Windows PowerShell path is not valid Unicode".to_string())? + .replace('\\', "/"); + if !safe_windows_launcher_token(&powershell) { + return Err(format!( + "trusted Windows PowerShell path {powershell} contains characters that cannot be represented safely in coding-agent hook commands" + )); + } + Ok(powershell) +} + +fn safe_windows_launcher_token(launcher: &str) -> bool { + !launcher.is_empty() + && launcher.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '/' | ':' | '.' | '_' | '-') + }) + && launcher + .to_ascii_lowercase() + .ends_with("/system32/windowspowershell/v1.0/powershell.exe") +} + +/// Decode only the exact PowerShell envelope emitted by [`encoded_windows_hook_command`]. +/// +/// Hermes uses this to migrate and replace Relay-owned hooks whose generation arguments change. +pub(crate) fn decode_windows_hook_command(command: &str) -> Option> { + const COMMAND_SEPARATOR: &str = " -NoLogo -NoProfile -NonInteractive -EncodedCommand "; + const SCRIPT_PREFIX: &str = "$ErrorActionPreference='Stop'; & "; + const SCRIPT_SUFFIX: &str = "; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE"; + + if command.encode_utf16().count() > MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS { + return None; + } + let (launcher, encoded) = command.split_once(COMMAND_SEPARATOR)?; + if !safe_windows_launcher_token(launcher) { + return None; + } + #[cfg(windows)] + if !launcher.eq_ignore_ascii_case(&windows_powershell_path().ok()?) { + return None; + } + if encoded.is_empty() || encoded.chars().any(char::is_whitespace) { + return None; + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + let pairs = bytes.chunks_exact(2); + if !pairs.remainder().is_empty() { + return None; + } + let script = String::from_utf16( + &pairs + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(), + ) + .ok()?; + let invocation = script + .strip_prefix(SCRIPT_PREFIX)? + .strip_suffix(SCRIPT_SUFFIX)?; + parse_powershell_single_quoted_arguments(invocation) +} + +fn parse_powershell_single_quoted_arguments(mut raw: &str) -> Option> { + let mut arguments = Vec::new(); + while !raw.is_empty() { + raw = raw.strip_prefix('\'')?; + let mut argument = String::new(); + loop { + let quote = raw.find('\'')?; + argument.push_str(&raw[..quote]); + raw = &raw[quote + 1..]; + if let Some(rest) = raw.strip_prefix('\'') { + argument.push('\''); + raw = rest; + } else { + break; + } + } + arguments.push(argument); + if raw.is_empty() { + break; + } + raw = raw.strip_prefix(' ')?; + if raw.is_empty() { + return None; + } + } + (!arguments.is_empty()).then_some(arguments) +} + +fn direct_hooks(events: &[&str], command: &str) -> Value { + let hooks: serde_json::Map = events .iter() .map(|event| { ( @@ -389,12 +930,12 @@ pub(crate) fn hermes_hooks(command: &str) -> Value { // Generates hook groups for Claude/Codex events and adds a wildcard matcher to tool events when // the target agent requires matcher-scoped tool hooks. Non-tool events omit matchers so they fire // for the full lifecycle. -fn hooks_for_events(events: &[&str], command: &str, matcher_for_tools: bool) -> Value { +fn grouped_hooks(events: &[&str], command: &str) -> Value { let hooks: serde_json::Map = events .iter() .map(|event| { let mut group = serde_json::Map::new(); - if matcher_for_tools && event_matches_tools(event) { + if event_matches_tools(event) { group.insert("matcher".into(), json!("*")); } group.insert( diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index 955a04e97..3c33356b2 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -9,15 +9,15 @@ use nemo_relay::observability::plugin_component::{ AtifStorageConfig, OBSERVABILITY_PLUGIN_KIND, ObservabilityConfig, }; use nemo_relay::plugin::PluginConfig; -use reqwest::Client; use serde_json::{Value, json}; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio::task::JoinHandle; use crate::config::{ - AgentConfigs, CodingAgent, EasyPathCommand, GatewayConfig, ResolvedConfig, RunCommand, - ServerArgs, any_config_file_exists, resolve_run_config, + AgentConfigs, CodingAgent, EasyPathCommand, GatewayConfig, RELAY_PLUGIN_ID, + RELAY_SOURCE_PLUGIN_ID, ResolvedConfig, RunCommand, ServerArgs, any_config_file_exists, + resolve_run_config, }; use crate::error::CliError; use crate::installer::{generated_hooks, transparent_hook_forward_command}; @@ -110,9 +110,13 @@ impl TransparentRun { } else { crate::plugins::lifecycle::active_dynamic_plugin_components(explicit_config, &resolved)? }; - let (agent, argv) = resolve_agent_and_argv(&command, &resolved.agents)?; + let invocation = resolve_agent_invocation(&command, &resolved.agents)?; + let agent = invocation.agent; if !dry_run { - let probe = crate::agent_process::version_probe_argv(agent, &argv); + let probe = crate::agent_process::version_probe_argv( + agent, + &invocation.argv[..=invocation.host_index], + ); validate_agent_version(agent, &probe).await?; } let listener = TcpListener::bind("127.0.0.1:0").await?; @@ -120,7 +124,7 @@ impl TransparentRun { let gateway_url = format!("http://{address}"); resolved.gateway.bind = address; - let prepared = PreparedRun::new(agent, argv, &gateway_url, &resolved, dry_run)?; + let prepared = PreparedRun::from_invocation(invocation, &gateway_url, &resolved, dry_run)?; Ok(Self { agent, prepared, @@ -179,53 +183,114 @@ async fn execute_live_run_with_dynamic( gateway_url: &str, prepared: PreparedRun, ) -> Result { - let running_server = RunningGateway::start(listener, gateway_config, dynamic_plugins); - if let Err(error) = wait_for_health(gateway_url).await { + let bootstrap_fingerprint = crate::config::transparent_gateway_fingerprint(gateway_url); + let running_server = RunningGateway::start( + listener, + gateway_config, + dynamic_plugins, + bootstrap_fingerprint.clone(), + ); + if let Err(error) = wait_for_health(gateway_url, &bootstrap_fingerprint).await { let restore = prepared.restore(); let server_result = running_server.stop().await; restore?; server_result?; return Err(error); } - let status = prepared.spawn_and_wait().await; - let restore = prepared.restore(); - let server_result = running_server.stop().await; - restore?; - server_result?; + supervise_prepared_run(&prepared, running_server).await +} + +async fn supervise_prepared_run( + prepared: &PreparedRun, + mut running_server: RunningGateway, +) -> Result { + let mut child = match prepared.spawn().await { + Ok(child) => child, + Err(error) => { + let restore = prepared.restore(); + let server_result = running_server.stop().await; + restore?; + server_result?; + return Err(error); + } + }; - Ok(exit_code(status?)) + tokio::select! { + status = child.wait() => { + let restore = prepared.restore(); + let server_result = running_server.stop().await; + restore?; + server_result?; + Ok(exit_code(status?)) + } + gateway_result = running_server.wait() => { + let child_result = child.terminate().await; + let restore = prepared.restore(); + restore?; + child_result?; + match gateway_result { + Err(error) => Err(error), + Ok(()) => Err(CliError::Launch( + "transparent Relay gateway stopped before the coding agent exited".into(), + )), + } + } + } } // Resolves the launched agent and argv from either an explicit command or a configured per-agent // command. Agent inference only happens from argv[0] when `--agent` was omitted, so explicit agent // selection can wrap commands whose executable name is not recognizable. -fn resolve_agent_and_argv( - command: &RunCommand, - agents: &AgentConfigs, -) -> Result<(CodingAgent, Vec), CliError> { - let argv = resolved_argv(command, agents)?; - let agent = resolved_agent(command, &argv)?; - Ok((agent, argv)) +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentInvocation { + agent: CodingAgent, + argv: Vec, + host_index: usize, } -// Resolves the full argv to spawn. When `--agent` is set (the easy-path and explicit `--agent` -// flows both go through this case), the configured agent command is the base argv and anything -// after `--` is appended as pass-through args. When `--agent` is absent, `command.command` IS -// the full argv (e.g., `nemo-relay run -- codex --model X` runs that exact command and infers -// the agent from argv[0]). -fn resolved_argv(command: &RunCommand, agents: &AgentConfigs) -> Result, CliError> { +fn resolve_agent_invocation( + command: &RunCommand, + agents: &AgentConfigs, +) -> Result { if let Some(agent) = command.agent { let mut argv = configured_command(agent, agents) .unwrap_or_else(|| vec![default_command_for(agent).to_string()]); + let host_index = argv + .iter() + .rposition(|argument| CodingAgent::infer(argument) == Some(agent)) + .unwrap_or(0); argv.extend(command.command.iter().cloned()); - return Ok(argv); + return Ok(AgentInvocation { + agent, + argv, + host_index, + }); } if command.command.is_empty() { return Err(CliError::Launch( "missing command; pass -- or --agent with a configured command".into(), )); } - Ok(command.command.clone()) + let argv = command.command.clone(); + let agent = CodingAgent::infer(&argv[0]).ok_or_else(|| { + CliError::Launch(format!( + "could not infer coding agent from command {:?}; pass --agent claude, --agent codex, or --agent hermes", + argv[0] + )) + })?; + Ok(AgentInvocation { + agent, + argv, + host_index: 0, + }) +} + +#[cfg(test)] +fn resolve_agent_and_argv( + command: &RunCommand, + agents: &AgentConfigs, +) -> Result<(CodingAgent, Vec), CliError> { + resolve_agent_invocation(command, agents).map(|invocation| (invocation.agent, invocation.argv)) } // Default agent binary names used when no `[agents.] command = "..."` override is in the @@ -273,20 +338,6 @@ async fn validate_agent_version(agent: CodingAgent, probe: &[String]) -> Result< .map_err(CliError::Launch) } -// Uses an explicit `--agent` when present and otherwise infers the agent from argv[0]. Inference is -// intentionally late so configured commands and direct CLI commands share the same validation path. -fn resolved_agent(command: &RunCommand, argv: &[String]) -> Result { - if let Some(agent) = command.agent { - return Ok(agent); - } - CodingAgent::infer(&argv[0]).ok_or_else(|| { - CliError::Launch(format!( - "could not infer coding agent from command {:?}; pass --agent claude, --agent codex, or --agent hermes", - argv[0] - )) - }) -} - // Splits a configured command string into argv words for run mode. This intentionally uses simple // whitespace splitting because config command values are a convenience fallback; complex shell // commands should be passed after `--` by the caller. @@ -298,6 +349,7 @@ fn configured_command(agent: CodingAgent, agents: &AgentConfigs) -> Option, + host_index: usize, env: Vec<(String, String)>, temp_dirs: Vec, notes: Vec, @@ -315,13 +367,15 @@ impl RunningGateway { listener: TcpListener, config: crate::config::GatewayConfig, dynamic_plugins: Vec, + bootstrap_fingerprint: String, ) -> Self { let (shutdown_tx, shutdown_rx) = oneshot::channel(); let task = tokio::spawn(async move { - server::serve_listener_with_dynamic( + server::serve_transparent_listener_with_dynamic( listener, config, dynamic_plugins, + bootstrap_fingerprint, Some(shutdown_rx), ) .await @@ -329,6 +383,12 @@ impl RunningGateway { Self { shutdown_tx, task } } + async fn wait(&mut self) -> Result<(), CliError> { + (&mut self.task) + .await + .map_err(|error| CliError::Launch(format!("gateway task failed: {error}")))? + } + // Requests shutdown and joins the server task. The send can fail only if the task already exited; // the join result still captures whether serving ended cleanly. async fn stop(self) -> Result<(), CliError> { @@ -340,19 +400,59 @@ impl RunningGateway { } impl PreparedRun { + fn from_invocation( + invocation: AgentInvocation, + gateway_url: &str, + resolved: &ResolvedConfig, + dry_run: bool, + ) -> Result { + Self::build( + invocation.agent, + invocation.argv, + invocation.host_index, + gateway_url, + resolved, + dry_run, + ) + } + + #[cfg(test)] + fn new( + agent: CodingAgent, + argv: Vec, + gateway_url: &str, + resolved: &ResolvedConfig, + dry_run: bool, + ) -> Result { + let boundary = argv + .iter() + .position(|argument| argument == "--") + .unwrap_or(argv.len()); + let host_index = argv[..boundary] + .iter() + .rposition(|argument| CodingAgent::infer(argument) == Some(agent)) + .unwrap_or(0); + Self::build(agent, argv, host_index, gateway_url, resolved, dry_run) + } + // Builds the launch plan and applies only the preparation needed by the selected agent. // Dry-run preparation records equivalent notes and argv/env changes without writing temporary // hook files or patching user/project configuration. - fn new( + fn build( agent: CodingAgent, argv: Vec, + host_index: usize, gateway_url: &str, resolved: &ResolvedConfig, dry_run: bool, ) -> Result { let mut run = Self { argv, - env: vec![("NEMO_RELAY_GATEWAY_URL".into(), gateway_url.into())], + host_index, + env: vec![ + (crate::config::GATEWAY_URL_ENV.into(), gateway_url.into()), + (crate::config::TRANSPARENT_RUN_ENV.into(), "1".into()), + ], temp_dirs: Vec::new(), notes: Vec::new(), }; @@ -362,12 +462,12 @@ impl PreparedRun { match agent { CodingAgent::ClaudeCode => { if dry_run { - run.prepare_claude_dry(gateway_url); + run.prepare_claude_dry(gateway_url)?; } else { run.prepare_claude(gateway_url)?; } } - CodingAgent::Codex => run.prepare_codex(gateway_url), + CodingAgent::Codex => run.prepare_codex(gateway_url)?, CodingAgent::Hermes => { if dry_run { run.prepare_hermes_dry(resolved.agents.hermes.hooks_path.as_deref())?; @@ -381,23 +481,27 @@ impl PreparedRun { // Records the Claude Code argv/env changes that would be made during a real run. The temporary // plugin path is symbolic so printed dry-run output is deterministic and non-mutating. - fn prepare_claude_dry(&mut self, gateway_url: &str) { - insert_after_agent( + fn prepare_claude_dry(&mut self, gateway_url: &str) -> Result<(), CliError> { + insert_after_host( &mut self.argv, - CodingAgent::ClaudeCode, + self.host_index, [ "--plugin-dir".into(), "".into(), + "--settings".into(), + "".into(), ], ); self.env .push(("ANTHROPIC_BASE_URL".into(), gateway_url.to_string())); self.notes .push("would generate a temporary Claude Code plugin directory".into()); + Ok(()) } - // Creates a temporary Claude Code plugin containing gateway hooks and points Claude at both - // that plugin directory and the gateway Anthropic-compatible gateway URL. + // Creates a temporary Claude Code plugin containing gateway hooks and a process-private + // settings overlay. Claude applies the first `--settings` argument, so the overlay preserves + // the caller's first explicit settings source while overriding only the gateway URL. fn prepare_claude(&mut self, gateway_url: &str) -> Result<(), CliError> { let root = temp_dir("nemo-relay-claude-plugin")?; std::fs::create_dir_all(root.join(".claude-plugin"))?; @@ -411,20 +515,31 @@ impl PreparedRun { })) .map_err(|error| CliError::Launch(error.to_string()))?, )?; + let hook_command = transparent_hook_forward_command( + &transparent_hook_executable(), + CodingAgent::ClaudeCode, + gateway_url, + ) + .map_err(CliError::Launch)?; write_hooks( &root.join("hooks/hooks.json"), - generated_hooks( - CodingAgent::ClaudeCode, - &transparent_hook_forward_command( - &transparent_hook_executable(), - CodingAgent::ClaudeCode, - ), - ), + generated_hooks(CodingAgent::ClaudeCode, &hook_command), )?; - insert_after_agent( + let settings_path = root.join("settings.json"); + let settings = claude_settings_overlay(&self.argv, self.host_index, gateway_url)?; + let settings_bytes = serde_json::to_vec_pretty(&settings) + .map_err(|error| CliError::Launch(error.to_string()))?; + crate::file_io::atomic_write_private(&settings_path, &settings_bytes) + .map_err(CliError::Launch)?; + insert_after_host( &mut self.argv, - CodingAgent::ClaudeCode, - ["--plugin-dir".into(), root.display().to_string()], + self.host_index, + [ + "--plugin-dir".into(), + root.display().to_string(), + "--settings".into(), + settings_path.display().to_string(), + ], ); self.env .push(("ANTHROPIC_BASE_URL".into(), gateway_url.to_string())); @@ -436,7 +551,7 @@ impl PreparedRun { // reserves built-in provider IDs, so run mode installs a temporary provider alias instead of // overriding `model_providers.openai`. Uses `features.hooks=true` introduced in codex-cli // current supported Codex releases. The centralized host policy validates the version first. - fn prepare_codex(&mut self, gateway_url: &str) { + fn prepare_codex(&mut self, gateway_url: &str) -> Result<(), CliError> { // Codex resolves auth via `CodexAuth::from_auth_dot_json` (`codex-rs/login/src/auth/ // manager.rs`): `auth_mode=ApiKey` uses `OPENAI_API_KEY`, `auth_mode=Chatgpt` uses the // OAuth token from `~/.codex/auth.json`. With `requires_openai_auth=true` the provider @@ -464,8 +579,13 @@ impl PreparedRun { or pass `--openai-base-url` to an upstream that needs no key." ); } - let hook_command = - transparent_hook_forward_command(&transparent_hook_executable(), CodingAgent::Codex); + let hook_command = transparent_hook_forward_command( + &transparent_hook_executable(), + CodingAgent::Codex, + gateway_url, + ) + .map_err(CliError::Launch)?; + let hook_groups = generated_hooks(CodingAgent::Codex, &hook_command); let mut args = vec![ "--config".to_string(), "features.hooks=true".to_string(), @@ -474,15 +594,14 @@ impl PreparedRun { "--config".to_string(), codex_gateway_provider_config(gateway_url), ]; - for (event, groups) in generated_hooks(CodingAgent::Codex, &hook_command)["hooks"] - .as_object() - .into_iter() - .flatten() - { + for (event, groups) in hook_groups["hooks"].as_object().into_iter().flatten() { args.push("--config".to_string()); args.push(format!("hooks.{event}={}", hook_groups_toml(groups))); } - insert_after_agent(&mut self.argv, CodingAgent::Codex, args); + args.push("--config".to_string()); + args.push(codex_session_hook_state_override(&hook_groups)?); + insert_after_host(&mut self.argv, self.host_index, args); + Ok(()) } // Hermes discovers hooks from `.hermes/config.yaml` instead of command-line flags. A @@ -495,7 +614,14 @@ impl PreparedRun { source_config.display() )) })?; - let overlay_home = create_hermes_overlay(source_home, &source_config)?; + let gateway_url = self + .env + .iter() + .find_map(|(name, value)| { + (name == crate::config::GATEWAY_URL_ENV).then_some(value.as_str()) + }) + .expect("transparent runs always define their gateway URL"); + let overlay_home = create_hermes_overlay(source_home, &source_config, gateway_url)?; self.env.push(("HERMES_ACCEPT_HOOKS".into(), "1".into())); self.env .push(("HERMES_HOME".into(), overlay_home.display().to_string())); @@ -519,15 +645,16 @@ impl PreparedRun { Ok(()) } - // Spawns the prepared child process with injected environment and waits for its exit status. + // Spawns the prepared child process with injected environment. // Stdio is inherited by default so agent interaction remains unchanged in transparent mode. - async fn spawn_and_wait(&self) -> Result { + async fn spawn(&self) -> Result { let mut command = crate::agent_process::tokio_command(&self.argv); for (name, value) in &self.env { command.env(name, value); } - let mut child = command.spawn()?; - child.wait().await.map_err(CliError::from) + crate::agent_process::SupervisedChild::spawn(&mut command) + .await + .map_err(CliError::from) } // Removes process-private plugin and configuration directories after the child exits. @@ -625,6 +752,225 @@ impl PreparedRun { } } +// Claude Code honors only the first `--settings` source. Preserve that source in the generated +// overlay so inserting Relay's process-private gateway setting cannot discard user configuration. +fn claude_settings_overlay( + argv: &[String], + host_index: usize, + gateway_url: &str, +) -> Result { + let mut settings = match first_claude_settings(argv, host_index)? { + Some(source) => read_claude_settings(source)?, + None => json!({}), + }; + let object = settings.as_object_mut().ok_or_else(|| { + CliError::Launch("Claude Code --settings must contain a JSON object".into()) + })?; + let environment = object.entry("env").or_insert_with(|| json!({})); + let environment = environment.as_object_mut().ok_or_else(|| { + CliError::Launch("Claude Code --settings field `env` must be a JSON object".into()) + })?; + environment.insert( + "ANTHROPIC_BASE_URL".into(), + Value::String(gateway_url.into()), + ); + Ok(settings) +} + +fn first_claude_settings(argv: &[String], host_index: usize) -> Result, CliError> { + let boundary = argv + .iter() + .skip(host_index + 1) + .position(|argument| argument == "--") + .map_or(argv.len(), |offset| host_index + 1 + offset); + let mut index = host_index + 1; + while index < boundary { + if argv[index] == "--settings" { + if index + 1 >= boundary || argv[index + 1].is_empty() { + return Err(CliError::Launch( + "Claude Code --settings is missing its value".into(), + )); + } + return Ok(Some(argv[index + 1].as_str())); + } + if let Some(source) = argv[index].strip_prefix("--settings=") { + if source.is_empty() { + return Err(CliError::Launch( + "Claude Code --settings is missing its value".into(), + )); + } + return Ok(Some(source)); + } + index += 1; + } + Ok(None) +} + +fn read_claude_settings(source: &str) -> Result { + let raw = if source.trim_start().starts_with('{') { + source.to_string() + } else { + std::fs::read_to_string(source).map_err(|error| { + CliError::Launch(format!( + "failed to read Claude Code settings {}: {error}", + Path::new(source).display() + )) + })? + }; + serde_json::from_str(&raw).map_err(|error| { + CliError::Launch(format!( + "failed to parse Claude Code --settings JSON: {error}" + )) + }) +} + +// Session hook definitions and their exact trust state share Codex's process-local CLI layer. This +// authorizes only the generated Relay command without rewriting the active user profile or using +// the process-wide hook-trust bypass. +fn codex_session_hook_state_override(generated: &Value) -> Result { + let events = generated + .get("hooks") + .and_then(Value::as_object) + .ok_or_else(|| CliError::Launch("generated Codex hooks were malformed".into()))?; + let mut states = Vec::new(); + for (event, groups) in events { + let groups = groups.as_array().ok_or_else(|| { + CliError::Launch(format!( + "generated Codex {event} hook groups were malformed" + )) + })?; + let event_key = codex_hook_event_key(event); + for (group_index, group) in groups.iter().enumerate() { + let group = group.as_object().ok_or_else(|| { + CliError::Launch(format!("generated Codex {event} hook group was malformed")) + })?; + let handlers = group + .get("hooks") + .and_then(Value::as_array) + .ok_or_else(|| { + CliError::Launch(format!( + "generated Codex {event} hook handlers were malformed" + )) + })?; + for (handler_index, handler) in handlers.iter().enumerate() { + let hash = codex_command_hook_hash(&event_key, group, handler)?; + let key = format!( + "//config.toml:{event_key}:{group_index}:{handler_index}" + ); + states.push(format!( + "{}={{trusted_hash={},enabled=true}}", + toml_string(&key), + toml_string(&hash) + )); + for plugin_id in [RELAY_PLUGIN_ID, RELAY_SOURCE_PLUGIN_ID] { + let key = format!( + "{plugin_id}:hooks/hooks.json:{event_key}:{group_index}:{handler_index}" + ); + states.push(format!("{}={{enabled=false}}", toml_string(&key))); + } + } + } + } + Ok(format!("hooks.state={{{}}}", states.join(","))) +} + +fn codex_hook_event_key(event: &str) -> String { + let mut normalized = String::with_capacity(event.len() + 2); + for (index, character) in event.chars().enumerate() { + if character.is_ascii_uppercase() { + if index > 0 { + normalized.push('_'); + } + normalized.push(character.to_ascii_lowercase()); + } else { + normalized.push(character); + } + } + normalized +} + +fn codex_command_hook_hash( + event_key: &str, + group: &serde_json::Map, + handler: &Value, +) -> Result { + use sha2::{Digest, Sha256}; + + let handler = handler.as_object().ok_or_else(|| { + CliError::Launch(format!( + "generated Codex {event_key} command hook was malformed" + )) + })?; + if handler.get("type").and_then(Value::as_str) != Some("command") { + return Err(CliError::Launch(format!( + "generated Codex {event_key} hook was not a command" + ))); + } + let command = handler + .get(if cfg!(windows) { + "commandWindows" + } else { + "command" + }) + .or_else(|| handler.get("command")) + .and_then(Value::as_str) + .ok_or_else(|| { + CliError::Launch(format!( + "generated Codex {event_key} hook command was missing" + )) + })?; + let timeout = handler + .get("timeout") + .and_then(Value::as_u64) + .unwrap_or(600) + .max(1); + let mut normalized_handler = serde_json::Map::new(); + normalized_handler.insert("type".into(), Value::String("command".into())); + normalized_handler.insert("command".into(), Value::String(command.into())); + normalized_handler.insert("timeout".into(), Value::Number(timeout.into())); + normalized_handler.insert("async".into(), Value::Bool(false)); + if let Some(status) = handler.get("statusMessage").and_then(Value::as_str) { + normalized_handler.insert("statusMessage".into(), Value::String(status.into())); + } + let mut identity = serde_json::Map::new(); + identity.insert("event_name".into(), Value::String(event_key.into())); + if let Some(matcher) = group.get("matcher").and_then(Value::as_str) { + identity.insert("matcher".into(), Value::String(matcher.into())); + } + identity.insert( + "hooks".into(), + Value::Array(vec![Value::Object(normalized_handler)]), + ); + let canonical = canonical_json(Value::Object(identity)); + let bytes = serde_json::to_vec(&canonical) + .map_err(|error| CliError::Launch(format!("failed to hash Codex hook: {error}")))?; + let digest = Sha256::digest(bytes); + Ok(format!( + "sha256:{}", + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + )) +} + +fn canonical_json(value: Value) -> Value { + match value { + Value::Object(object) => { + let mut entries = object.into_iter().collect::>(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + Value::Object( + entries + .into_iter() + .map(|(key, value)| (key, canonical_json(value))) + .collect(), + ) + } + Value::Array(values) => Value::Array(values.into_iter().map(canonical_json).collect()), + other => other, + } +} + /// Renders a bordered status frame for daemon and transparent-run startup output. pub(crate) fn render_status_frame(lines: &[String], color: bool) -> String { let max_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0); @@ -771,19 +1117,23 @@ fn exit_code(status: std::process::ExitStatus) -> ExitCode { // Polls the ephemeral gateway health endpoint for roughly one second before launching the agent. // Startup failures return a launcher error so the child command is never run against a dead proxy. -async fn wait_for_health(gateway_url: &str) -> Result<(), CliError> { - let client = Client::new(); - let url = format!("{}/healthz", gateway_url.trim_end_matches('/')); +async fn wait_for_health(gateway_url: &str, bootstrap_fingerprint: &str) -> Result<(), CliError> { for _ in 0..50 { - if let Ok(response) = client.get(&url).send().await - && response.status().is_success() + let gateway_url = gateway_url.to_string(); + let bootstrap_fingerprint = bootstrap_fingerprint.to_string(); + if tokio::task::spawn_blocking(move || { + crate::sidecar::healthz_compatible(&gateway_url, &bootstrap_fingerprint) + }) + .await + .map_err(|error| CliError::Launch(format!("gateway readiness task failed: {error}")))? { return Ok(()); } tokio::time::sleep(Duration::from_millis(20)).await; } Err(CliError::Launch(format!( - "gateway did not become ready at {url}" + "gateway did not become ready at {}/healthz", + gateway_url.trim_end_matches('/') ))) } @@ -859,21 +1209,16 @@ fn path_with_transparent_hook_dir() -> Option { .map(|path| path.to_string_lossy().into_owned()) } -// Inserts generated agent flags immediately after the last argv element that looks like the agent -// executable. Falling back to index 0 keeps wrapper commands usable by inserting after the first -// word when the agent cannot be found later in argv. -fn insert_after_agent( +// The invocation resolver determines this index before pass-through arguments are appended. Using +// it here prevents a prompt token named `codex` or `claude` from becoming an accidental insertion +// target while preserving configured wrapper prefixes. +fn insert_after_host( argv: &mut Vec, - agent: CodingAgent, + host_index: usize, args: impl IntoIterator, ) { - let index = argv - .iter() - .enumerate() - .filter_map(|(index, arg)| (CodingAgent::infer(arg) == Some(agent)).then_some(index)) - .next_back() - .unwrap_or(0); - argv.splice(index + 1..index + 1, args); + debug_assert!(host_index < argv.len()); + argv.splice(host_index + 1..host_index + 1, args); } // Writes pretty JSON hook config to a path whose parent has already been created by the caller. @@ -889,7 +1234,11 @@ fn write_hooks(path: &Path, hooks: Value) -> Result<(), CliError> { // Creates a per-process Hermes home whose user state points at the original profile while the // config and hook approval files remain private to this transparent run. Hermes has no standalone // config-file override, so `HERMES_HOME` is its supported process-scoped configuration boundary. -fn create_hermes_overlay(source_home: &Path, source_config: &Path) -> Result { +fn create_hermes_overlay( + source_home: &Path, + source_config: &Path, + gateway_url: &str, +) -> Result { // Prefer a sibling of HERMES_HOME so Windows file hard links remain on one volume. Fall back // to the OS temp directory when the profile parent is not writable; regular files then use a // copy fallback, while profile directories remain live through junctions. @@ -899,7 +1248,7 @@ fn create_hermes_overlay(source_home: &Path, source_config: &Path) -> Result Result<(), CliError> { let absolute_overlay = overlay .canonicalize() @@ -942,7 +1292,7 @@ fn populate_hermes_overlay( .map(|path| path.canonicalize().unwrap_or(path)) .map(crate::plugin_host::portable_executable_path) .unwrap_or_else(|_| PathBuf::from("nemo-relay")); - let contents = crate::hermes::transparent_config(&existing, &relay)?; + let contents = crate::hermes::transparent_config(&existing, &relay, gateway_url)?; std::fs::write(overlay.join("config.yaml"), contents)?; Ok(()) } @@ -978,22 +1328,23 @@ fn link_hermes_state(source: &Path, destination: &Path, directory: bool) -> Resu #[cfg(windows)] fn create_windows_junction(source: &Path, destination: &Path) -> Result<(), CliError> { + use std::os::windows::process::CommandExt; + // Directory junctions do not require Developer Mode or SeCreateSymbolicLinkPrivilege. Paths // travel through environment variables so the fixed cmd program never interpolates user // content into shell syntax; delayed expansion is disabled for literal exclamation marks. - let status = std::process::Command::new( + let mut command = std::process::Command::new( std::env::var_os("COMSPEC").unwrap_or_else(|| std::ffi::OsString::from("cmd.exe")), - ) - .args([ - "/d", - "/v:off", - "/s", - "/c", - "mklink /J \"%NEMO_RELAY_JUNCTION_DEST%\" \"%NEMO_RELAY_JUNCTION_SOURCE%\" >nul", - ]) - .env("NEMO_RELAY_JUNCTION_SOURCE", source) - .env("NEMO_RELAY_JUNCTION_DEST", destination) - .status()?; + ); + command.args(["/d", "/e:on", "/v:off", "/s", "/c"]); + // `cmd.exe` parses the command after `/c` itself rather than with the Windows CRT rules used + // by `Command::arg`. The outer quote pair is required so the inner path quotes survive `/s`. + command + .raw_arg(r#""mklink /J "%NEMO_RELAY_JUNCTION_DEST%" "%NEMO_RELAY_JUNCTION_SOURCE%" >nul""#); + let status = command + .env("NEMO_RELAY_JUNCTION_SOURCE", source) + .env("NEMO_RELAY_JUNCTION_DEST", destination) + .status()?; if status.success() { Ok(()) } else { @@ -1055,12 +1406,15 @@ fn temp_dir(prefix: &str) -> Result { fn private_temp_dir(parent: &Path, prefix: &str) -> Result { let path = parent.join(format!("{prefix}-{}", uuid::Uuid::now_v7())); - let mut builder = std::fs::DirBuilder::new(); #[cfg(unix)] - { + let builder = { use std::os::unix::fs::DirBuilderExt; + let mut builder = std::fs::DirBuilder::new(); builder.mode(0o700); - } + builder + }; + #[cfg(not(unix))] + let builder = std::fs::DirBuilder::new(); builder.create(&path)?; Ok(path) } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index c6fe9ac67..712ffd5ed 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -31,6 +31,10 @@ mod session; mod setup; mod sidecar; +#[cfg(test)] +#[path = "../tests/coverage/hook_assertions.rs"] +mod hook_assertions; + use std::process::ExitCode; use clap::Parser; @@ -40,11 +44,28 @@ use crate::config::{ PluginsSubcommand, PricingCommand, PricingSubcommand, ServerArgs, }; -#[tokio::main] +fn main() -> ExitCode { + // Managed MCP hosts can preserve an unresolved `${NAME}` placeholder. Remove those values + // before clap reads environment-backed numeric and socket options, and before Tokio creates + // worker threads that would make process-environment mutation unsafe. + mcp_environment::remove_unresolved_mcp_placeholders(); + let runtime = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + eprintln!("failed to initialize async runtime: {error}"); + return ExitCode::FAILURE; + } + }; + runtime.block_on(async_main()) +} + // Runs the async CLI entrypoint and converts any surfaced gateway error into a non-zero process // exit. Errors are printed once here so subcommands can return structured errors without also // owning process-level reporting. -async fn main() -> ExitCode { +async fn async_main() -> ExitCode { if let Err(error) = sidecar::join_sidecar_job_from_env() { eprintln!("{error}"); return ExitCode::FAILURE; @@ -91,7 +112,7 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result { launcher::easy_path(CodingAgent::Hermes, command, Some(server)).await } - Command::Mcp(command) => mcp::run(command.agent, server).await, + Command::Mcp => mcp::run(server).await, Command::Config(command) => run_config(command).await, Command::Plugins(command) => run_plugins(command, server), Command::ModelPricing(command) => run_pricing(command), diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs index 1bfa5bacc..8225fd2e9 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -9,19 +9,40 @@ mod session; mod transport; use std::net::SocketAddr; +use std::path::Path; use std::process::ExitCode; -use crate::config::{CodingAgent, ServerArgs}; +use serde_json::{Value, json}; + +use crate::config::ServerArgs; use crate::error::CliError; +use crate::install_generation::{GENERATION_FILE_ENV, GENERATION_TOKEN_ENV}; + +pub(crate) const SERVER_NAME: &str = "nemo-relay"; +const LAUNCH_ARGS: &[&str] = &["mcp"]; -pub(crate) async fn run( - agent: CodingAgent, - server_args: &ServerArgs, -) -> Result { +pub(crate) async fn run(server_args: &ServerArgs) -> Result { + if transparent_run_active() { + // An installed plugin can still be enabled inside `nemo-relay run`. In that process the + // wrapper already owns a healthy dynamic gateway, so this MCP instance authenticates and + // monitors it instead of launching the fixed persistent sidecar. + let gateway_url = std::env::var(crate::config::GATEWAY_URL_ENV).map_err(|_| { + CliError::Launch(format!( + "{} is required when {}=1", + crate::config::GATEWAY_URL_ENV, + crate::config::TRANSPARENT_RUN_ENV + )) + })?; + let bootstrap_fingerprint = crate::config::transparent_gateway_fingerprint(&gateway_url); + let lease = gateway::GatewayLease::borrow(gateway_url, bootstrap_fingerprint).await?; + let frames = transport::spawn_stdin_reader()?; + session::run(lease, frames, tokio::io::stdout()).await?; + return Ok(ExitCode::SUCCESS); + } // Starting the MCP process is the lifecycle boundary. Acquire the shared gateway before // reading protocol frames so hosts can rely on process startup rather than their individual // initialize and hook ordering. - let lease = gateway::GatewayPlan::resolve(agent, server_args) + let lease = gateway::GatewayPlan::resolve(server_args) .await? .acquire() .await?; @@ -30,6 +51,63 @@ pub(crate) async fn run( Ok(ExitCode::SUCCESS) } +/// Builds the host-independent persistent MCP launch contract. +/// +/// Host adapters add only schema-specific activation and environment-forwarding fields. Keeping +/// the command, arguments, fixed gateway bind, and generation fence here ensures Codex, Claude +/// Code, and Hermes launch the same process. +pub(crate) fn persistent_server( + relay: &Path, + generation_file: &Path, + generation_token: &str, +) -> Value { + json!({ + "command": relay, + "args": LAUNCH_ARGS, + "env": { + "NEMO_RELAY_GATEWAY_BIND": crate::sidecar::DEFAULT_BIND, + (GENERATION_FILE_ENV): generation_file, + (GENERATION_TOKEN_ENV): generation_token + } + }) +} + +/// Returns whether a server entry is owned by Relay's current or legacy MCP launch contract. +/// +/// Legacy `--agent` spellings are accepted only for migration. New configurations always use the +/// single host-neutral `nemo-relay mcp` command. +pub(crate) fn is_managed_server( + server: &Value, + relay_executable: impl FnOnce(&str) -> bool, +) -> bool { + let executable_matches = server + .get("command") + .and_then(Value::as_str) + .is_some_and(relay_executable); + if !executable_matches { + return false; + } + if server.get("args") == Some(&json!(LAUNCH_ARGS)) { + return true; + } + let Some(arguments) = server.get("args").and_then(Value::as_array) else { + return false; + }; + arguments.len() == 3 + && arguments[0] == "mcp" + && arguments[1] == "--agent" + && arguments[2] + .as_str() + .is_some_and(|agent| matches!(agent, "claude" | "codex" | "hermes")) +} + +fn transparent_run_active() -> bool { + std::env::var(crate::config::TRANSPARENT_RUN_ENV) + .ok() + .as_deref() + == Some("1") +} + fn default_mcp_bind() -> SocketAddr { crate::sidecar::DEFAULT_BIND .parse() @@ -37,26 +115,12 @@ fn default_mcp_bind() -> SocketAddr { } #[cfg(test)] -async fn run_session( - bind: SocketAddr, - gateway_url: String, - sidecar_args: Vec, - bootstrap_fingerprint: String, - heartbeat_interval: std::time::Duration, - reader: R, - writer: W, -) -> Result<(), CliError> +async fn run_session(reader: R, writer: W) -> Result<(), CliError> where R: tokio::io::AsyncBufRead + Unpin, W: tokio::io::AsyncWrite + Unpin, { - let lease = gateway::GatewayPlan::test_lease( - bind, - gateway_url, - sidecar_args, - bootstrap_fingerprint, - heartbeat_interval, - ); + let lease = gateway::GatewayLease::test_pending(); session::serve_with_lease(lease, reader, writer).await } diff --git a/crates/cli/src/mcp/gateway.rs b/crates/cli/src/mcp/gateway.rs index 5bd0c3b2f..9e43bc6c8 100644 --- a/crates/cli/src/mcp/gateway.rs +++ b/crates/cli/src/mcp/gateway.rs @@ -3,64 +3,68 @@ //! Acquisition and liveness lease for a shared coding-agent gateway. -#[cfg(test)] -use std::ffi::OsString; use std::net::SocketAddr; use std::time::Duration; -use crate::config::CodingAgent; use crate::config::ServerArgs; use crate::error::CliError; -use crate::install_generation::InstallGeneration; +use crate::install_generation::{ActiveGenerationGuard, InstallGeneration}; use crate::sidecar::{GatewayEndpoint, GatewaySpec}; const UNHEALTHY_CONFIRMATIONS: u8 = 3; const UNHEALTHY_CONFIRMATION_INTERVAL: Duration = Duration::from_millis(50); +const BORROWED_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); pub(super) struct GatewayPlan { spec: GatewaySpec, heartbeat_interval: Duration, generation: Option, + generation_guard: Option, } impl GatewayPlan { - pub(super) async fn resolve( - agent: CodingAgent, - server_args: &ServerArgs, - ) -> Result { - let generation = tokio::task::spawn_blocking(InstallGeneration::capture_from_env) + pub(super) async fn resolve(server_args: &ServerArgs) -> Result { + let captured = tokio::task::spawn_blocking(InstallGeneration::capture_guarded_from_env) .await .map_err(|error| { CliError::Launch(format!("MCP generation capture task failed: {error}")) })? .map_err(CliError::Launch)?; + let (generation, generation_guard) = captured + .map(|(generation, guard)| (Some(generation), Some(guard))) + .unwrap_or((None, None)); let bind = server_args.bind.unwrap_or_else(super::default_mcp_bind); - let launch = crate::sidecar::resolve_plugin_gateway(agent, server_args, bind)?; + let launch = crate::sidecar::resolve_plugin_gateway(server_args, bind)?; let heartbeat_interval = crate::sidecar::plugin_heartbeat_interval().map_err(CliError::Launch)?; Ok(Self { spec: launch.gateway, heartbeat_interval, generation, + generation_guard, }) } pub(super) async fn acquire(mut self) -> Result { - let acquisition = acquire_gateway(self.spec.clone(), self.generation.clone()).await?; + let acquisition = acquire_gateway(self.spec.clone(), self.generation_guard.take()).await?; let endpoint = acquisition.endpoint; self.spec = acquisition.spec; - let monitor = tokio::spawn(async move { self.monitor(endpoint).await }); - Ok(GatewayLease { - monitor, - _endpoint_lease: acquisition.lease, - }) + let monitor = tokio::spawn(async move { self.monitor(endpoint, acquisition.lease).await }); + Ok(GatewayLease { monitor }) } - async fn monitor(self, endpoint: crate::sidecar::GatewayEndpoint) -> Result<(), CliError> { + async fn monitor( + self, + endpoint: crate::sidecar::GatewayEndpoint, + endpoint_lease: crate::sidecar::EndpointLease, + ) -> Result<(), CliError> { let health_spec = self.spec.clone(); let restart_spec = self.spec.clone(); let restart_generation = self.generation.clone(); let verify_generation = self.generation; + let recovery_cohort = endpoint_lease.cohort_id().to_string(); + let verify_cohort = recovery_cohort.clone(); + let verify_url = endpoint.url.clone(); maintain_gateway_instances_with_generation( self.spec.bind(), endpoint, @@ -80,55 +84,84 @@ impl GatewayPlan { restart_spec.clone(), restart_generation.clone(), expected_instance, + recovery_cohort.clone(), ) }, move || { let generation = verify_generation.clone(); - async move { verify_generation_async(generation).await } + let cohort = verify_cohort.clone(); + let url = verify_url.clone(); + async move { verify_lifecycle_async(generation, cohort, url).await } }, ) .await } - - #[cfg(test)] - pub(super) fn test_lease( - bind: SocketAddr, - gateway_url: String, - sidecar_args: Vec, - bootstrap_fingerprint: String, - heartbeat_interval: Duration, - ) -> GatewayLease { - let plan = Self { - spec: GatewaySpec::new(CodingAgent::Codex, bind) - .with_launch_args(sidecar_args) - .with_fingerprint(bootstrap_fingerprint), - heartbeat_interval, - generation: None, - }; - let instance_id = plan - .spec - .healthy_instance(&gateway_url) - .unwrap_or_else(|| "test-initial-instance".into()); - let endpoint = crate::sidecar::GatewayEndpoint { - address: bind, - url: gateway_url, - instance_id, - }; - let monitor = tokio::spawn(async move { plan.monitor(endpoint).await }); - GatewayLease { - monitor, - _endpoint_lease: None, - } - } } /// An active liveness lease. Dropping it stops heartbeats immediately. pub(super) struct GatewayLease { monitor: tokio::task::JoinHandle>, - _endpoint_lease: Option, } impl GatewayLease { + #[cfg(test)] + pub(super) fn test_pending() -> Self { + let monitor = tokio::spawn(std::future::pending::>()); + Self { monitor } + } + + pub(super) async fn borrow( + gateway_url: String, + bootstrap_fingerprint: String, + ) -> Result { + Self::borrow_with_interval( + gateway_url, + bootstrap_fingerprint, + BORROWED_HEARTBEAT_INTERVAL, + ) + .await + } + + pub(super) async fn borrow_with_interval( + gateway_url: String, + bootstrap_fingerprint: String, + heartbeat_interval: Duration, + ) -> Result { + let expected_instance = authenticated_instance_id( + gateway_url.clone(), + bootstrap_fingerprint.clone(), + ) + .await? + .ok_or_else(|| { + CliError::Launch(format!( + "{} does not identify the authenticated NeMo Relay gateway owned by this transparent run", + crate::config::GATEWAY_URL_ENV + )) + })?; + let monitor = tokio::spawn(async move { + loop { + tokio::time::sleep(heartbeat_interval).await; + let current = + authenticated_instance_id(gateway_url.clone(), bootstrap_fingerprint.clone()) + .await?; + match current { + Some(instance) if instance == expected_instance => {} + Some(instance) => { + return Err(CliError::Launch(format!( + "transparent Relay gateway instance changed from {expected_instance} to {instance}" + ))); + } + None => { + return Err(CliError::Launch(format!( + "transparent Relay gateway at {gateway_url} is no longer available" + ))); + } + } + } + }); + Ok(Self { monitor }) + } + pub(super) async fn wait(&mut self) -> Result<(), CliError> { (&mut self.monitor).await.map_err(|error| { CliError::Launch(format!("gateway maintenance task failed: {error}")) @@ -136,6 +169,21 @@ impl GatewayLease { } } +async fn authenticated_instance_id( + gateway_url: String, + bootstrap_fingerprint: String, +) -> Result, CliError> { + tokio::task::spawn_blocking(move || { + crate::sidecar::authenticated_instance_id(&gateway_url, &bootstrap_fingerprint) + }) + .await + .map_err(|error| { + CliError::Launch(format!( + "transparent gateway verification task failed: {error}" + )) + }) +} + impl Drop for GatewayLease { fn drop(&mut self) { self.monitor.abort(); @@ -144,13 +192,10 @@ impl Drop for GatewayLease { async fn acquire_gateway( spec: GatewaySpec, - generation: Option, + generation_guard: Option, ) -> Result { tokio::task::spawn_blocking(move || { - let _generation_guard = generation - .as_ref() - .map(InstallGeneration::guard_current) - .transpose()?; + let _generation_guard = generation_guard; spec.acquire() }) .await @@ -162,28 +207,52 @@ async fn recover_gateway( spec: GatewaySpec, generation: Option, expected_instance: String, + recovery_cohort: String, ) -> Result { tokio::task::spawn_blocking(move || { let _generation_guard = generation .as_ref() .map(InstallGeneration::guard_current) .transpose()?; - spec.recover(&expected_instance) + spec.recover(&expected_instance, &recovery_cohort) }) .await .map_err(|error| CliError::Launch(format!("gateway recovery task failed: {error}")))? .map_err(CliError::Launch) } -async fn verify_generation_async(generation: Option) -> Result<(), CliError> { - tokio::task::spawn_blocking(move || { - generation - .as_ref() - .map_or(Ok(()), InstallGeneration::verify_current) - }) - .await - .map_err(|error| CliError::Launch(format!("MCP generation verification task failed: {error}")))? - .map_err(CliError::Launch) +async fn verify_lifecycle_async( + generation: Option, + recovery_cohort: String, + gateway_url: String, +) -> Result<(), CliError> { + loop { + let generation = generation.clone(); + let recovery_cohort = recovery_cohort.clone(); + let gateway_url = gateway_url.clone(); + let current = tokio::task::spawn_blocking(move || { + if let Some(generation) = generation.as_ref() + && !generation.try_verify_current()? + { + return Ok(false); + } + if crate::sidecar::check_gateway_cohort(&gateway_url, &recovery_cohort)? + == crate::sidecar::GatewayCohortStatus::UpdateInProgress + { + return Ok(false); + } + Ok(true) + }) + .await + .map_err(|error| { + CliError::Launch(format!("MCP lifecycle verification task failed: {error}")) + })? + .map_err(CliError::Launch)?; + if current { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } } #[cfg(test)] @@ -281,6 +350,10 @@ where } observed_instance = healthy(endpoint.url.clone(), recovery.instance_id().into()).await?; + // The health probe can queue or block while an integration replacement rotates the + // endpoint cohort. Revalidate before accepting either its instance or its failure so + // an old client cannot adopt the replacement across that asynchronous gap. + verify_generation().await?; if observed_instance.is_some() { break; } diff --git a/crates/cli/src/mcp_environment.rs b/crates/cli/src/mcp_environment.rs index 95f4cff2f..b12d1ed67 100644 --- a/crates/cli/src/mcp_environment.rs +++ b/crates/cli/src/mcp_environment.rs @@ -7,29 +7,45 @@ use std::collections::BTreeSet; use serde_json::Value; +use crate::install_generation::{GENERATION_FILE_ENV, GENERATION_TOKEN_ENV}; + const BASE_MCP_ENV_VARS: &[&str] = &[ + "ALL_PROXY", "ANTHROPIC_API_KEY", "APPDATA", "AWS_ACCESS_KEY_ID", "AWS_ALLOW_HTTP", + "AWS_CA_BUNDLE", "AWS_CONFIG_FILE", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_DEFAULT_REGION", + "AWS_EC2_METADATA_DISABLED", "AWS_ENDPOINT_URL", "AWS_PROFILE", "AWS_REGION", + "AWS_ROLE_ARN", + "AWS_ROLE_SESSION_NAME", + "AWS_SDK_LOAD_CONFIG", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_SHARED_CREDENTIALS_FILE", + "AWS_STS_REGIONAL_ENDPOINTS", + "AWS_WEB_IDENTITY_TOKEN_FILE", "HOME", "HTTPS_PROXY", "HTTP_PROXY", "LOCALAPPDATA", "NEMO_RELAY_ANTHROPIC_BASE_URL", + "NEMO_RELAY_GATEWAY_URL", "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES", "NEMO_RELAY_OPENAI_BASE_URL", "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "NEMO_RELAY_PYTHON", + "NEMO_RELAY_TRANSPARENT_RUN", "NO_PROXY", "OPENAI_API_KEY", "OTEL_EXPORTER_OTLP_COMPRESSION", @@ -52,6 +68,7 @@ const BASE_MCP_ENV_VARS: &[&str] = &[ "USERPROFILE", "XDG_CONFIG_HOME", "XDG_RUNTIME_DIR", + "all_proxy", "http_proxy", "https_proxy", "no_proxy", @@ -66,8 +83,8 @@ const BLOCKED_MCP_ENV_VARS: &[&str] = &[ "NEMO_RELAY_CONFIG_SCOPE", "NEMO_RELAY_FAIL_CLOSED", "NEMO_RELAY_GATEWAY_BIND", - "NEMO_RELAY_GATEWAY_URL", "NEMO_RELAY_HOST_SOCKET", + "NEMO_RELAY_MCP_GENERATION", "NEMO_RELAY_MCP_GENERATION_FILE", "NEMO_RELAY_NATIVE_ABI_VERSION", "NEMO_RELAY_PLUGIN_BINARY", @@ -95,10 +112,10 @@ pub(crate) fn forwarded_names_for_platform( config: Option<&Value>, windows: bool, ) -> Vec { - let mut names = BASE_MCP_ENV_VARS - .iter() - .map(|name| (*name).to_string()) - .collect::>(); + let mut names = BTreeSet::new(); + for name in BASE_MCP_ENV_VARS { + insert_name(&mut names, (*name).to_string(), windows); + } for name in environment { if prefix_allowed(&name, windows) && !blocked(&name) { insert_name(&mut names, name, windows); @@ -110,19 +127,64 @@ pub(crate) fn forwarded_names_for_platform( names.into_iter().collect() } -pub(crate) fn config_referenced_names(config: Option<&Value>) -> Vec { - config_referenced_names_for_platform(config, cfg!(windows)) +/// Removes unresolved `${NAME}` values injected by MCP hosts before CLI parsing. +/// +/// Hermes forwards environment names through placeholder values rather than a separate +/// `env_vars` list. When a variable is absent, Hermes preserves the self-placeholder. Relay must +/// treat that value as unset before clap reads numeric or socket-valued environment options. The +/// generation fence scopes this cleanup to managed persistent MCP launches; internal variables +/// remain untouched so malformed or retired generation identities fail closed during validation. +pub(crate) fn remove_unresolved_mcp_placeholders() { + if std::env::var_os(GENERATION_FILE_ENV).is_none() + || std::env::var_os(GENERATION_TOKEN_ENV).is_none() + { + return; + } + let unresolved = std::env::vars_os() + .filter_map(|(name, value)| { + let name_text = name.to_str()?; + let value = value.to_str()?; + (!blocked(name_text) + && unresolved_self_placeholder_for_platform(name_text, value, cfg!(windows))) + .then_some(name) + }) + .collect::>(); + for name in unresolved { + // SAFETY: The synchronous CLI entrypoint calls this before constructing the Tokio runtime, + // so no other thread can read or write the process environment concurrently. + unsafe { std::env::remove_var(name) }; + } } -pub(crate) fn config_referenced_names_for_platform( - config: Option<&Value>, - windows: bool, -) -> Vec { - let mut names = BTreeSet::new(); - if let Some(config) = config { - collect_config_names(config, &mut names, windows); +pub(crate) fn forwarded_names_match_for_platform(left: &str, right: &str, windows: bool) -> bool { + if windows { + left.eq_ignore_ascii_case(right) + } else { + left == right } - names.into_iter().collect() +} + +/// Returns whether a name could have been captured from an earlier process environment. +/// +/// Arbitrary config-referenced names remain in the current expected set. Historical extras are +/// therefore limited to the static allowlist and approved dynamic prefixes. +pub(crate) fn previously_forwardable_name_for_platform(name: &str, windows: bool) -> bool { + !blocked(name) + && (BASE_MCP_ENV_VARS + .iter() + .any(|base| forwarded_names_match_for_platform(name, base, windows)) + || prefix_allowed(name, windows)) +} + +pub(crate) fn unresolved_self_placeholder_for_platform( + name: &str, + value: &str, + windows: bool, +) -> bool { + value + .strip_prefix("${") + .and_then(|value| value.strip_suffix('}')) + .is_some_and(|placeholder| forwarded_names_match_for_platform(name, placeholder, windows)) } fn prefix_allowed(name: &str, windows: bool) -> bool { diff --git a/crates/cli/src/plugin_host/codex.rs b/crates/cli/src/plugin_host/codex.rs index c02c13458..07b7c6121 100644 --- a/crates/cli/src/plugin_host/codex.rs +++ b/crates/cli/src/plugin_host/codex.rs @@ -12,19 +12,22 @@ use std::process::ExitCode; use serde_json::{Value, json}; use toml_edit::{DocumentMut, InlineTable, Item, Table, Value as TomlValue, value}; -use crate::config::{BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey, CodingAgent}; +use crate::config::{ + BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey, CodingAgent, RELAY_PLUGIN_ID, +}; use crate::installer::generated_hooks; #[cfg(test)] use crate::installer::merge_hooks; use super::codex_app_server::{CodexAppServerClient, CodexHookMetadata, CodexHooksClient}; use super::shared::{ - FileSnapshot, atomic_write, backup, backup_path, current_exe, ensure_table, home_dir, - portable_executable_path, read_json_object, remove_backup, restore_file_snapshot, shell_quote, - shell_quote_arg_for_platform, snapshot_optional_file, write_json, + FileSnapshot, atomic_write, atomic_write_private, backup, backup_path, current_exe, + ensure_table, home_dir, portable_executable_path, read_json_object, remove_backup, + restore_file_snapshot, shell_quote, shell_quote_arg_for_platform, snapshot_optional_file, + write_json, }; -pub(super) const CODEX_PLUGIN_ID: &str = "nemo-relay-plugin@nemo-relay-local"; +pub(super) const CODEX_PLUGIN_ID: &str = RELAY_PLUGIN_ID; pub(super) const CODEX_PLUGIN_HOOK_KEY_PREFIX: &str = "nemo-relay-plugin@nemo-relay-local:hooks/hooks.json:"; @@ -77,11 +80,21 @@ pub(crate) fn restore_codex_setup(snapshot: &CodexSetupSnapshot) -> Result<(), S } } +#[cfg(test)] pub(super) fn install_codex( gateway_url: &str, plugin_hooks_path: &Path, ) -> Result { - let expected_command = expected_plugin_hook_command()?; + install_codex_with_generation(gateway_url, plugin_hooks_path, None) +} + +pub(super) fn install_codex_with_generation( + gateway_url: &str, + plugin_hooks_path: &Path, + generation_token: Option<&str>, +) -> Result { + let expected_command = + expected_plugin_hook_command_with_token(plugin_hooks_path, generation_token)?; validate_plugin_hooks(plugin_hooks_path, &expected_command)?; install_codex_with_trust( gateway_url, @@ -168,19 +181,6 @@ pub(super) fn uninstall_codex_with_client( Ok(ExitCode::SUCCESS) } -const GENERATED_CODEX_HOOK_EVENTS: &[(&str, &str)] = &[ - ("sessionstart", "SessionStart"), - ("userpromptsubmit", "UserPromptSubmit"), - ("pretooluse", "PreToolUse"), - ("posttooluse", "PostToolUse"), - ("permissionrequest", "PermissionRequest"), - ("subagentstart", "SubagentStart"), - ("subagentstop", "SubagentStop"), - ("stop", "Stop"), - ("precompact", "PreCompact"), - ("postcompact", "PostCompact"), -]; - #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(super) struct CodexHookTrustReport { trusted: Vec, @@ -226,9 +226,10 @@ impl CodexHookTrustReport { pub(super) fn empty_codex_hook_trust_report() -> CodexHookTrustReport { CodexHookTrustReport { - missing_required: GENERATED_CODEX_HOOK_EVENTS + missing_required: CodingAgent::Codex + .hook_events() .iter() - .map(|(_, display)| (*display).to_string()) + .map(|event| (*event).to_string()) .collect(), ..CodexHookTrustReport::default() } @@ -236,9 +237,17 @@ pub(super) fn empty_codex_hook_trust_report() -> CodexHookTrustReport { pub(super) fn codex_hook_trust_report( plugin_hooks_path: &Path, +) -> Result { + codex_hook_trust_report_with_generation(plugin_hooks_path, None) +} + +pub(super) fn codex_hook_trust_report_with_generation( + plugin_hooks_path: &Path, + generation_token: Option<&str>, ) -> Result { let home = home_dir()?; - let expected_command = expected_plugin_hook_command()?; + let expected_command = + expected_plugin_hook_command_with_token(plugin_hooks_path, generation_token)?; validate_plugin_hooks(plugin_hooks_path, &expected_command)?; let mut client = CodexAppServerClient::start()?; codex_hook_trust_report_with_client(&mut client, &home, &expected_command) @@ -353,9 +362,7 @@ fn relay_codex_plugin_hooks( hook.source == "plugin" && hook.plugin_id.as_deref() == Some(CODEX_PLUGIN_ID) && hook.handler_type == "command" - && GENERATED_CODEX_HOOK_EVENTS - .iter() - .any(|(event, _)| normalize_hook_event(&hook.event_name) == *event) + && is_generated_codex_hook_event(&hook.event_name) }) .collect()) } @@ -560,11 +567,49 @@ fn restore_hook_trust_after_failure( Err(original_error) } -pub(super) fn expected_plugin_hook_command() -> Result { +#[cfg(test)] +pub(super) fn expected_plugin_hook_command(plugin_hooks_path: &Path) -> Result { + expected_plugin_hook_command_with_token(plugin_hooks_path, None) +} + +fn expected_plugin_hook_command_with_token( + plugin_hooks_path: &Path, + generation_token: Option<&str>, +) -> Result { let relay = current_exe()?; let relay = relay.canonicalize().unwrap_or(relay); let relay = portable_executable_path(relay); - Ok(codex_plugin_hook_command(&relay)) + let generation_path = plugin_generation_file(plugin_hooks_path)?; + let captured; + let generation_token = match generation_token { + Some(token) => token, + None => { + captured = + crate::install_generation::InstallGeneration::capture(generation_path.clone())?; + captured.token() + } + }; + codex_plugin_hook_command(&relay, &generation_path, generation_token) +} + +fn plugin_generation_file(plugin_hooks_path: &Path) -> Result { + let generation = plugin_hooks_path + .parent() + .and_then(Path::parent) + .map(|root| root.join(crate::install_generation::GENERATION_FILE_NAME)) + .ok_or_else(|| { + format!( + "Codex plugin hooks path {} is not inside a plugin hooks directory", + plugin_hooks_path.display() + ) + })?; + if generation.is_absolute() { + Ok(generation) + } else { + env::current_dir() + .map(|current| current.join(generation)) + .map_err(|error| format!("failed to resolve the Codex plugin generation path: {error}")) + } } fn validate_plugin_hooks(path: &Path, expected_command: &str) -> Result<(), String> { @@ -592,29 +637,41 @@ pub(super) fn codex_hook_trust_report_for(hooks: &[CodexHookMetadata]) -> CodexH report.disabled.push(hook.key.clone()); } } - report.missing_required = GENERATED_CODEX_HOOK_EVENTS + report.missing_required = CodingAgent::Codex + .hook_events() .iter() - .filter(|(normalized, _)| { + .filter(|event| { + let normalized = normalize_hook_event(event); !hooks .iter() - .any(|hook| normalize_hook_event(&hook.event_name) == *normalized) + .any(|hook| normalize_hook_event(&hook.event_name) == normalized) }) - .map(|(_, display)| (*display).to_string()) + .map(|event| (*event).to_string()) .collect(); - report.duplicate_required = GENERATED_CODEX_HOOK_EVENTS + report.duplicate_required = CodingAgent::Codex + .hook_events() .iter() - .filter(|(normalized, _)| { + .filter(|event| { + let normalized = normalize_hook_event(event); hooks .iter() - .filter(|hook| normalize_hook_event(&hook.event_name) == *normalized) + .filter(|hook| normalize_hook_event(&hook.event_name) == normalized) .count() > 1 }) - .map(|(_, display)| (*display).to_string()) + .map(|event| (*event).to_string()) .collect(); report } +fn is_generated_codex_hook_event(event: &str) -> bool { + let normalized = normalize_hook_event(event); + CodingAgent::Codex + .hook_events() + .iter() + .any(|expected| normalize_hook_event(expected) == normalized) +} + fn normalize_hook_event(event: &str) -> String { event .chars() @@ -658,16 +715,35 @@ pub(super) fn prepare_codex_config(path: &Path) -> Result<(), String> { } pub(super) fn install_codex_config(path: &Path, gateway_url: &str) -> Result<(), String> { - let client_token = BootstrapChallengeKey::load() - .map_err(|error| error.to_string())? - .client_token(); + let challenge = BootstrapChallengeKey::load().map_err(|error| error.to_string())?; + let client_token = challenge.client_token(); let raw = read_optional_text(path)?; let mut doc = raw .parse::() .map_err(|error| format!("invalid TOML in {}: {error}", path.display()))?; let backup_snapshot = snapshot_optional_file(&backup_path(path))?; - if !codex_config_doc_has_managed_install(&doc, gateway_url) { - backup(path)?; + let has_managed_proof = + codex_provider_client_token(&doc).is_some_and(|token| challenge.verify_client_token(token)); + let provider_extensions = codex_provider_user_extensions(&doc, gateway_url); + let unmodified_managed_install = codex_config_doc_has_managed_install(&doc, gateway_url) + && has_managed_proof + && codex_provider_has_only_generated_fields(&doc); + if !unmodified_managed_install + && let Err(error) = refresh_codex_config_backup( + path, + &raw, + &doc, + gateway_url, + has_managed_proof, + &challenge, + ) + { + return match restore_file_snapshot(&backup_snapshot) { + Ok(()) => Err(error), + Err(restore_error) => Err(format!( + "{error}; additionally failed to restore the Codex backup: {restore_error}" + )), + }; } doc["model_provider"] = value("nemo-relay-openai"); ensure_table(&mut doc, "features")["hooks"] = value(true); @@ -683,14 +759,351 @@ pub(super) fn install_codex_config(path: &Path, gateway_url: &str) -> Result<(), headers.insert(BOOTSTRAP_CLIENT_TOKEN_HEADER, TomlValue::from(client_token)); provider["http_headers"] = Item::Value(TomlValue::InlineTable(headers)); providers["nemo-relay-openai"] = Item::Table(provider); + if let Some(extensions) = provider_extensions.as_ref() { + merge_codex_provider_extensions(&mut doc, extensions); + } - if let Err(error) = atomic_write(path, doc.to_string().as_bytes()) { + if let Err(error) = atomic_write_private(path, doc.to_string().as_bytes()) { restore_file_snapshot(&backup_snapshot)?; return Err(error); } Ok(()) } +/// Refresh the uninstall baseline without carrying installer-owned fields forward. +/// +/// A user can edit one field of an installed config before a forced reinstall. The current file +/// then contains both that user change and Relay's provider, hook flag, and client proof. Reusing +/// the whole file as the new backup would make those generated fields survive uninstall. Apply +/// the same ownership rules as uninstall to reconstruct the user baseline first. +fn refresh_codex_config_backup( + path: &Path, + raw: &str, + current: &DocumentMut, + gateway_url: &str, + has_managed_proof: bool, + challenge: &BootstrapChallengeKey, +) -> Result<(), String> { + let previous = read_codex_backup_doc_for_refresh(path)? + .map(|backup| sanitize_codex_backup_doc(backup, gateway_url, Some(challenge))); + if previous.is_none() && !has_managed_proof { + if !path.exists() { + return Ok(()); + } + return atomic_write_private(&backup_path(path), raw.as_bytes()); + } + + let empty = DocumentMut::new(); + let previous = previous.as_ref().unwrap_or(&empty); + let mut baseline = current.clone(); + let preserved_provider = codex_extended_provider_without_proof(&baseline, gateway_url); + let provider_is_managed = codex_provider_item_is_managed(&baseline, gateway_url); + restore_codex_config_from_backup(&mut baseline, previous, provider_is_managed, false); + restore_codex_client_proof_from_backup(&mut baseline, previous, Some(challenge)); + if let Some(provider) = preserved_provider { + ensure_table(&mut baseline, "model_providers") + .insert("nemo-relay-openai", Item::Table(provider)); + } + remove_empty_table(&mut baseline, "model_providers"); + remove_empty_table(&mut baseline, "features"); + atomic_write_private(&backup_path(path), baseline.to_string().as_bytes()) +} + +fn read_codex_backup_doc_for_refresh(path: &Path) -> Result, String> { + let backup = backup_path(path); + let raw = match fs::read_to_string(&backup) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!("failed to read {}: {error}", backup.display())); + } + }; + // A stale backup from an interrupted or older installation is not a trustworthy baseline. + // The enclosing install transaction still snapshots and restores its bytes on later failure. + Ok(raw.parse::().ok()) +} + +fn restore_codex_client_proof_from_backup( + doc: &mut DocumentMut, + backup: &DocumentMut, + challenge: Option<&BootstrapChallengeKey>, +) { + let current_is_managed = codex_provider_header(doc, BOOTSTRAP_CLIENT_TOKEN_HEADER) + .and_then(TomlValue::as_str) + .is_some_and(|token| { + challenge.is_some_and(|challenge| challenge.verify_client_token(token)) + }); + if !current_is_managed { + return; + } + let replacement = codex_provider_header(backup, BOOTSTRAP_CLIENT_TOKEN_HEADER) + .filter(|value| { + !value.as_str().is_some_and(|token| { + challenge.is_some_and(|challenge| challenge.verify_client_token(token)) + }) + }) + .cloned(); + let Some(provider) = doc + .get_mut("model_providers") + .and_then(Item::as_table_mut) + .and_then(|providers| providers.get_mut("nemo-relay-openai")) + .and_then(Item::as_table_mut) + else { + return; + }; + let Some(headers) = provider.get_mut("http_headers") else { + return; + }; + + let remove_headers = if let Some(headers) = headers.as_inline_table_mut() { + if !headers.contains_key(BOOTSTRAP_CLIENT_TOKEN_HEADER) { + return; + } + match replacement { + Some(value) => { + headers.insert(BOOTSTRAP_CLIENT_TOKEN_HEADER, value); + } + None => { + headers.remove(BOOTSTRAP_CLIENT_TOKEN_HEADER); + } + } + headers.is_empty() + } else if let Some(headers) = headers.as_table_mut() { + if !headers.contains_key(BOOTSTRAP_CLIENT_TOKEN_HEADER) { + return; + } + match replacement { + Some(value) => { + headers.insert(BOOTSTRAP_CLIENT_TOKEN_HEADER, Item::Value(value)); + } + None => { + headers.remove(BOOTSTRAP_CLIENT_TOKEN_HEADER); + } + } + headers.is_empty() + } else { + false + }; + if remove_headers { + provider.remove("http_headers"); + } +} + +/// Remove installer-owned state from a backup produced by an older partial-reinstall bug. +/// +/// The client proof is the ownership signal: only a token authenticated by this user's current +/// bootstrap key permits cleanup. Exact generated providers are removed wholesale. A provider +/// edited by the user is retained, with only Relay's reserved proof and hook feature removed. +fn sanitize_codex_backup_doc( + mut backup: DocumentMut, + gateway_url: &str, + challenge: Option<&BootstrapChallengeKey>, +) -> DocumentMut { + let reserved_token = + codex_provider_header(&backup, BOOTSTRAP_CLIENT_TOKEN_HEADER).and_then(TomlValue::as_str); + let has_managed_proof = reserved_token.is_some_and(|token| { + challenge.is_some_and(|challenge| challenge.verify_client_token(token)) + }); + let provider_is_managed = codex_provider_item_is_managed(&backup, gateway_url); + let has_generated_lineage = provider_is_managed && reserved_token.is_some(); + if !has_managed_proof && !has_generated_lineage { + return backup; + } + + if provider_is_managed { + let preserved_provider = codex_extended_provider_without_proof(&backup, gateway_url); + if top_level_item_is_str(&backup, "model_provider", "nemo-relay-openai") { + backup.as_table_mut().remove("model_provider"); + } + if let Some(providers) = backup + .get_mut("model_providers") + .and_then(Item::as_table_mut) + { + providers.remove("nemo-relay-openai"); + } + if let Some(provider) = preserved_provider { + ensure_table(&mut backup, "model_providers") + .insert("nemo-relay-openai", Item::Table(provider)); + } + } else { + let empty = DocumentMut::new(); + restore_codex_client_proof_from_backup(&mut backup, &empty, challenge); + } + remove_table_item_if_bool(&mut backup, "features", "hooks", true); + remove_empty_table(&mut backup, "model_providers"); + remove_empty_table(&mut backup, "features"); + backup +} + +/// Extract fields a user added to an otherwise generated provider table. +fn codex_provider_user_extensions(doc: &DocumentMut, gateway_url: &str) -> Option
{ + if !codex_provider_item_is_managed(doc, gateway_url) { + return None; + } + let mut extensions = doc + .get("model_providers")? + .as_table()? + .get("nemo-relay-openai")? + .as_table()? + .clone(); + for key in [ + "name", + "base_url", + "wire_api", + "requires_openai_auth", + "supports_websockets", + ] { + extensions.remove(key); + } + remove_codex_provider_header(&mut extensions, BOOTSTRAP_CLIENT_TOKEN_HEADER); + (!extensions.is_empty()).then_some(extensions) +} + +fn codex_extended_provider_without_proof(doc: &DocumentMut, gateway_url: &str) -> Option
{ + codex_provider_user_extensions(doc, gateway_url)?; + let mut provider = doc + .get("model_providers")? + .as_table()? + .get("nemo-relay-openai")? + .as_table()? + .clone(); + remove_codex_provider_header(&mut provider, BOOTSTRAP_CLIENT_TOKEN_HEADER); + Some(provider) +} + +fn remove_codex_provider_header(provider: &mut Table, name: &str) { + let Some(headers) = provider.get_mut("http_headers") else { + return; + }; + let remove_headers = if let Some(headers) = headers.as_inline_table_mut() { + headers.remove(name); + headers.is_empty() + } else if let Some(headers) = headers.as_table_mut() { + headers.remove(name); + headers.is_empty() + } else { + false + }; + if remove_headers { + provider.remove("http_headers"); + } +} + +fn merge_codex_provider_extensions(doc: &mut DocumentMut, extensions: &Table) { + let providers = ensure_table(doc, "model_providers"); + if !providers + .get("nemo-relay-openai") + .is_some_and(Item::is_table) + { + providers["nemo-relay-openai"] = Item::Table(Table::new()); + } + let provider = providers["nemo-relay-openai"] + .as_table_mut() + .expect("provider table was just inserted"); + for (key, item) in extensions.iter() { + if key == "http_headers" { + merge_codex_provider_headers(provider, item); + } else { + provider.insert(key, item.clone()); + } + } +} + +fn merge_codex_provider_headers(provider: &mut Table, extensions: &Item) { + let Some(entries) = codex_header_entries(extensions) else { + provider.insert("http_headers", extensions.clone()); + return; + }; + let Some(headers) = provider.get_mut("http_headers") else { + provider.insert("http_headers", extensions.clone()); + return; + }; + if let Some(headers) = headers.as_inline_table_mut() { + for (name, value) in entries { + headers.insert(&name, value); + } + } else if let Some(headers) = headers.as_table_mut() { + for (name, value) in entries { + headers.insert(&name, Item::Value(value)); + } + } else { + *headers = extensions.clone(); + } +} + +fn codex_header_entries(headers: &Item) -> Option> { + if let Some(headers) = headers.as_inline_table() { + return Some( + headers + .iter() + .map(|(name, value)| (name.to_string(), value.clone())) + .collect(), + ); + } + headers.as_table().and_then(|headers| { + headers + .iter() + .map(|(name, item)| { + item.as_value() + .cloned() + .map(|value| (name.to_string(), value)) + }) + .collect::>>() + }) +} + +fn codex_provider_has_only_generated_fields(doc: &DocumentMut) -> bool { + let Some(provider) = doc + .get("model_providers") + .and_then(Item::as_table) + .and_then(|providers| providers.get("nemo-relay-openai")) + .and_then(Item::as_table) + else { + return false; + }; + let generated_fields = [ + "name", + "base_url", + "wire_api", + "requires_openai_auth", + "supports_websockets", + "http_headers", + ]; + if provider.len() != generated_fields.len() + || !generated_fields + .iter() + .all(|field| provider.contains_key(field)) + { + return false; + } + let Some(headers) = provider.get("http_headers") else { + return false; + }; + headers.as_inline_table().is_some_and(|headers| { + headers.len() == 1 && headers.contains_key(BOOTSTRAP_CLIENT_TOKEN_HEADER) + }) || headers.as_table().is_some_and(|headers| { + headers.len() == 1 && headers.contains_key(BOOTSTRAP_CLIENT_TOKEN_HEADER) + }) +} + +pub(super) fn codex_provider_header<'a>(doc: &'a DocumentMut, name: &str) -> Option<&'a TomlValue> { + let headers = doc + .get("model_providers") + .and_then(Item::as_table) + .and_then(|providers| providers.get("nemo-relay-openai")) + .and_then(Item::as_table) + .and_then(|provider| provider.get("http_headers"))?; + headers + .as_inline_table() + .and_then(|headers| headers.get(name)) + .or_else(|| { + headers + .as_table() + .and_then(|headers| headers.get(name)) + .and_then(Item::as_value) + }) +} + pub(super) fn read_optional_text(path: &Path) -> Result { match fs::read_to_string(path) { Ok(raw) => Ok(raw), @@ -712,7 +1125,10 @@ pub(super) fn uninstall_codex_config( let mut doc = raw .parse::() .map_err(|error| format!("invalid TOML in {}: {error}", path.display()))?; - let backup_doc = read_codex_backup_doc(path)?; + let challenge = BootstrapChallengeKey::load_existing().map_err(|error| error.to_string())?; + let backup_doc = read_codex_backup_doc(path)? + .map(|backup| sanitize_codex_backup_doc(backup, gateway_url, challenge.as_ref())); + let preserved_provider = codex_extended_provider_without_proof(&doc, gateway_url); let provider_is_managed = codex_provider_item_is_managed(&doc, gateway_url); match backup_doc.as_ref() { Some(backup_doc) => { @@ -725,6 +1141,16 @@ pub(super) fn uninstall_codex_config( } None => remove_codex_config_without_backup(&mut doc, provider_is_managed, preserve_hooks), } + if let Some(provider) = preserved_provider { + ensure_table(&mut doc, "model_providers") + .insert("nemo-relay-openai", Item::Table(provider)); + } + let empty_backup = DocumentMut::new(); + restore_codex_client_proof_from_backup( + &mut doc, + backup_doc.as_ref().unwrap_or(&empty_backup), + challenge.as_ref(), + ); remove_empty_table(&mut doc, "model_providers"); remove_empty_table(&mut doc, "features"); @@ -1126,8 +1552,18 @@ pub(super) fn codex_provider_client_token(doc: &DocumentMut) -> Option<&str> { } pub(super) fn codex_hooks_installed(path: &Path) -> Result { + codex_hooks_installed_with_generation(path, None) +} + +pub(super) fn codex_hooks_installed_with_generation( + path: &Path, + generation_token: Option<&str>, +) -> Result { let value = read_json_object(path)?; - let generated = generated_hooks(CodingAgent::Codex, &expected_plugin_hook_command()?); + let generated = generated_hooks( + CodingAgent::Codex, + &expected_plugin_hook_command_with_token(path, generation_token)?, + ); Ok(value == generated) } @@ -1145,28 +1581,32 @@ pub(super) fn codex_hook_command(gateway_url: &str) -> String { ) } -pub(super) fn codex_plugin_hook_command(relay: &Path) -> String { - codex_plugin_hook_command_for_platform(relay, cfg!(windows)) -} - -pub(super) fn codex_plugin_hook_command_for_platform(relay: &Path, windows: bool) -> String { - crate::installer::persistent_hook_forward_command_for_platform( +pub(super) fn codex_plugin_hook_command( + relay: &Path, + generation: &Path, + generation_token: &str, +) -> Result { + crate::installer::persistent_hook_forward_command( relay, CodingAgent::Codex, - windows, + generation, + generation_token, ) } #[cfg(test)] -pub(super) fn codex_hook_command_for_platform( +pub(super) fn codex_plugin_hook_command_for_platform( relay: &Path, - gateway_url: &str, + generation: &Path, + generation_token: &str, windows: bool, ) -> String { - format!( - "{} hook-forward codex --gateway-url {}", - crate::plugin_host::shell_quote_for_platform(relay, windows), - shell_quote_arg_for_platform(gateway_url, windows) + crate::installer::persistent_hook_forward_command_for_platform( + relay, + CodingAgent::Codex, + generation, + generation_token, + windows, ) } diff --git a/crates/cli/src/plugin_host/mod.rs b/crates/cli/src/plugin_host/mod.rs index 91e0b25dd..f196c37f8 100644 --- a/crates/cli/src/plugin_host/mod.rs +++ b/crates/cli/src/plugin_host/mod.rs @@ -12,7 +12,6 @@ pub(crate) use claude::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_clau pub(crate) use codex::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; pub(crate) use shared::portable_executable_path; pub(crate) use shared::shell_quote_arg_for_platform; -pub(crate) use shared::shell_quote_for_platform; #[cfg(test)] pub(crate) use shared::strip_windows_verbatim_prefix; @@ -21,8 +20,11 @@ use std::path::Path; use serde_json::{Value, json}; use claude::claude_settings_base_url; -use codex::{codex_hook_trust_report, empty_codex_hook_trust_report}; -use codex::{codex_hooks_installed, codex_provider_installed, install_codex, uninstall_codex}; +use codex::{ + codex_hook_trust_report, codex_hook_trust_report_with_generation, codex_hooks_installed, + codex_hooks_installed_with_generation, codex_provider_installed, empty_codex_hook_trust_report, + install_codex_with_generation, uninstall_codex, +}; use shared::{current_exe, healthz, print_check, print_info}; use crate::config::CodingAgent; @@ -30,12 +32,21 @@ use crate::config::CodingAgent; #[cfg(test)] pub(super) use crate::sidecar::DEFAULT_URL; -pub(crate) fn install_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Result<(), String> { - install_codex(gateway_url, &plugin_root.join("hooks").join("hooks.json")).map(|_| ()) +pub(crate) fn install_codex_plugin_with_generation( + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, +) -> Result<(), String> { + install_codex_with_generation( + gateway_url, + &plugin_root.join("hooks").join("hooks.json"), + generation_token, + ) + .map(|_| ()) } pub(crate) fn stop_plugin_gateway() -> Result<(), String> { - crate::sidecar::stop_owned_sidecar(crate::sidecar::DEFAULT_URL) + crate::sidecar::stop_owned_sidecar_and_reset(crate::sidecar::DEFAULT_URL) } pub(crate) fn uninstall_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Result<(), String> { @@ -54,11 +65,21 @@ pub(crate) fn doctor_plugin( agent: CodingAgent, gateway_url: &str, plugin_root: &Path, +) -> Result<(), String> { + doctor_plugin_with_generation(agent, gateway_url, plugin_root, None) +} + +pub(crate) fn doctor_plugin_with_generation( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, ) -> Result<(), String> { if doctor_ok( agent, gateway_url, Some(&plugin_root.join("hooks").join("hooks.json")), + generation_token, )? { Ok(()) } else { @@ -134,6 +155,7 @@ fn doctor_ok( agent: CodingAgent, gateway_url: &str, plugin_hooks_path: Option<&Path>, + generation_token: Option<&str>, ) -> Result { let mut ok = true; ok &= print_check( @@ -159,11 +181,11 @@ fn doctor_ok( let plugin_hooks_path = plugin_hooks_path .ok_or_else(|| "Codex plugin hooks path is required for doctor".to_string())?; let provider = codex_provider_installed(gateway_url); - let hooks = codex_hooks_installed(plugin_hooks_path)?; + let hooks = codex_hooks_installed_with_generation(plugin_hooks_path, generation_token)?; ok &= print_check("codex provider alias", provider); ok &= print_check("codex hooks", hooks); let trust = if hooks { - codex_hook_trust_report(plugin_hooks_path)? + codex_hook_trust_report_with_generation(plugin_hooks_path, generation_token)? } else { empty_codex_hook_trust_report() }; diff --git a/crates/cli/src/plugin_host/shared.rs b/crates/cli/src/plugin_host/shared.rs index 27cba584d..5e5a470c7 100644 --- a/crates/cli/src/plugin_host/shared.rs +++ b/crates/cli/src/plugin_host/shared.rs @@ -10,7 +10,11 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; use toml_edit::{DocumentMut, Item, Table}; -pub(super) use crate::file_io::atomic_write; +pub(super) use crate::file_io::{ + atomic_write, atomic_write_private, atomic_write_with_permissions, +}; +#[cfg(windows)] +use crate::file_io::{atomic_write_with_windows_dacl, read_windows_dacl}; pub(super) use crate::sidecar::{current_exe, healthz}; pub(crate) fn shell_quote(path: &Path) -> String { @@ -56,7 +60,7 @@ fn cmd_quote_arg(raw: &str) -> String { // cmd expands percent variables even inside quotes. Insert a zero-length // substring expansion before the literal percent, matching Rust's hardened // batch-file encoder, so values such as `%USERPROFILE%` remain literal. - '%' => escaped.push_str("%%cd:~,%%"), + '%' => escaped.push_str("%%cd:~,%"), // Double quotes are represented by a paired quote inside a quoted cmd token. '"' => escaped.push_str("\"\""), _ => escaped.push(ch), @@ -155,6 +159,9 @@ pub(super) fn print_info(label: &str, message: &str) { pub(super) struct FileSnapshot { path: PathBuf, bytes: Option>, + permissions: Option, + #[cfg(windows)] + dacl: Option>, } pub(super) fn snapshot_optional_file(path: &Path) -> Result { @@ -162,10 +169,21 @@ pub(super) fn snapshot_optional_file(path: &Path) -> Result Ok(FileSnapshot { path: path.to_path_buf(), bytes: Some(bytes), + permissions: fs::metadata(path).ok().map(|value| value.permissions()), + #[cfg(windows)] + dacl: Some(read_windows_dacl(path).map_err(|error| { + format!( + "failed to read access control for {}: {error}", + path.display() + ) + })?), }), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(FileSnapshot { path: path.to_path_buf(), bytes: None, + permissions: None, + #[cfg(windows)] + dacl: None, }), Err(error) => Err(format!("failed to read {}: {error}", path.display())), } @@ -173,7 +191,11 @@ pub(super) fn snapshot_optional_file(path: &Path) -> Result Result<(), String> { if let Some(bytes) = snapshot.bytes.as_deref() { - return atomic_write(&snapshot.path, bytes); + #[cfg(windows)] + if let Some(dacl) = snapshot.dacl.as_deref() { + return atomic_write_with_windows_dacl(&snapshot.path, bytes, dacl); + } + return atomic_write_with_permissions(&snapshot.path, bytes, snapshot.permissions.as_ref()); } match fs::remove_file(&snapshot.path) { Ok(()) => Ok(()), diff --git a/crates/cli/src/plugin_install/marketplace.rs b/crates/cli/src/plugin_install/marketplace.rs index d9eb7e56d..6b4609132 100644 --- a/crates/cli/src/plugin_install/marketplace.rs +++ b/crates/cli/src/plugin_install/marketplace.rs @@ -10,7 +10,9 @@ use std::path::Path; use serde_json::{Value, json}; use crate::config::IntegrationHost; -use crate::install_generation::{GENERATION_FILE_ENV, write_new_generation}; +use crate::install_generation::{ + write_new_generation_with_token_at, write_staged_generation_with_token, +}; use crate::installer::generated_hooks; use super::state::{PluginInstallOptions, PluginLayout, remove_path, write_json}; @@ -22,7 +24,15 @@ pub(super) fn write_plugin_marketplace( relay: &Path, options: &PluginInstallOptions, ) -> Result<(), String> { - write_plugin_marketplace_for_generation(host, layout, relay, &layout.generation_fence, options) + write_plugin_marketplace_for_generation( + host, + layout, + relay, + &layout.generation_fence, + &layout.generation_lock, + true, + options, + ) } pub(super) fn write_plugin_marketplace_for_generation( @@ -30,18 +40,16 @@ pub(super) fn write_plugin_marketplace_for_generation( layout: &PluginLayout, relay: &Path, active_generation_fence: &Path, + active_generation_lock: &Path, + initialize_generation_lock: bool, options: &PluginInstallOptions, ) -> Result<(), String> { if options.dry_run { println!("write {}", layout.marketplace_manifest.display()); println!("write {}", layout.plugin_manifest.display()); - if plugin_uses_mcp(host) { - println!("write {}", layout.mcp_config.display()); - println!("write {}", layout.generation_fence.display()); - } - if plugin_has_hooks_template(host) { - println!("write {}", layout.hooks_path.display()); - } + println!("write {}", layout.mcp_config.display()); + println!("write {}", layout.generation_fence.display()); + println!("write {}", layout.hooks_path.display()); return Ok(()); } remove_path(&layout.plugin_root, options)?; @@ -52,23 +60,23 @@ pub(super) fn write_plugin_marketplace_for_generation( .unwrap_or(&layout.marketplace_root), ) .map_err(|error| format!("failed to create {}: {error}", layout.plugin_root.display()))?; - if plugin_has_hooks_template(host) { - fs::create_dir_all(layout.hooks_path.parent().unwrap_or(&layout.plugin_root)).map_err( - |error| format!("failed to create {}: {error}", layout.hooks_path.display()), - )?; - } + fs::create_dir_all(layout.hooks_path.parent().unwrap_or(&layout.plugin_root)) + .map_err(|error| format!("failed to create {}: {error}", layout.hooks_path.display()))?; write_json(&layout.marketplace_manifest, &marketplace_manifest(host))?; write_json(&layout.plugin_manifest, &plugin_manifest(host))?; - if plugin_uses_mcp(host) { - write_new_generation(&layout.generation_fence)?; - } + let generation_token = if initialize_generation_lock { + write_new_generation_with_token_at(&layout.generation_fence, active_generation_lock) + } else { + write_staged_generation_with_token(&layout.generation_fence, active_generation_lock) + }?; write_json( &layout.mcp_config, - &plugin_mcp_config(host, relay, active_generation_fence)?, + &plugin_mcp_config(host, relay, active_generation_fence, &generation_token)?, + )?; + write_json( + &layout.hooks_path, + &plugin_hooks(host, relay, active_generation_fence, &generation_token)?, )?; - if plugin_has_hooks_template(host) { - write_json(&layout.hooks_path, &plugin_hooks(host, relay))?; - } Ok(()) } @@ -148,9 +156,7 @@ pub(super) fn plugin_manifest(host: IntegrationHost) -> Value { "license": "Apache-2.0", "keywords": keywords }); - if plugin_uses_mcp(host) { - manifest["mcpServers"] = json!("./.mcp.json"); - } + manifest["mcpServers"] = json!("./.mcp.json"); if matches!(host, IntegrationHost::Codex) { manifest["interface"] = json!({ "displayName": "NeMo Relay Plugin", @@ -171,36 +177,31 @@ pub(super) fn plugin_mcp_config( host: IntegrationHost, relay: &Path, generation_fence: &Path, + generation_token: &str, ) -> Result { let generation_fence = absolute_or_self(generation_fence); - let server = match host { - IntegrationHost::Codex => json!({ - "command": relay, - "args": ["mcp", "--agent", "codex"], - "env": { - "NEMO_RELAY_GATEWAY_BIND": crate::sidecar::DEFAULT_BIND, - (GENERATION_FILE_ENV): generation_fence - }, - "env_vars": plugin_mcp_env_vars()?, - "required": true, - "startup_timeout_sec": 20 - }), - IntegrationHost::ClaudeCode => json!({ - "command": relay, - "args": ["mcp", "--agent", "claude"], - "env": { - "NEMO_RELAY_GATEWAY_BIND": crate::sidecar::DEFAULT_BIND, - (GENERATION_FILE_ENV): generation_fence - }, - "alwaysLoad": true - }), + let mut server = crate::mcp::persistent_server(relay, &generation_fence, generation_token); + let fields = server + .as_object_mut() + .expect("persistent MCP server is a JSON object"); + match host { + IntegrationHost::Codex => { + fields.insert("env_vars".into(), json!(plugin_mcp_env_vars()?)); + fields.insert("required".into(), json!(true)); + fields.insert("startup_timeout_sec".into(), json!(20)); + } + IntegrationHost::ClaudeCode => { + fields.insert("alwaysLoad".into(), json!(true)); + } IntegrationHost::Hermes | IntegrationHost::All => { unreachable!("all is expanded before MCP generation") } - }; + } Ok(match host { - IntegrationHost::Codex => json!({ "nemo-relay": server }), - IntegrationHost::ClaudeCode => json!({ "mcpServers": { "nemo-relay": server } }), + IntegrationHost::Codex => json!({ (crate::mcp::SERVER_NAME): server }), + IntegrationHost::ClaudeCode => { + json!({ "mcpServers": { (crate::mcp::SERVER_NAME): server } }) + } IntegrationHost::Hermes | IntegrationHost::All => { unreachable!("all is expanded before MCP generation") } @@ -216,14 +217,25 @@ fn absolute_or_self(path: &Path) -> std::path::PathBuf { .unwrap_or_else(|_| path.to_owned()) } -pub(super) fn plugin_hooks(host: IntegrationHost, relay: &Path) -> Value { +pub(super) fn plugin_hooks( + host: IntegrationHost, + relay: &Path, + generation_fence: &Path, + generation_token: &str, +) -> Result { let agent = host .agent() .expect("all is expanded before hook generation"); - generated_hooks( + let generation_fence = absolute_or_self(generation_fence); + Ok(generated_hooks( agent, - &crate::installer::persistent_hook_forward_command(relay, agent), - ) + &crate::installer::persistent_hook_forward_command( + relay, + agent, + &generation_fence, + generation_token, + )?, + )) } pub(super) fn plugin_mcp_env_vars() -> Result, String> { @@ -238,21 +250,3 @@ pub(super) fn plugin_mcp_env_vars_from( ) -> Vec { crate::mcp_environment::forwarded_names(environment, config) } - -pub(super) fn plugin_has_hooks_template(host: IntegrationHost) -> bool { - match host { - IntegrationHost::Codex | IntegrationHost::ClaudeCode => true, - IntegrationHost::Hermes | IntegrationHost::All => { - unreachable!("all is expanded before hook generation") - } - } -} - -pub(super) fn plugin_uses_mcp(host: IntegrationHost) -> bool { - match host { - IntegrationHost::Codex | IntegrationHost::ClaudeCode => true, - IntegrationHost::Hermes | IntegrationHost::All => { - unreachable!("all is expanded before MCP generation") - } - } -} diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/plugin_install/mod.rs index ff45e18a9..e5416dcd9 100644 --- a/crates/cli/src/plugin_install/mod.rs +++ b/crates/cli/src/plugin_install/mod.rs @@ -28,14 +28,16 @@ use host::{ run_host_plugin_removal, validate_relay_hook_forward, validate_relay_mcp, }; use marketplace::{ - marketplace_manifest, plugin_hooks, plugin_manifest, plugin_mcp_config, plugin_uses_mcp, + marketplace_manifest, plugin_hooks, plugin_manifest, plugin_mcp_config, write_plugin_marketplace, write_plugin_marketplace_for_generation, }; use operation_lock::{DEFAULT_OPERATION_LOCK_TIMEOUT, PluginOperationLock}; use setup::{ - PluginSetupRunner, PluginSetupSnapshot, RealPluginSetupRunner, run_plugin_doctor, - run_plugin_doctor_json, run_plugin_setup, run_plugin_uninstall, + PluginSetupRunner, PluginSetupSnapshot, RealPluginSetupRunner, run_plugin_doctor_json, + run_plugin_doctor_with_generation, run_plugin_setup_with_generation, run_plugin_uninstall, }; +#[cfg(test)] +use setup::{run_plugin_doctor, run_plugin_setup}; use state::{ CanonicalizeOrSelf, HostRegistrationProgress, HostSelectionMode, PluginInstallOptions, PluginLayout, PluginState, default_install_dir, mark_plugin_setup_installed, read_state, @@ -635,13 +637,11 @@ fn install_host_locked( ) -> Result<(), String> { let relay = require_relay(options, runner)?; validate_relay_hook_forward(&relay, options, runner)?; - if plugin_uses_mcp(host) { - validate_relay_mcp(&relay, options, runner)?; - } + validate_relay_mcp(&relay, options, runner)?; require_host_cli(host, options, runner)?; host::validate_host_version(host, options, runner)?; let layout = PluginLayout::new(host, &options.install_dir); - let plugin_preflight = if !options.dry_run && plugin_uses_mcp(host) { + let plugin_preflight = if !options.dry_run { Some(prepare_plugin_install(host, &layout, options, runner)?) } else { None @@ -654,9 +654,31 @@ fn install_host_locked( return Err(existing_plugin_install_requires_force_error(host)); } let mut force_snapshot = None; - let staged = if options.force && !options.dry_run && plugin_uses_mcp(host) { + let mut replacement_generation_lock = None; + let staged = if options.force && !options.dry_run { let preflight = plugin_preflight.expect("MCP plugin force install has preflight state"); - let staged = stage_plugin_marketplace(host, &relay, &layout, options)?; + let initialize_generation_lock = match preflight.generation_retirement.as_ref() { + Some(retirement) => !retirement.uses_lock_path(&layout.generation_lock)?, + None => true, + }; + let staged = + stage_plugin_marketplace(host, &relay, &layout, initialize_generation_lock, options)?; + if initialize_generation_lock { + replacement_generation_lock = match acquire_replacement_generation_lock( + host, + &staged.layout.generation_fence, + staged.generation_lock_created, + ) { + Ok(lock) => Some(lock), + Err(error) => { + staged.cleanup(); + if staged.generation_lock_created { + remove_generation_lock_best_effort(&layout.generation_lock); + } + return Err(error); + } + }; + } match begin_force_replacement(host, &layout, preflight, options, runner, setup_runner) { Ok(mut snapshot) => { if let Err(error) = setup_runner.refresh_gateway() { @@ -683,13 +705,14 @@ fn install_host_locked( None }; if options.force && staged.is_none() { - if !options.dry_run && plugin_uses_mcp(host) { + if !options.dry_run { setup_runner.refresh_gateway()?; } force_cleanup_existing_install(host, &layout, options, runner, setup_runner)?; } if let Some(staged) = staged.as_ref() { if let Err(error) = staged.promote(&layout) { + staged.cleanup(); return restore_force_replacement_after_error( host, &layout, @@ -704,13 +727,77 @@ fn install_host_locked( .as_mut() .expect("force snapshot exists") .replacement_promoted = true; + if let Some(lock) = replacement_generation_lock.as_mut() + && let Err(error) = lock.retarget_promoted_marker(&layout.generation_fence) + { + staged.cleanup(); + return restore_force_replacement_after_error( + host, + &layout, + force_snapshot.as_mut().expect("force snapshot exists"), + options, + runner, + setup_runner, + error, + ); + } staged.cleanup(); } else { - write_plugin_marketplace(host, &layout, &relay, options)?; + let generation_lock_created = + !options.dry_run && generation_lock_is_absent(&layout.generation_lock); + if let Err(error) = write_plugin_marketplace(host, &layout, &relay, options) { + let cleanup_error = (!options.dry_run) + .then(|| remove_path(&layout.marketplace_root, options).err()) + .flatten(); + if generation_lock_created { + remove_generation_lock_best_effort(&layout.generation_lock); + } + return match cleanup_error { + Some(cleanup_error) => Err(format!( + "{error}; additionally failed to remove the incomplete marketplace: {cleanup_error}" + )), + None => Err(error), + }; + } + if !options.dry_run { + replacement_generation_lock = match acquire_replacement_generation_lock( + host, + &layout.generation_fence, + generation_lock_created, + ) { + Ok(lock) => Some(lock), + Err(error) => { + let cleanup_error = remove_path(&layout.marketplace_root, options).err(); + if generation_lock_created { + remove_generation_lock_best_effort(&layout.generation_lock); + } + return match cleanup_error { + Some(cleanup_error) => Err(format!( + "{error}; additionally failed to remove the incomplete marketplace: {cleanup_error}" + )), + None => Err(error), + }; + } + }; + } } if let Err(error) = write_state(&layout, options) { let _replacement_retirement = if force_snapshot.is_some() { - match retire_replacement_before_rollback(host, &layout, options, setup_runner) { + let existing_retirement = replacement_generation_lock + .as_mut() + .map(ReplacementGenerationLock::retirement_mut) + .or_else(|| { + force_snapshot + .as_mut() + .and_then(|snapshot| snapshot.generation_retirement.as_mut()) + }); + match retire_replacement_before_rollback( + host, + &layout, + options, + setup_runner, + existing_retirement, + ) { Ok(retirement) => retirement, Err(retirement_error) => { return Err(format!( @@ -735,27 +822,81 @@ fn install_host_locked( return Err(error); } let mut registration = HostRegistrationProgress::default(); + let mut registration_state_uncertain = false; let mut setup_installed = false; let result = (|| { - run_host_marketplace_registration(host, &layout.marketplace_root, options, runner)?; + let generation_token = replacement_generation_lock + .as_ref() + .map(ReplacementGenerationLock::retirement) + .or_else(|| { + force_snapshot + .as_ref() + .and_then(|snapshot| snapshot.generation_retirement.as_ref()) + }) + .map(GenerationRetirement::active_visible_token) + .transpose()?; + if let Err(error) = + run_host_marketplace_registration(host, &layout.marketplace_root, options, runner) + { + registration_state_uncertain = true; + return Err(error); + } registration.host_marketplace_added = true; - run_host_plugin_registration(host, options, runner)?; + if let Err(error) = run_host_plugin_registration(host, options, runner) { + registration_state_uncertain = true; + return Err(error); + } registration.host_plugin_added = true; if !matches!(host, IntegrationHost::Codex) { setup_installed = true; } - run_plugin_setup(host, &layout, options, setup_runner)?; + run_plugin_setup_with_generation( + host, + &layout, + options, + setup_runner, + generation_token.as_deref(), + )?; setup_installed = true; mark_plugin_setup_installed(host, &layout, options)?; if !options.skip_doctor { - run_plugin_doctor(host, &layout.plugin_root, options, setup_runner)?; + run_plugin_doctor_with_generation( + host, + &layout.plugin_root, + options, + setup_runner, + generation_token.as_deref(), + )?; } Ok(()) })(); if let Err(error) = result { + if registration_state_uncertain { + let observed = host_registration_report(host, options, runner).map_err(|report_error| { + format!( + "{error}; refusing destructive rollback because the host registration state could not be verified after a registration command failed: {report_error}" + ) + })?; + registration.host_plugin_added |= observed.host_plugin_registered; + registration.host_marketplace_added |= observed.host_marketplace_registered; + } let replacement_may_be_live = force_snapshot.is_some() || registration.host_plugin_added; let _replacement_retirement = if replacement_may_be_live { - match retire_replacement_before_rollback(host, &layout, options, setup_runner) { + let existing_retirement = replacement_generation_lock + .as_mut() + .map(ReplacementGenerationLock::retirement_mut) + .or_else(|| { + force_snapshot + .as_mut() + .and_then(|snapshot| snapshot.generation_retirement.as_mut()) + }); + match retire_replacement_before_rollback( + host, + &layout, + options, + setup_runner, + existing_retirement, + ) { Ok(retirement) => retirement, Err(retirement_error) => { return Err(format!( @@ -795,7 +936,7 @@ fn install_host_locked( return Err(error); } if let Some(snapshot) = force_snapshot { - snapshot.commit(); + snapshot.commit(&layout.generation_lock); } println!( "installed {} plugin marketplace at {}", @@ -863,7 +1004,9 @@ fn uninstall_host_locked( ) })?; } - if let Err(error) = setup_runner.refresh_gateway() { + if !options.dry_run + && let Err(error) = setup_runner.refresh_gateway() + { if let Some(retirement) = generation_retirement.as_mut() && let Err(restore_error) = retirement.restore_after_rollback() { @@ -873,7 +1016,21 @@ fn uninstall_host_locked( } return Err(error); } - uninstall_host_with_setup_override(host, options, runner, setup_runner, false) + let retired_lock = generation_retirement + .as_ref() + .map(|retirement| retirement.lock_path().to_owned()); + if let Some(retirement) = generation_retirement.as_mut() { + retirement.release_legacy_lock_for_tree_mutation()?; + retirement.commit_replacement(); + } + let result = uninstall_host_with_setup_override(host, options, runner, setup_runner, false); + if result.is_ok() { + drop(generation_retirement); + if let Some(lock_path) = retired_lock { + remove_generation_lock_best_effort(&lock_path); + } + } + result } fn retire_installed_generation( @@ -883,7 +1040,7 @@ fn retire_installed_generation( options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result, String> { - if options.dry_run || !plugin_uses_mcp(host) { + if options.dry_run { return Ok(None); } let generation_fence = plugin_root.join(GENERATION_FILE_NAME); @@ -914,8 +1071,28 @@ fn retire_replacement_before_rollback( layout: &PluginLayout, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, + existing_retirement: Option<&mut GenerationRetirement>, ) -> Result, String> { - if options.dry_run || !plugin_uses_mcp(host) { + if options.dry_run { + return Ok(None); + } + if let Some(retirement) = existing_retirement + && retirement.uses_lock_path(&layout.generation_lock)? + { + let visible = retirement.retire_visible_replacement().map_err(|error| { + format!( + "failed to retire replacement MCP generation {} before rollback: {error}", + layout.generation_fence.display() + ) + })?; + if let Err(error) = setup_runner.refresh_gateway() { + return match retirement.restore_visible_replacement(visible) { + Ok(()) => Err(error), + Err(restore_error) => Err(format!( + "{error}; additionally failed to restore the replacement MCP generation after rollback refresh failed: {restore_error}" + )), + }; + } return Ok(None); } let mut retirement = GenerationRetirement::acquire(&layout.generation_fence) @@ -927,7 +1104,15 @@ fn retire_replacement_before_rollback( layout.generation_fence.display() ) })?; - setup_runner.refresh_gateway()?; + if let Err(error) = setup_runner.refresh_gateway() { + return match retirement.restore_after_rollback() { + Ok(()) => Err(error), + Err(restore_error) => Err(format!( + "{error}; additionally failed to restore the replacement MCP generation after rollback refresh failed: {restore_error}" + )), + }; + } + retirement.commit_replacement(); Ok(Some(retirement)) } @@ -1164,33 +1349,40 @@ fn collect_host_plugin_readiness( .map(|_| "hook-forward is supported".into()), ); if let Some(plugin) = readiness.plugin.as_ref() { + let generation_fence = plugin.join(crate::install_generation::GENERATION_FILE_NAME); readiness.push( "Generated hooks", - generated_manifest_check( - &plugin.join("hooks").join("hooks.json"), - &plugin_hooks(host, &relay), - "hooks", - ), + InstallGeneration::capture(generation_fence.clone()).and_then(|generation| { + let expected = + plugin_hooks(host, &relay, &generation_fence, generation.token())?; + generated_manifest_check( + &plugin.join("hooks").join("hooks.json"), + &expected, + "hooks", + ) + }), ); } - if plugin_uses_mcp(host) { + readiness.push( + "Relay MCP support", + validate_relay_mcp(&relay, options, runner) + .map(|_| "native mcp subcommand is supported".into()), + ); + if let Some(plugin) = readiness.plugin.as_ref() { + let generation_fence = plugin.join(crate::install_generation::GENERATION_FILE_NAME); + let mcp_config = plugin_mcp_config_path(plugin); readiness.push( - "Relay MCP support", - validate_relay_mcp(&relay, options, runner) - .map(|_| "native mcp subcommand is supported".into()), + "MCP generation fence", + InstallGeneration::capture(generation_fence.clone()) + .map(|_| format!("valid generation at {}", generation_fence.display())), ); - if let Some(plugin) = readiness.plugin.as_ref() { - let generation_fence = plugin.join(crate::install_generation::GENERATION_FILE_NAME); - let mcp_config = plugin_mcp_config_path(plugin); - readiness.push( - "MCP generation fence", - InstallGeneration::capture(generation_fence.clone()) - .map(|_| format!("valid generation at {}", generation_fence.display())), - ); - let check = plugin_mcp_config(host, &relay, &generation_fence) - .and_then(|expected| generated_mcp_config_check(host, &mcp_config, &expected)); - readiness.push("Generated MCP server", check); - } + let check = + InstallGeneration::capture(generation_fence.clone()).and_then(|generation| { + plugin_mcp_config(host, &relay, &generation_fence, generation.token()).and_then( + |expected| generated_mcp_config_check(host, &mcp_config, &expected), + ) + }); + readiness.push("Generated MCP server", check); } } @@ -1315,6 +1507,15 @@ fn generated_mcp_config_check( host: IntegrationHost, path: &Path, expected: &Value, +) -> Result { + generated_mcp_config_check_for_platform(host, path, expected, cfg!(windows)) +} + +fn generated_mcp_config_check_for_platform( + host: IntegrationHost, + path: &Path, + expected: &Value, + windows: bool, ) -> Result { let raw = std::fs::read_to_string(path).map_err(|error| { format!( @@ -1327,35 +1528,57 @@ fn generated_mcp_config_check( if actual == *expected { return Ok(format!("valid at {}", path.display())); } - let expected_server = match host { - IntegrationHost::Codex => &expected["nemo-relay"], - IntegrationHost::ClaudeCode => &expected["mcpServers"]["nemo-relay"], - IntegrationHost::Hermes | IntegrationHost::All => { - unreachable!("all is expanded before MCP validation") - } + if !matches!(host, IntegrationHost::Codex) { + return Err(format!( + "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", + path.display(), + host.as_arg() + )); + } + let expected_server = &expected["nemo-relay"]; + let actual_server = &actual["nemo-relay"]; + let Some(expected_vars) = mcp_env_var_names(expected_server) else { + return Err(format!( + "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", + path.display(), + host.as_arg() + )); }; - let actual_server = match host { - IntegrationHost::Codex => &actual["nemo-relay"], - IntegrationHost::ClaudeCode => &actual["mcpServers"]["nemo-relay"], - IntegrationHost::Hermes | IntegrationHost::All => { - unreachable!("all is expanded before MCP validation") - } + let Some(actual_vars) = mcp_env_var_names(actual_server) else { + return Err(format!( + "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", + path.display(), + host.as_arg() + )); }; - let expected_vars = expected_server["env_vars"] - .as_array() - .into_iter() - .flatten() - .filter_map(Value::as_str) - .collect::>(); - let actual_vars = actual_server["env_vars"] - .as_array() - .into_iter() - .flatten() - .filter_map(Value::as_str) - .collect::>(); + let duplicate = actual_vars.iter().enumerate().any(|(index, name)| { + actual_vars[..index].iter().any(|other| { + crate::mcp_environment::forwarded_names_match_for_platform(name, other, windows) + }) + }); + if duplicate + || actual_vars.iter().any(|name| { + !expected_vars.iter().any(|expected| { + crate::mcp_environment::forwarded_names_match_for_platform(name, expected, windows) + }) && !crate::mcp_environment::previously_forwardable_name_for_platform(name, windows) + }) + { + return Err(format!( + "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", + path.display(), + host.as_arg() + )); + } let missing = expected_vars - .difference(&actual_vars) - .copied() + .iter() + .filter(|expected| { + !actual_vars.iter().any(|actual| { + crate::mcp_environment::forwarded_names_match_for_platform( + expected, actual, windows, + ) + }) + }) + .map(String::as_str) .collect::>(); if !missing.is_empty() { return Err(format!( @@ -1365,12 +1588,37 @@ fn generated_mcp_config_check( host.as_arg() )); } + let mut expected_without_vars = expected.clone(); + let mut actual_without_vars = actual.clone(); + let expected_server = expected_without_vars + .get_mut("nemo-relay") + .and_then(Value::as_object_mut); + let actual_server = actual_without_vars + .get_mut("nemo-relay") + .and_then(Value::as_object_mut); + if let (Some(expected_server), Some(actual_server)) = (expected_server, actual_server) { + expected_server.remove("env_vars"); + actual_server.remove("env_vars"); + if actual_without_vars == expected_without_vars { + return Ok(format!("valid at {}", path.display())); + } + } Err(format!( - "unexpected MCP server manifest contents at {}", - path.display() + "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", + path.display(), + host.as_arg() )) } +fn mcp_env_var_names(server: &Value) -> Option> { + server + .get("env_vars")? + .as_array()? + .iter() + .map(|value| value.as_str().map(str::to_owned)) + .collect() +} + fn marketplace_manifest_path(host: IntegrationHost, root: &Path) -> PathBuf { match host { IntegrationHost::Codex => root @@ -1401,6 +1649,87 @@ fn plugin_mcp_config_path(root: &Path) -> PathBuf { struct StagedPluginMarketplace { layout: PluginLayout, parent: PathBuf, + generation_lock_created: bool, +} + +struct ReplacementGenerationLock { + retirement: Option, + lock_path: PathBuf, + remove_lock_if_unreferenced: bool, +} + +fn acquire_replacement_generation_lock( + host: IntegrationHost, + marker_path: &Path, + remove_lock_if_unreferenced: bool, +) -> Result { + match GenerationRetirement::acquire(marker_path) { + Ok(Some(retirement)) => Ok(ReplacementGenerationLock::new( + retirement, + remove_lock_if_unreferenced, + )), + Ok(None) => Err(missing_generation_fence_error(host, marker_path)), + Err(cause) => Err(invalid_generation_fence_error(host, marker_path, &cause)), + } +} + +impl ReplacementGenerationLock { + fn new(retirement: GenerationRetirement, remove_lock_if_unreferenced: bool) -> Self { + Self { + lock_path: retirement.lock_path().to_owned(), + retirement: Some(retirement), + remove_lock_if_unreferenced, + } + } + + fn retirement_mut(&mut self) -> &mut GenerationRetirement { + self.retirement + .as_mut() + .expect("replacement generation transaction remains present") + } + + fn retirement(&self) -> &GenerationRetirement { + self.retirement + .as_ref() + .expect("replacement generation transaction remains present") + } + + fn retarget_promoted_marker(&mut self, marker_path: &Path) -> Result<(), String> { + self.retirement_mut().retarget_promoted_marker(marker_path) + } +} + +impl Drop for ReplacementGenerationLock { + fn drop(&mut self) { + let remove_owned_lock = self.remove_lock_if_unreferenced + && self.retirement.as_ref().is_some_and(|retirement| { + match fs::metadata(retirement.marker_path()) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(error) => { + eprintln!( + "warning: retaining MCP generation lock {} because marker {} could not be inspected: {error}", + self.lock_path.display(), + retirement.marker_path().display() + ); + false + } + Ok(_) => match retirement.visible_marker_uses_transaction_lock() { + Ok(referenced) => !referenced, + Err(error) => { + eprintln!( + "warning: retaining MCP generation lock {} because its marker reference could not be verified: {error}", + self.lock_path.display() + ); + false + } + }, + } + }); + drop(self.retirement.take()); + if remove_owned_lock { + remove_generation_lock_best_effort(&self.lock_path); + } + } } impl StagedPluginMarketplace { @@ -1540,7 +1869,17 @@ impl ForceInstallSnapshot { .starts_with(&self.original_marketplace_root) } - fn commit(self) { + fn commit(mut self, replacement_lock: &Path) { + let obsolete_lock = self.generation_retirement.as_ref().and_then(|retirement| { + retirement + .uses_lock_path(replacement_lock) + .ok() + .filter(|same| !same) + .map(|_| retirement.lock_path().to_owned()) + }); + if let Some(retirement) = self.generation_retirement.as_mut() { + retirement.commit_replacement(); + } if self.marketplace_moved { match fs::remove_dir_all(&self.backup_marketplace_root) { Ok(()) => {} @@ -1563,13 +1902,36 @@ impl ForceInstallSnapshot { ), } } + drop(self.generation_retirement.take()); + if let Some(lock_path) = obsolete_lock { + remove_generation_lock_best_effort(&lock_path); + } } } +fn remove_generation_lock_best_effort(path: &Path) { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => eprintln!( + "warning: failed to remove retired MCP generation lock {}: {error}", + path.display() + ), + } +} + +fn generation_lock_is_absent(path: &Path) -> bool { + matches!( + fs::symlink_metadata(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ) +} + fn stage_plugin_marketplace( host: IntegrationHost, relay: &Path, target: &PluginLayout, + initialize_generation_lock: bool, options: &PluginInstallOptions, ) -> Result { let parent = options.install_dir.join(format!( @@ -1577,18 +1939,47 @@ fn stage_plugin_marketplace( host.as_arg(), uuid::Uuid::now_v7() )); + stage_plugin_marketplace_at( + host, + relay, + target, + initialize_generation_lock, + options, + parent, + ) +} + +fn stage_plugin_marketplace_at( + host: IntegrationHost, + relay: &Path, + target: &PluginLayout, + initialize_generation_lock: bool, + options: &PluginInstallOptions, + parent: PathBuf, +) -> Result { let layout = PluginLayout::new(host, &parent); + let generation_lock_created = + initialize_generation_lock && generation_lock_is_absent(&target.generation_lock); if let Err(error) = write_plugin_marketplace_for_generation( host, &layout, relay, &target.generation_fence, + &target.generation_lock, + initialize_generation_lock, options, ) { let _ = fs::remove_dir_all(&parent); + if generation_lock_created { + remove_generation_lock_best_effort(&target.generation_lock); + } return Err(error); } - Ok(StagedPluginMarketplace { layout, parent }) + Ok(StagedPluginMarketplace { + layout, + parent, + generation_lock_created, + }) } fn begin_force_replacement( @@ -1655,13 +2046,24 @@ fn begin_force_replacement( }); cleanup_state.host_plugin_removed = !plugin_registered; cleanup_state.host_marketplace_removed = !marketplace_registered; - let result = run_host_unregistration( - host, - &mut cleanup_state, - &options.install_dir, - options, - runner, - ) + let result = (|| { + if cleanup_state.plugin_setup_installed { + run_plugin_uninstall( + host, + &cleanup_state.plugin_root, + options, + setup_runner, + )?; + cleanup_state.plugin_setup_installed = false; + } + run_host_unregistration( + host, + &mut cleanup_state, + &options.install_dir, + options, + runner, + ) + })() .and_then(|()| { if let Some(retirement) = snapshot.generation_retirement.as_mut() { retirement.invalidate_for_replacement().map_err(|error| { @@ -1670,6 +2072,14 @@ fn begin_force_replacement( snapshot.original_generation_fence.display() ) })?; + retirement + .release_legacy_lock_for_tree_mutation() + .map_err(|error| { + format!( + "failed to release previous MCP generation {} before moving its plugin tree: {error}", + snapshot.original_generation_fence.display() + ) + })?; } if snapshot.original_marketplace_root.exists() { fs::rename( diff --git a/crates/cli/src/plugin_install/setup.rs b/crates/cli/src/plugin_install/setup.rs index c8931977d..8e5544396 100644 --- a/crates/cli/src/plugin_install/setup.rs +++ b/crates/cli/src/plugin_install/setup.rs @@ -12,17 +12,33 @@ use super::DEFAULT_GATEWAY_URL; use super::state::PluginInstallOptions; use super::state::PluginLayout; +#[cfg(test)] pub(super) fn run_plugin_setup( host: IntegrationHost, layout: &PluginLayout, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + run_plugin_setup_with_generation(host, layout, options, setup_runner, None) +} + +pub(super) fn run_plugin_setup_with_generation( + host: IntegrationHost, + layout: &PluginLayout, + options: &PluginInstallOptions, + setup_runner: &dyn PluginSetupRunner, + generation_token: Option<&str>, ) -> Result<(), String> { if options.dry_run { println!("{}", setup_action_description(host, "configure")); return Ok(()); } - setup_runner.setup(host, DEFAULT_GATEWAY_URL, &layout.plugin_root) + setup_runner.setup_with_generation( + host, + DEFAULT_GATEWAY_URL, + &layout.plugin_root, + generation_token, + ) } pub(super) fn run_plugin_uninstall( @@ -38,17 +54,28 @@ pub(super) fn run_plugin_uninstall( setup_runner.uninstall(host, DEFAULT_GATEWAY_URL, plugin_root) } +#[cfg(test)] pub(super) fn run_plugin_doctor( host: IntegrationHost, plugin_root: &Path, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + run_plugin_doctor_with_generation(host, plugin_root, options, setup_runner, None) +} + +pub(super) fn run_plugin_doctor_with_generation( + host: IntegrationHost, + plugin_root: &Path, + options: &PluginInstallOptions, + setup_runner: &dyn PluginSetupRunner, + generation_token: Option<&str>, ) -> Result<(), String> { if options.dry_run { println!("{}", setup_action_description(host, "doctor")); return Ok(()); } - setup_runner.doctor(host, DEFAULT_GATEWAY_URL, plugin_root) + setup_runner.doctor_with_generation(host, DEFAULT_GATEWAY_URL, plugin_root, generation_token) } pub(super) fn run_plugin_doctor_json( @@ -97,6 +124,15 @@ pub(super) trait PluginSetupRunner { gateway_url: &str, plugin_root: &Path, ) -> Result<(), String>; + fn setup_with_generation( + &self, + host: IntegrationHost, + gateway_url: &str, + plugin_root: &Path, + _generation_token: Option<&str>, + ) -> Result<(), String> { + self.setup(host, gateway_url, plugin_root) + } fn uninstall( &self, host: IntegrationHost, @@ -109,6 +145,15 @@ pub(super) trait PluginSetupRunner { gateway_url: &str, plugin_root: &Path, ) -> Result<(), String>; + fn doctor_with_generation( + &self, + host: IntegrationHost, + gateway_url: &str, + plugin_root: &Path, + _generation_token: Option<&str>, + ) -> Result<(), String> { + self.doctor(host, gateway_url, plugin_root) + } fn doctor_json( &self, host: IntegrationHost, @@ -159,9 +204,23 @@ impl PluginSetupRunner for RealPluginSetupRunner { host: IntegrationHost, gateway_url: &str, plugin_root: &Path, + ) -> Result<(), String> { + self.setup_with_generation(host, gateway_url, plugin_root, None) + } + + fn setup_with_generation( + &self, + host: IntegrationHost, + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, ) -> Result<(), String> { match host { - IntegrationHost::Codex => plugin_host::install_codex_plugin(gateway_url, plugin_root), + IntegrationHost::Codex => plugin_host::install_codex_plugin_with_generation( + gateway_url, + plugin_root, + generation_token, + ), IntegrationHost::ClaudeCode => plugin_host::enable_claude_provider(gateway_url), IntegrationHost::Hermes | IntegrationHost::All => { unreachable!("all is expanded before plugin setup") @@ -189,11 +248,24 @@ impl PluginSetupRunner for RealPluginSetupRunner { host: IntegrationHost, gateway_url: &str, plugin_root: &Path, + ) -> Result<(), String> { + self.doctor_with_generation(host, gateway_url, plugin_root, None) + } + + fn doctor_with_generation( + &self, + host: IntegrationHost, + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, ) -> Result<(), String> { match host { - IntegrationHost::Codex => { - plugin_host::doctor_plugin(CodingAgent::Codex, gateway_url, plugin_root) - } + IntegrationHost::Codex => plugin_host::doctor_plugin_with_generation( + CodingAgent::Codex, + gateway_url, + plugin_root, + generation_token, + ), IntegrationHost::ClaudeCode => { plugin_host::doctor_plugin(CodingAgent::ClaudeCode, gateway_url, plugin_root) } diff --git a/crates/cli/src/plugin_install/state.rs b/crates/cli/src/plugin_install/state.rs index b98c5a96a..f1155501c 100644 --- a/crates/cli/src/plugin_install/state.rs +++ b/crates/cli/src/plugin_install/state.rs @@ -51,6 +51,7 @@ pub(super) struct PluginLayout { pub(super) plugin_manifest: PathBuf, pub(super) mcp_config: PathBuf, pub(super) generation_fence: PathBuf, + pub(super) generation_lock: PathBuf, pub(super) hooks_path: PathBuf, pub(super) state_path: PathBuf, } @@ -80,6 +81,8 @@ impl PluginLayout { }; let mcp_config = plugin_root.join(".mcp.json"); let generation_fence = plugin_root.join(GENERATION_FILE_NAME); + let generation_lock = + install_dir.join(format!(".nemo-relay-{}-mcp-generation.lock", host.as_arg())); let hooks_path = plugin_root.join("hooks").join("hooks.json"); let state_path = state_path(host, install_dir); Self { @@ -90,6 +93,7 @@ impl PluginLayout { plugin_manifest, mcp_config, generation_fence, + generation_lock, hooks_path, state_path, } @@ -253,7 +257,7 @@ pub(super) fn write_json(path: &Path, value: &Value) -> Result<(), String> { } let mut bytes = serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?; bytes.push(b'\n'); - fs::write(path, bytes).map_err(|error| format!("failed to write {}: {error}", path.display())) + crate::file_io::atomic_write(path, &bytes) } pub(super) fn remove_path(path: &Path, options: &PluginInstallOptions) -> Result<(), String> { diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index d3d9c5dc9..02ff32f45 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -47,6 +47,7 @@ pub(crate) struct AppState { pub(crate) config: GatewayConfig, pub(crate) bootstrap_fingerprint: Option, pub(crate) bootstrap_challenge_key: Option, + pub(crate) require_provider_client_token: bool, pub(crate) http: Client, pub(crate) sessions: SessionManager, pub(crate) last_activity: Arc>, @@ -170,6 +171,7 @@ pub(crate) async fn serve_listener_with_bootstrap( } /// Serves the gateway router and activates enabled dynamic plugin components. +#[cfg(test)] pub(crate) async fn serve_listener_with_dynamic( listener: TcpListener, config: GatewayConfig, @@ -188,6 +190,27 @@ pub(crate) async fn serve_listener_with_dynamic( .await } +/// Serves a wrapper-owned dynamic gateway with authenticated health while keeping foreground +/// provider-auth semantics. Plugin-owned MCP clients use the proof to borrow only this instance. +pub(crate) async fn serve_transparent_listener_with_dynamic( + listener: TcpListener, + config: GatewayConfig, + dynamic_plugins: Vec, + bootstrap_fingerprint: String, + shutdown: Option>, +) -> Result<(), CliError> { + serve_listener_with_dynamic_inner( + listener, + config, + dynamic_plugins, + Some(bootstrap_fingerprint), + None, + shutdown.map(ShutdownMode::Receiver), + None, + ) + .await +} + type ShutdownFuture = Pin + Send>>; enum ShutdownMode { @@ -208,6 +231,7 @@ async fn serve_listener_with_dynamic_inner( .as_ref() .map(|_| BootstrapChallengeKey::load()) .transpose()?; + let require_provider_client_token = managed_bootstrap.is_some(); let plugin_activation = PluginActivation::initialize(config.plugin_config.clone(), dynamic_plugins).await?; let (bootstrap_shutdown, bootstrap_shutdown_rx) = bootstrap_shutdown_channel(); @@ -215,6 +239,7 @@ async fn serve_listener_with_dynamic_inner( config, bootstrap_fingerprint, bootstrap_challenge_key, + require_provider_client_token, bootstrap_shutdown, ); let instance_id = state.instance_id.clone(); @@ -331,13 +356,14 @@ pub(crate) fn router(config: GatewayConfig) -> Router { impl AppState { #[cfg(test)] fn new(config: GatewayConfig) -> Self { - Self::new_with_bootstrap(config, None, None, None) + Self::new_with_bootstrap(config, None, None, false, None) } fn new_with_bootstrap( config: GatewayConfig, bootstrap_fingerprint: Option, bootstrap_challenge_key: Option, + require_provider_client_token: bool, bootstrap_shutdown: Option, ) -> Self { let sessions = SessionManager::new(config.clone()); @@ -352,6 +378,7 @@ impl AppState { config, bootstrap_fingerprint, bootstrap_challenge_key, + require_provider_client_token, http, sessions, last_activity: Arc::new(Mutex::new(Instant::now())), @@ -371,7 +398,7 @@ impl AppState { /// present the private per-user proof installed into their provider configuration before Relay /// can spend a forwarded credential on their behalf. pub(crate) fn allows_environment_provider_auth(&self, headers: &HeaderMap) -> bool { - if self.bootstrap_fingerprint.is_none() { + if !self.require_provider_client_token { return true; } let Some(key) = self.bootstrap_challenge_key.as_ref() else { diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs index f90bd8554..7db6b3eb7 100644 --- a/crates/cli/src/sidecar.rs +++ b/crates/cli/src/sidecar.rs @@ -17,22 +17,22 @@ use std::sync::{OnceLock, mpsc}; use std::thread; use std::time::{Duration, Instant}; -use crate::config::{CodingAgent, ServerArgs, resolve_persistent_server_config}; +use crate::config::{ServerArgs, resolve_persistent_server_config}; use crate::error::CliError; -use crate::file_io::{LockAttempt, try_lock_exclusive}; -#[cfg(test)] -pub(crate) use health::healthz_compatible; +use crate::file_io::{LockAttempt, try_lock_exclusive, try_lock_shared}; use health::{ RelayHealth, probe_after_lock as probe_relay_health_after_lock, probe_with_instance as probe_relay_health_with_instance, }; -pub(crate) use health::{healthz, loopback_bind}; +pub(crate) use health::{ + VerifiedHttpError, VerifiedHttpResponse, authenticated_instance_id, healthz, + healthz_compatible, loopback_bind, +}; use process::DetachedSidecarProcess; #[cfg(all(windows, not(test)))] use process::SidecarJob; #[cfg(all(test, windows))] pub(crate) use process::SidecarJob; -pub(crate) use process::configure_detached_sidecar; pub(crate) use process::join_sidecar_job_from_env; #[cfg(all(test, unix))] pub(crate) use process::terminate_sidecar_process_tree; @@ -43,12 +43,13 @@ pub(crate) use process::{ WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, terminate_unready_sidecar, windows_sidecar_creation_flags, }; +pub(crate) use process::{configure_detached_sidecar, spawn_detached_sidecar}; pub(crate) use state::BOOTSTRAP_STATE_DIR_ENV; pub(crate) use state::EndpointLease; use state::{ - RecoveryEpoch, create_private_runtime_dir, open_lock as open_sidecar_lock, read_owner_record, - read_ready_file as read_sidecar_ready_file, read_recovery_epoch, runtime_dir, - write_recovery_epoch, + RecoveryEpoch, create_private_runtime_dir, lock_endpoint_shared, + open_lock as open_sidecar_lock, read_owner_record, read_ready_file as read_sidecar_ready_file, + read_recovery_epoch, runtime_dir, validate_recovery_cohort, write_recovery_epoch, }; pub(crate) use state::{ lock_endpoint as lock_sidecar_endpoint, lock_path as sidecar_lock_path, @@ -63,7 +64,8 @@ pub(crate) use state::{ write_owner as write_sidecar_owner, }; pub(crate) use state::{ - publish_owner_from_env as publish_sidecar_owner_from_env, stop_owned as stop_owned_sidecar, + publish_owner_from_env as publish_sidecar_owner_from_env, + stop_owned_and_reset as stop_owned_sidecar_and_reset, }; pub(crate) const DEFAULT_BIND: &str = "127.0.0.1:47632"; @@ -82,10 +84,27 @@ pub(crate) struct GatewayEndpoint { pub(crate) struct GatewayAcquisition { pub(crate) endpoint: GatewayEndpoint, - pub(crate) lease: Option, + pub(crate) lease: EndpointLease, pub(crate) spec: GatewaySpec, } +/// Endpoint transaction lock retained while a hook request is in flight. +pub(crate) struct GatewayCohortGuard { + _lock: fs::File, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum GatewayCohortStatus { + Current, + UpdateInProgress, +} + +impl GatewayAcquisition { + pub(crate) fn guard_cohort(&self) -> Result { + guard_gateway_cohort(&self.endpoint.url, self.lease.cohort_id()) + } +} + /// Complete launch and compatibility contract for one shared gateway. /// /// Callers construct this once and use the same value for discovery, startup, health checks, and @@ -93,7 +112,6 @@ pub(crate) struct GatewayAcquisition { /// identity and later adopted under another. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct GatewaySpec { - agent: CodingAgent, bind: SocketAddr, sidecar_args: Vec, bootstrap_fingerprint: Option, @@ -101,9 +119,8 @@ pub(crate) struct GatewaySpec { } impl GatewaySpec { - pub(crate) fn new(agent: CodingAgent, bind: SocketAddr) -> Self { + pub(crate) fn new(bind: SocketAddr) -> Self { Self { - agent, bind, sidecar_args: Vec::new(), bootstrap_fingerprint: None, @@ -130,26 +147,83 @@ impl GatewaySpec { self.bind } - pub(crate) fn ensure(&self) -> Result { - ensure_gateway(self) - } - pub(crate) fn acquire(&self) -> Result { acquire_gateway(self) } - pub(crate) fn recover(&self, expected_instance: &str) -> Result { - recover_gateway(self, expected_instance) + #[cfg(test)] + pub(crate) fn acquire_with_lease( + &self, + lease: EndpointLease, + ) -> Result { + let url = format!("http://{}", self.bind); + let state = sidecar_state_dir()?; + acquire_fixed_gateway(self, &state, &url, lease) + } + + pub(crate) fn recover( + &self, + expected_instance: &str, + cohort_id: &str, + ) -> Result { + recover_gateway(self, expected_instance, cohort_id) } pub(crate) fn healthy_instance(&self, url: &str) -> Option { health::compatible_instance_id(url, self.bootstrap_fingerprint.as_deref()) } + + /// Checks an already-bound endpoint without starting or replacing it. + /// + /// `Ok(None)` means no listener is ready yet, which lets source-plugin hooks wait for their + /// concurrently starting MCP bootstrap. Identity or protocol failures are terminal so a + /// foreign listener never receives a lifecycle payload. + pub(crate) fn existing_healthy_instance(&self, url: &str) -> Result, String> { + match probe_relay_health_with_instance(url, self.bootstrap_fingerprint.as_deref()) { + (RelayHealth::Compatible, instance_id) => Ok(instance_id), + (RelayHealth::Unavailable, _) => Ok(None), + (RelayHealth::Incompatible, _) => Err(format!( + "an incompatible NeMo Relay gateway is already listening at {url}" + )), + (RelayHealth::Foreign, _) => Err(format!( + "a foreign process is already listening at the shared Relay gateway URL {url}" + )), + } + } + + pub(crate) fn post_verified( + &self, + url: &str, + path: &str, + headers: &[(String, String)], + body: &[u8], + timeout: Duration, + max_response_bytes: usize, + ) -> Result { + let Some(fingerprint) = self.bootstrap_fingerprint.as_deref() else { + return Err(VerifiedHttpError::missing_fingerprint()); + }; + health::post_verified( + url, + fingerprint, + path, + headers, + body, + timeout, + max_response_bytes, + ) + } } fn acquire_gateway(spec: &GatewaySpec) -> Result { + if !spec.bind.ip().is_loopback() { + return Err(format!( + "plugin sidecars require a loopback bind address, got {}", + spec.bind + )); + } if spec.bind.port() == 0 { - let endpoint = spec.ensure()?; + let endpoint = start_automatic_gateway(spec)?; let effective_spec = GatewaySpec { bind: endpoint.address, ..spec.clone() @@ -160,67 +234,126 @@ fn acquire_gateway(spec: &GatewaySpec) -> Result { &effective_spec, &state, &endpoint.url, + lease.cohort_id(), lease.fresh_epoch(), &endpoint, )?; return Ok(GatewayAcquisition { endpoint, - lease: Some(lease), + lease, spec: effective_spec, }); } let url = format!("http://{}", spec.bind); let state = sidecar_state_dir()?; let lease = EndpointLease::acquire(&state, &url)?; - let endpoint = if lease.fresh_epoch() { - spec.ensure()? - } else if let Some(epoch) = read_recovery_epoch(&state, &url)? { - spec.recover(&epoch.instance_id)? - } else { - spec.ensure()? + acquire_fixed_gateway(spec, &state, &url, lease) +} + +fn acquire_fixed_gateway( + spec: &GatewaySpec, + state: &Path, + url: &str, + lease: EndpointLease, +) -> Result { + debug_assert_ne!( + spec.bind.port(), + 0, + "fixed acquisition requires a fixed port" + ); + let runtime = runtime_dir(); + prepare_sidecar_directories(&runtime, state)?; + let lock = lock_sidecar_endpoint(state, url)?; + validate_recovery_cohort(state, url, lease.cohort_id())?; + let (health, instance_id) = + probe_relay_health_after_lock(url, spec.bootstrap_fingerprint.as_deref()); + let endpoint = match health { + RelayHealth::Compatible => compatible_endpoint(spec.bind, url.to_string(), instance_id)?, + RelayHealth::Incompatible => return Err(incompatible_relay_error(url)), + RelayHealth::Foreign => return Err(foreign_listener_error(url)), + RelayHealth::Unavailable => { + let epoch = (!lease.fresh_epoch()) + .then(|| read_recovery_epoch(state, url, lease.cohort_id())) + .transpose()? + .flatten(); + if let Some(epoch) = epoch { + restart_gateway_after_lock( + spec, + &runtime, + state, + url, + lease.cohort_id(), + &epoch.instance_id, + &lock, + )? + } else { + start_sidecar_bind(spec, &runtime, state, Some(&lock)) + .map_err(|error| sidecar_start_error(&runtime, &error))? + } + } }; - record_gateway_epoch(spec, &state, &url, lease.fresh_epoch(), &endpoint)?; + record_gateway_epoch_after_lock( + spec, + state, + url, + lease.cohort_id(), + lease.fresh_epoch(), + &endpoint, + )?; Ok(GatewayAcquisition { endpoint, - lease: Some(lease), + lease, spec: spec.clone(), }) } -fn recover_gateway(spec: &GatewaySpec, expected_instance: &str) -> Result { +fn recover_gateway( + spec: &GatewaySpec, + expected_instance: &str, + cohort_id: &str, +) -> Result { debug_assert_ne!(spec.bind.port(), 0, "acquisition resolves automatic ports"); - let agent = spec.agent; let url = format!("http://{}", spec.bind); let runtime = runtime_dir(); let state = sidecar_state_dir()?; - create_private_runtime_dir(&runtime).map_err(|error| { - sidecar_start_error( - agent, - &runtime, - &format!("failed to create {}: {error}", runtime.display()), - ) - })?; - create_private_runtime_dir(&state).map_err(|error| { - sidecar_start_error( - agent, - &runtime, - &format!("failed to create {}: {error}", state.display()), - ) - })?; + prepare_sidecar_directories(&runtime, &state)?; let lock = lock_sidecar_endpoint(&state, &url)?; + validate_recovery_cohort(&state, &url, cohort_id)?; let (health, instance_id) = probe_relay_health_after_lock(&url, spec.bootstrap_fingerprint.as_deref()); match health { RelayHealth::Compatible => { let endpoint = compatible_endpoint(spec.bind, url.clone(), instance_id)?; - reconcile_gateway_epoch(&state, &url, false, &endpoint.instance_id)?; + reconcile_gateway_epoch(&state, &url, cohort_id, false, &endpoint.instance_id)?; return Ok(endpoint); } - RelayHealth::Incompatible => return Err(incompatible_relay_error(agent, &url)), + RelayHealth::Incompatible => return Err(incompatible_relay_error(&url)), RelayHealth::Foreign => return Err(foreign_listener_error(&url)), RelayHealth::Unavailable => {} } - let mut epoch = read_recovery_epoch(&state, &url)? + let endpoint = restart_gateway_after_lock( + spec, + &runtime, + &state, + &url, + cohort_id, + expected_instance, + &lock, + )?; + record_gateway_epoch_after_lock(spec, &state, &url, cohort_id, false, &endpoint)?; + Ok(endpoint) +} + +fn restart_gateway_after_lock( + spec: &GatewaySpec, + runtime: &Path, + state: &Path, + url: &str, + cohort_id: &str, + expected_instance: &str, + lock: &fs::File, +) -> Result { + let mut epoch = read_recovery_epoch(state, url, cohort_id)? .ok_or_else(|| "shared gateway recovery epoch is missing".to_string())?; if epoch.instance_id != expected_instance { return Err(format!( @@ -235,21 +368,32 @@ fn recover_gateway(spec: &GatewaySpec, expected_instance: &str) -> Result Result<(), String> { let _lock = lock_sidecar_endpoint(state, url)?; + record_gateway_epoch_after_lock(spec, state, url, cohort_id, fresh, endpoint) +} + +fn record_gateway_epoch_after_lock( + spec: &GatewaySpec, + state: &Path, + url: &str, + cohort_id: &str, + fresh: bool, + endpoint: &GatewayEndpoint, +) -> Result<(), String> { + validate_recovery_cohort(state, url, cohort_id)?; let current = spec.healthy_instance(url).ok_or_else(|| { "shared Relay gateway disappeared before recovery was recorded".to_string() })?; @@ -258,18 +402,19 @@ fn record_gateway_epoch( } else { ¤t }; - reconcile_gateway_epoch(state, url, fresh, instance_id) + reconcile_gateway_epoch(state, url, cohort_id, fresh, instance_id) } fn reconcile_gateway_epoch( state: &Path, url: &str, + cohort_id: &str, fresh: bool, instance_id: &str, ) -> Result<(), String> { - let mut epoch = read_recovery_epoch(state, url)?; + let mut epoch = read_recovery_epoch(state, url, cohort_id)?; if fresh || epoch.is_none() { - return write_recovery_epoch(state, &RecoveryEpoch::new(url, instance_id)); + return write_recovery_epoch(state, &RecoveryEpoch::new(url, cohort_id, instance_id)); } let epoch = epoch.as_mut().expect("gateway epoch is present"); if epoch.instance_id == instance_id { @@ -292,7 +437,6 @@ pub(crate) struct PluginGatewaySpec { } pub(crate) fn resolve_plugin_gateway( - agent: CodingAgent, server_args: &ServerArgs, bind: SocketAddr, ) -> Result { @@ -319,7 +463,7 @@ pub(crate) fn resolve_plugin_gateway( .flat_map(|(flag, value)| [OsString::from(flag), OsString::from(value)]) .collect(); Ok(PluginGatewaySpec { - gateway: GatewaySpec::new(agent, bind) + gateway: GatewaySpec::new(bind) .with_launch_args(sidecar_args) .with_fingerprint(bootstrap_fingerprint) .with_user_config_scope(), @@ -328,102 +472,73 @@ pub(crate) fn resolve_plugin_gateway( } #[cfg(test)] -pub(crate) fn ensure_sidecar_bind( - agent: CodingAgent, - bind: SocketAddr, -) -> Result { - GatewaySpec::new(agent, bind).ensure() +pub(crate) fn validate_gateway_cohort(url: &str, cohort_id: &str) -> Result<(), String> { + loop { + match check_gateway_cohort(url, cohort_id)? { + GatewayCohortStatus::Current => return Ok(()), + GatewayCohortStatus::UpdateInProgress => thread::sleep(Duration::from_millis(50)), + } + } } -fn ensure_gateway(spec: &GatewaySpec) -> Result { - let agent = spec.agent; - let bind = spec.bind; - let bootstrap_fingerprint = spec.bootstrap_fingerprint.as_deref(); - if !bind.ip().is_loopback() { - return Err(format!( - "plugin sidecars require a loopback bind address, got {bind}" - )); +/// Check one committed endpoint lifecycle snapshot without blocking on an in-progress update. +pub(crate) fn check_gateway_cohort( + url: &str, + cohort_id: &str, +) -> Result { + let state = sidecar_state_dir()?; + let lock = open_sidecar_lock(&sidecar_lock_path(&state, url))?; + match try_lock_shared(&lock) + .map_err(|error| format!("failed to inspect gateway lifecycle at {url}: {error}"))? + { + LockAttempt::Contended => Ok(GatewayCohortStatus::UpdateInProgress), + LockAttempt::Acquired => { + validate_recovery_cohort(&state, url, cohort_id)?; + Ok(GatewayCohortStatus::Current) + } } - let url = format!("http://{bind}"); +} + +/// Pin one committed gateway cohort across a short-lived operation such as hook delivery. +/// +/// Unlike heartbeat validation this remains bounded: a hook must fail open/closed according to +/// its own policy instead of waiting indefinitely for an installer that is still running. +pub(crate) fn guard_gateway_cohort( + url: &str, + cohort_id: &str, +) -> Result { + let state = sidecar_state_dir()?; + let lock = lock_endpoint_shared(&state, url)?; + validate_recovery_cohort(&state, url, cohort_id)?; + Ok(GatewayCohortGuard { _lock: lock }) +} + +fn start_automatic_gateway(spec: &GatewaySpec) -> Result { + debug_assert_eq!( + spec.bind.port(), + 0, + "automatic gateway startup requires port zero" + ); let runtime = runtime_dir(); - create_private_runtime_dir(&runtime).map_err(|error| { + let state = sidecar_state_dir()?; + prepare_sidecar_directories(&runtime, &state)?; + start_sidecar_bind(spec, &runtime, &state, None) + .map_err(|error| sidecar_start_error(&runtime, &error)) +} + +fn prepare_sidecar_directories(runtime: &Path, state: &Path) -> Result<(), String> { + create_private_runtime_dir(runtime).map_err(|error| { sidecar_start_error( - agent, - &runtime, + runtime, &format!("failed to create {}: {error}", runtime.display()), ) })?; - let state = sidecar_state_dir()?; - create_private_runtime_dir(&state).map_err(|error| { + create_private_runtime_dir(state).map_err(|error| { sidecar_start_error( - agent, - &runtime, + runtime, &format!("failed to create {}: {error}", state.display()), ) - })?; - if bind.port() == 0 { - return start_sidecar_bind(spec, &runtime, &state, None) - .map_err(|error| sidecar_start_error(agent, &runtime, &error)); - } - let lock_path = sidecar_lock_path(&state, &url); - let (initial_health, initial_instance) = - probe_relay_health_with_instance(&url, bootstrap_fingerprint); - match initial_health { - RelayHealth::Compatible => { - return compatible_endpoint(bind, url, initial_instance); - } - RelayHealth::Incompatible | RelayHealth::Foreign | RelayHealth::Unavailable => {} - } - let lock = OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .open(&lock_path) - .map_err(|error| { - sidecar_start_error( - agent, - &runtime, - &format!( - "failed to open sidecar lock {}: {error}", - lock_path.display() - ), - ) - })?; - let lock_deadline = Instant::now() + SIDECAR_LOCK_TIMEOUT; - loop { - match try_lock_exclusive(&lock) { - Ok(LockAttempt::Acquired) => break, - Ok(LockAttempt::Contended) => { - if Instant::now() >= lock_deadline { - return Err(sidecar_start_error( - agent, - &runtime, - "sidecar lock timed out", - )); - } - thread::sleep(Duration::from_millis(50)); - } - Err(error) => { - return Err(sidecar_start_error( - agent, - &runtime, - &format!("failed to acquire sidecar lock: {error}"), - )); - } - } - } - let (health, instance_id) = probe_relay_health_after_lock(&url, bootstrap_fingerprint); - match health { - RelayHealth::Compatible => { - return compatible_endpoint(bind, url, instance_id); - } - RelayHealth::Incompatible => return Err(incompatible_relay_error(agent, &url)), - RelayHealth::Foreign => return Err(foreign_listener_error(&url)), - RelayHealth::Unavailable => {} - } - let result = start_sidecar_bind(spec, &runtime, &state, Some(lock)); - result.map_err(|error| sidecar_start_error(agent, &runtime, &error)) + }) } fn compatible_endpoint( @@ -445,25 +560,20 @@ fn foreign_listener_error(url: &str) -> String { ) } -fn incompatible_relay_error(agent: CodingAgent, url: &str) -> String { - let remediation = match agent { - CodingAgent::Codex => "run `nemo-relay install codex --force`", - CodingAgent::ClaudeCode => "run `nemo-relay install claude-code --force`", - CodingAgent::Hermes => "run `nemo-relay install hermes --force`", - }; +fn incompatible_relay_error(url: &str) -> String { format!( - "{url} is occupied by NeMo Relay with a different version or persistent configuration; stop it, wait for its idle shutdown, or {remediation} before retrying" + "{url} is occupied by NeMo Relay with a different version or persistent configuration; stop it, wait for its idle shutdown, or reinstall the affected integration with `nemo-relay install --force` before retrying" ) } -fn sidecar_start_error(agent: CodingAgent, runtime: &Path, error: &str) -> String { - let log_path = runtime.join(format!("{}-sidecar.log", agent.as_arg())); +fn sidecar_start_error(runtime: &Path, error: &str) -> String { + let log_path = runtime.join("gateway-sidecar.log"); format!("{error}; inspect {}", log_path.display()) } #[cfg(all(test, unix))] -pub(super) fn start_sidecar(agent: CodingAgent, url: &str, runtime: &Path) -> Result<(), String> { - let spec = GatewaySpec::new(agent, loopback_bind(url)?); +pub(super) fn start_sidecar(url: &str, runtime: &Path) -> Result<(), String> { + let spec = GatewaySpec::new(loopback_bind(url)?); start_sidecar_bind(&spec, runtime, runtime, None).map(|_| ()) } @@ -544,9 +654,8 @@ fn start_sidecar_bind( spec: &GatewaySpec, runtime: &Path, state: &Path, - mut startup_lock: Option, + startup_lock: Option<&fs::File>, ) -> Result { - let agent = spec.agent; let bind = spec.bind; let sidecar_args = &spec.sidecar_args; let bootstrap_fingerprint = spec.bootstrap_fingerprint.as_deref(); @@ -559,17 +668,16 @@ fn start_sidecar_bind( return compatible_endpoint(bind, requested_url, instance_id); } RelayHealth::Incompatible => { - return Err(incompatible_relay_error(agent, &requested_url)); + return Err(incompatible_relay_error(&requested_url)); } RelayHealth::Foreign => return Err(foreign_listener_error(&requested_url)), RelayHealth::Unavailable => {} } } let relay = relay_binary()?; - let log_path = runtime.join(format!("{}-sidecar.log", agent.as_arg())); + let log_path = runtime.join("gateway-sidecar.log"); let ready_path = runtime.join(format!( - "{}-sidecar-{}-{}.ready.json", - agent.as_arg(), + "gateway-sidecar-{}-{}.ready.json", std::process::id(), uuid::Uuid::now_v7() )); @@ -603,6 +711,8 @@ fn start_sidecar_bind( ) .env(BOOTSTRAP_STATE_DIR_ENV, state) .env("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", &shutdown_token) + .env_remove(crate::install_generation::GENERATION_FILE_ENV) + .env_remove(crate::install_generation::GENERATION_TOKEN_ENV) .stdin(Stdio::null()) .stdout(Stdio::from(log)) .stderr(Stdio::from(err_log)); @@ -621,8 +731,7 @@ fn start_sidecar_bind( } } configure_detached_sidecar(&mut command); - let child = command - .spawn() + let child = spawn_detached_sidecar(&mut command) .map_err(|error| format!("failed to spawn nemo-relay sidecar: {error}"))?; let startup_pid_path = ready_path.with_extension("pid"); let _ = fs::write(&startup_pid_path, child.id().to_string()); @@ -642,10 +751,13 @@ fn start_sidecar_bind( .as_deref() == Some(endpoint.instance_id.as_str()) => { - let ownership_lock = match startup_lock.take() { - Some(lock) => lock, - None => lock_sidecar_endpoint(state, &endpoint.url)?, - }; + let owned_lock = startup_lock + .is_none() + .then(|| lock_sidecar_endpoint(state, &endpoint.url)) + .transpose()?; + let _ownership_lock = startup_lock + .or(owned_lock.as_ref()) + .expect("sidecar readiness holds an endpoint lock"); let owner_path = sidecar_owner_path(state, &endpoint.url); let pid_path = sidecar_pid_path(state, &endpoint.url); let pid = child.id(); @@ -668,7 +780,6 @@ fn start_sidecar_bind( let _ = fs::remove_file(&ready_path); return Err(error); } - drop(ownership_lock); let _ = fs::remove_file(&ready_path); return Ok(endpoint); } @@ -799,9 +910,17 @@ fn run_sidecar_reaper(receiver: mpsc::Receiver) { let mut index = 0; while index < children.len() { if !children[index].exited { - match children[index].process.try_wait() { - Ok(Some(_)) => children[index].exited = true, - Ok(None) => { + match children[index].process.has_exited_for_tree_cleanup() { + Ok(true) => { + children[index].exited = true; + if let Err(error) = children[index].process.terminate_retained_descendants() + { + eprintln!( + "failed to terminate nemo-relay sidecar descendants: {error}" + ); + } + } + Ok(false) => { index += 1; continue; } @@ -812,7 +931,6 @@ fn run_sidecar_reaper(receiver: mpsc::Receiver) { } } } - children[index].process.terminate_retained_descendants(); if cleanup_reaped_sidecar(&children[index]) { let request = children.swap_remove(index); drop(request); diff --git a/crates/cli/src/sidecar/health.rs b/crates/cli/src/sidecar/health.rs index a4f1e7563..fd15df844 100644 --- a/crates/cli/src/sidecar/health.rs +++ b/crates/cli/src/sidecar/health.rs @@ -24,15 +24,195 @@ pub(super) enum RelayHealth { Unavailable, } +#[derive(Debug)] +pub(crate) struct VerifiedHttpResponse { + pub(crate) status: u16, + pub(crate) body: Vec, +} + +#[derive(Debug)] +pub(crate) struct VerifiedHttpError { + message: String, + retryable: bool, +} + +impl VerifiedHttpError { + fn before_payload(message: impl Into) -> Self { + Self { + message: message.into(), + retryable: true, + } + } + + fn after_payload(message: impl Into) -> Self { + Self { + message: message.into(), + retryable: false, + } + } + + pub(crate) fn is_retryable(&self) -> bool { + self.retryable + } + + pub(super) fn missing_fingerprint() -> Self { + Self::after_payload("managed Relay gateway is missing its bootstrap fingerprint") + } +} + +impl std::fmt::Display for VerifiedHttpError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +/// Authenticates a Relay gateway and sends one HTTP request on that same TCP connection. +/// +/// Keeping the challenge and payload on one established connection closes the port-replacement +/// gap between an authenticated health probe and hook delivery. A foreign listener can receive +/// the challenge, but the payload is not written until Relay proves possession of the per-user +/// bootstrap key on the connection that will receive it. +pub(crate) fn post_verified( + url: &str, + bootstrap_fingerprint: &str, + path: &str, + headers: &[(String, String)], + body: &[u8], + timeout: Duration, + max_response_bytes: usize, +) -> Result { + let (host, port) = parse_loopback_url(url).map_err(VerifiedHttpError::after_payload)?; + let address = (host.as_str(), port) + .to_socket_addrs() + .map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to resolve verified gateway {url}: {error}" + )) + })? + .next() + .ok_or_else(|| { + VerifiedHttpError::before_payload(format!( + "verified gateway {url} has no socket address" + )) + })?; + let mut stream = TcpStream::connect_timeout(&address, timeout).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to connect to verified gateway {url}: {error}" + )) + })?; + stream.set_read_timeout(Some(timeout)).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to configure verified gateway read timeout: {error}" + )) + })?; + stream.set_write_timeout(Some(timeout)).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to configure verified gateway write timeout: {error}" + )) + })?; + + let key = BootstrapChallengeKey::load().map_err(|error| { + VerifiedHttpError::after_payload(format!( + "failed to load the Relay bootstrap challenge key: {error}" + )) + })?; + let mut nonce = [0_u8; 32]; + SystemRandom::new().fill(&mut nonce).map_err(|_| { + VerifiedHttpError::after_payload("failed to generate a Relay bootstrap challenge") + })?; + let nonce = nonce + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let authority = loopback_authority(&host, port); + let challenge = format!( + "GET /healthz HTTP/1.1\r\nHost: {authority}\r\nX-NeMo-Relay-Bootstrap-Fingerprint: {bootstrap_fingerprint}\r\nX-NeMo-Relay-Bootstrap-Nonce: {nonce}\r\nConnection: keep-alive\r\n\r\n" + ); + stream.write_all(challenge.as_bytes()).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to send the Relay bootstrap challenge: {error}" + )) + })?; + let (health_headers, health_body) = + read_http_message(&mut stream, 16 * 1024).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to read the Relay bootstrap challenge response: {error}" + )) + })?; + let (health, _) = classify_health_response( + &health_headers, + &health_body, + Some((bootstrap_fingerprint, nonce.as_str(), &key)), + ); + match health { + RelayHealth::Compatible => {} + RelayHealth::Incompatible => { + return Err(VerifiedHttpError::after_payload(format!( + "an incompatible NeMo Relay gateway is listening at {url}" + ))); + } + RelayHealth::Foreign | RelayHealth::Unavailable => { + return Err(VerifiedHttpError::after_payload(format!( + "a foreign process is listening at the shared Relay gateway URL {url}" + ))); + } + } + if http_header(&health_headers, "connection") + .is_some_and(|value| value.eq_ignore_ascii_case("close")) + { + return Err(VerifiedHttpError::before_payload( + "verified Relay gateway closed the authenticated connection before hook delivery", + )); + } + + let mut request = format!("POST {path} HTTP/1.1\r\nHost: {authority}\r\n"); + for (name, value) in headers { + request.push_str(name); + request.push_str(": "); + request.push_str(value); + request.push_str("\r\n"); + } + request.push_str(&format!( + "Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + )); + stream.write_all(request.as_bytes()).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to send verified hook request headers: {error}" + )) + })?; + stream.write_all(body).map_err(|error| { + VerifiedHttpError::after_payload(format!( + "verified hook payload delivery became indeterminate: {error}" + )) + })?; + let (response_headers, response_body) = read_http_message(&mut stream, max_response_bytes) + .map_err(|error| { + VerifiedHttpError::after_payload(format!( + "failed to read verified hook response: {error}" + )) + })?; + let status = http_status(&response_headers).ok_or_else(|| { + VerifiedHttpError::after_payload("verified hook response had an invalid HTTP status") + })?; + Ok(VerifiedHttpResponse { + status, + body: response_body, + }) +} + pub(crate) fn healthz(url: &str) -> bool { probe(url, None) == RelayHealth::Compatible } -#[cfg(test)] pub(crate) fn healthz_compatible(url: &str, bootstrap_fingerprint: &str) -> bool { probe(url, Some(bootstrap_fingerprint)) == RelayHealth::Compatible } +pub(crate) fn authenticated_instance_id(url: &str, bootstrap_fingerprint: &str) -> Option { + compatible_instance_id(url, Some(bootstrap_fingerprint)) +} + pub(super) fn probe_after_lock( url: &str, bootstrap_fingerprint: Option<&str>, @@ -119,49 +299,16 @@ pub(super) fn probe_with_instance( if stream.write_all(request.as_bytes()).is_err() { return (RelayHealth::Foreign, None); } - let mut response = Vec::new(); - if stream.take(16 * 1024).read_to_end(&mut response).is_err() { - return (RelayHealth::Foreign, None); - } - let Some((headers, body)) = split_http_response(&response) else { - return (RelayHealth::Foreign, None); - }; - let Ok(body) = serde_json::from_slice::(body) else { + let Ok((headers, body)) = read_http_message(&mut stream, 16 * 1024) else { return (RelayHealth::Foreign, None); }; - if body.get("service").and_then(Value::as_str) != Some("nemo-relay") - || body.get("bootstrap_protocol").and_then(Value::as_u64) - != Some(BOOTSTRAP_PROTOCOL_VERSION) - { - return (RelayHealth::Foreign, None); - } - if body.get("version").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION")) - || headers.starts_with(b"HTTP/1.1 409") - || headers.starts_with(b"HTTP/1.0 409") - { - return (RelayHealth::Incompatible, None); - } - if (headers.starts_with(b"HTTP/1.1 200") || headers.starts_with(b"HTTP/1.0 200")) - && body.get("status").and_then(Value::as_str) == Some("ok") - { - if let Some((fingerprint, nonce, key)) = challenge { - let Some(proof) = http_header(headers, "x-nemo-relay-bootstrap-proof") else { - return (RelayHealth::Foreign, None); - }; - if !key.verify(fingerprint, &nonce, proof) { - return (RelayHealth::Foreign, None); - } - } - let Some(instance_id) = body - .get("instance_id") - .and_then(Value::as_str) - .filter(|instance_id| !instance_id.is_empty() && instance_id.len() <= 128) - else { - return (RelayHealth::Foreign, None); - }; - return (RelayHealth::Compatible, Some(instance_id.to_owned())); - } - (RelayHealth::Foreign, None) + classify_health_response( + &headers, + &body, + challenge + .as_ref() + .map(|(fingerprint, nonce, key)| (*fingerprint, nonce.as_str(), key)), + ) } pub(super) fn request_shutdown(url: &str, token: &str) -> Result<(), String> { @@ -215,6 +362,129 @@ fn http_header<'a>(headers: &'a [u8], name: &str) -> Option<&'a str> { }) } +fn http_status(headers: &[u8]) -> Option { + let line = headers.split(|byte| *byte == b'\n').next()?; + let line = std::str::from_utf8(line).ok()?.trim_end_matches('\r'); + let mut fields = line.split_ascii_whitespace(); + matches!(fields.next(), Some("HTTP/1.1" | "HTTP/1.0")) + .then(|| fields.next()?.parse().ok()) + .flatten() +} + +fn classify_health_response( + headers: &[u8], + body: &[u8], + challenge: Option<(&str, &str, &BootstrapChallengeKey)>, +) -> (RelayHealth, Option) { + let Ok(body) = serde_json::from_slice::(body) else { + return (RelayHealth::Foreign, None); + }; + if body.get("service").and_then(Value::as_str) != Some("nemo-relay") + || body.get("bootstrap_protocol").and_then(Value::as_u64) + != Some(BOOTSTRAP_PROTOCOL_VERSION) + { + return (RelayHealth::Foreign, None); + } + if body.get("version").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION")) + || http_status(headers) == Some(409) + { + return (RelayHealth::Incompatible, None); + } + if http_status(headers) != Some(200) || body.get("status").and_then(Value::as_str) != Some("ok") + { + return (RelayHealth::Foreign, None); + } + if let Some((fingerprint, nonce, key)) = challenge { + let Some(proof) = http_header(headers, "x-nemo-relay-bootstrap-proof") else { + return (RelayHealth::Foreign, None); + }; + if !key.verify(fingerprint, nonce, proof) { + return (RelayHealth::Foreign, None); + } + } + let Some(instance_id) = body + .get("instance_id") + .and_then(Value::as_str) + .filter(|instance_id| !instance_id.is_empty() && instance_id.len() <= 128) + else { + return (RelayHealth::Foreign, None); + }; + (RelayHealth::Compatible, Some(instance_id.to_owned())) +} + +fn read_http_message( + stream: &mut TcpStream, + max_body_bytes: usize, +) -> std::io::Result<(Vec, Vec)> { + const MAX_HEADER_BYTES: usize = 16 * 1024; + + let mut response = Vec::new(); + let header_end = loop { + if let Some(index) = response.windows(4).position(|window| window == b"\r\n\r\n") { + break index + 4; + } + if response.len() >= MAX_HEADER_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP response headers exceed the Relay limit", + )); + } + let mut chunk = [0_u8; 1024]; + let read = stream.read(&mut chunk)?; + if read == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "HTTP response ended before its headers", + )); + } + response.extend_from_slice(&chunk[..read]); + }; + let headers = response[..header_end - 4].to_vec(); + if http_header(&headers, "transfer-encoding").is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "chunked HTTP responses are not supported by the verified Relay transport", + )); + } + let content_length = http_header(&headers, "content-length") + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP response omitted Content-Length", + ) + })? + .parse::() + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP response had an invalid Content-Length", + ) + })?; + if content_length > max_body_bytes { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("HTTP response exceeds the {max_body_bytes}-byte Relay limit"), + )); + } + let mut body = response[header_end..].to_vec(); + if body.len() > content_length { + body.truncate(content_length); + } + while body.len() < content_length { + let mut chunk = [0_u8; 4096]; + let needed = (content_length - body.len()).min(chunk.len()); + let read = stream.read(&mut chunk[..needed])?; + if read == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "HTTP response ended before its declared body", + )); + } + body.extend_from_slice(&chunk[..read]); + } + Ok((headers, body)) +} + fn split_http_response(response: &[u8]) -> Option<(&[u8], &[u8])> { response .windows(4) diff --git a/crates/cli/src/sidecar/process.rs b/crates/cli/src/sidecar/process.rs index 0f768a9b8..7192da808 100644 --- a/crates/cli/src/sidecar/process.rs +++ b/crates/cli/src/sidecar/process.rs @@ -7,12 +7,128 @@ use std::env; use std::process::{Child, Command}; #[cfg(windows)] -use std::sync::OnceLock; +use std::sync::{Mutex, OnceLock}; #[cfg(windows)] const SIDECAR_JOB_NAME_ENV: &str = "NEMO_RELAY_SIDECAR_JOB_NAME"; #[cfg(windows)] static RETAINED_SIDECAR_JOB: OnceLock = OnceLock::new(); +#[cfg(windows)] +static SIDECAR_SPAWN_LOCK: Mutex<()> = Mutex::new(()); + +#[cfg(windows)] +pub(super) struct HandleInheritanceGuard { + handles: Vec, +} + +#[cfg(windows)] +impl HandleInheritanceGuard { + pub(super) fn suppress( + handles: impl IntoIterator, + ) -> std::io::Result { + use windows_sys::Win32::Foundation::{ + GetHandleInformation, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, SetHandleInformation, + }; + + let mut guard = Self { + handles: Vec::new(), + }; + for handle in handles { + if handle.is_null() || handle == INVALID_HANDLE_VALUE || guard.handles.contains(&handle) + { + continue; + } + let mut flags = 0; + // SAFETY: `handle` is a live process handle and `flags` is writable storage. + if unsafe { GetHandleInformation(handle, &mut flags) } == 0 { + return Err(std::io::Error::last_os_error()); + } + if flags & HANDLE_FLAG_INHERIT == 0 { + continue; + } + // SAFETY: The mask changes only the inheritance bit on this live handle. + if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) } == 0 { + return Err(std::io::Error::last_os_error()); + } + guard.handles.push(handle); + } + Ok(guard) + } + + fn restore(&mut self) -> std::io::Result<()> { + use windows_sys::Win32::Foundation::{HANDLE_FLAG_INHERIT, SetHandleInformation}; + + let mut failed = Vec::new(); + let mut first_error = None; + for handle in self.handles.drain(..).rev() { + // SAFETY: Each handle was live and inheritable when this guard suppressed it. + if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) } + == 0 + { + failed.push(handle); + first_error.get_or_insert_with(std::io::Error::last_os_error); + } + } + self.handles = failed; + first_error.map_or(Ok(()), Err) + } +} + +#[cfg(windows)] +impl Drop for HandleInheritanceGuard { + fn drop(&mut self) { + let _ = self.restore(); + } +} + +#[cfg(windows)] +pub(crate) fn spawn_detached_sidecar(command: &mut Command) -> std::io::Result { + use std::os::windows::io::AsRawHandle; + + // Rust's stable Windows process API passes `bInheritHandles = TRUE`. Temporarily suppress the + // host's inherited standard handles so a sidecar launched by a captured hook cannot retain the + // hook's output pipes. `Command` creates separate inheritable handles for the explicitly + // configured sidecar log files while this lock is held. + let _spawn_guard = SIDECAR_SPAWN_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let standard_handles = [ + std::io::stdin().as_raw_handle().cast(), + std::io::stdout().as_raw_handle().cast(), + std::io::stderr().as_raw_handle().cast(), + ]; + let mut inheritance = HandleInheritanceGuard::suppress(standard_handles).map_err(|error| { + std::io::Error::new( + error.kind(), + format!("failed to suppress inherited standard handles: {error}"), + ) + })?; + let spawned = command.spawn(); + let restored = inheritance.restore().map_err(|error| { + std::io::Error::new( + error.kind(), + format!("failed to restore standard-handle inheritance: {error}"), + ) + }); + match (spawned, restored) { + (Ok(child), Ok(())) => Ok(child), + (Err(error), Ok(())) => Err(error), + (Ok(mut child), Err(error)) => { + let _ = child.kill(); + let _ = child.wait(); + Err(error) + } + (Err(spawn_error), Err(restore_error)) => Err(std::io::Error::new( + spawn_error.kind(), + format!("{spawn_error}; additionally, {restore_error}"), + )), + } +} + +#[cfg(not(windows))] +pub(crate) fn spawn_detached_sidecar(command: &mut Command) -> std::io::Result { + command.spawn() +} pub(super) struct DetachedSidecarProcess { child: Child, @@ -39,6 +155,35 @@ impl DetachedSidecarProcess { self.child.try_wait() } + /// Observe direct-child exit without releasing the Unix process-group identifier first. + pub(super) fn has_exited_for_tree_cleanup(&mut self) -> std::io::Result { + #[cfg(unix)] + { + let mut information = std::mem::MaybeUninit::::zeroed(); + // SAFETY: `information` is writable, the child PID is stable while its `Child` handle + // remains unreaped, and WNOWAIT intentionally preserves that zombie until the owned + // process group has been terminated. + if unsafe { + libc::waitid( + libc::P_PID, + self.child.id() as libc::id_t, + information.as_mut_ptr(), + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + } == -1 + { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: `waitid` initialized the signal information on success. POSIX requires a + // zero PID for WNOHANG when the selected child has not changed state. + Ok(unsafe { information.assume_init().si_pid() } != 0) + } + #[cfg(not(unix))] + { + self.child.try_wait().map(|status| status.is_some()) + } + } + pub(super) fn terminate(&mut self) { #[cfg(windows)] if let Some(job) = self.job.as_ref() { @@ -47,10 +192,42 @@ impl DetachedSidecarProcess { terminate_sidecar_process_tree(&mut self.child); } - pub(super) fn terminate_retained_descendants(&self) { - #[cfg(windows)] - if let Some(job) = self.job.as_ref() { - job.terminate(); + pub(super) fn terminate_retained_descendants(&mut self) -> std::io::Result<()> { + #[cfg(unix)] + { + let process_group = -(self.child.id() as i32); + // SAFETY: Detached sidecars call `setsid` before exec, so the direct child's PID is the + // owned process-group ID. Unix exit observation left the leader unreaped to prevent + // reuse while this signal is delivered. + let terminated = if unsafe { libc::kill(process_group, libc::SIGKILL) } == -1 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + Ok(()) + } else { + Err(error) + } + } else { + Ok(()) + }; + let reaped = self.child.wait().map(|_| ()); + match (terminated, reaped) { + (Ok(()), Ok(())) => Ok(()), + (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), + (Err(terminate_error), Err(reap_error)) => Err(std::io::Error::new( + terminate_error.kind(), + format!( + "{terminate_error}; additionally failed to reap the sidecar: {reap_error}" + ), + )), + } + } + #[cfg(not(unix))] + { + #[cfg(windows)] + if let Some(job) = self.job.as_ref() { + job.terminate(); + } + Ok(()) } } } diff --git a/crates/cli/src/sidecar/state.rs b/crates/cli/src/sidecar/state.rs index b957e7127..a9fb66e8b 100644 --- a/crates/cli/src/sidecar/state.rs +++ b/crates/cli/src/sidecar/state.rs @@ -13,7 +13,7 @@ use std::time::{Duration, Instant}; use reqwest::Url; use serde::{Deserialize, Serialize}; -use crate::file_io::{LockAttempt, atomic_write, try_lock_exclusive}; +use crate::file_io::{LockAttempt, atomic_write, try_lock_exclusive, try_lock_shared}; use super::health::{RelayHealth, probe, request_shutdown}; use super::{BOOTSTRAP_PROTOCOL_VERSION, GatewayEndpoint, SIDECAR_LOCK_TIMEOUT}; @@ -75,17 +75,19 @@ pub(super) struct RecoveryEpoch { service: String, bootstrap_protocol: u64, url: String, + cohort_id: String, pub(super) instance_id: String, pub(super) restarts: u8, pub(super) pending: bool, } impl RecoveryEpoch { - pub(super) fn new(url: &str, instance_id: &str) -> Self { + pub(super) fn new(url: &str, cohort_id: &str, instance_id: &str) -> Self { Self { service: "nemo-relay".into(), bootstrap_protocol: BOOTSTRAP_PROTOCOL_VERSION, url: url.into(), + cohort_id: cohort_id.into(), instance_id: instance_id.into(), restarts: 0, pending: false, @@ -93,10 +95,32 @@ impl RecoveryEpoch { } } +#[derive(Clone, Debug, Deserialize, Serialize)] +struct RecoveryCohort { + service: String, + bootstrap_protocol: u64, + url: String, + cohort_id: String, +} + +impl RecoveryCohort { + fn new(url: &str) -> Self { + Self { + service: "nemo-relay".into(), + bootstrap_protocol: BOOTSTRAP_PROTOCOL_VERSION, + url: url.into(), + cohort_id: uuid::Uuid::now_v7().to_string(), + } + } +} + /// Process-lifetime registration used to delimit one shared recovery epoch. pub(crate) struct EndpointLease { file: Option, path: PathBuf, + runtime: PathBuf, + url: String, + cohort_id: String, fresh_epoch: bool, } @@ -108,9 +132,11 @@ impl EndpointLease { runtime.display() ) })?; - let registry_path = runtime.join(format!("{}-leases.lock", lock_name(url))); + let registry_path = lease_registry_path(runtime, url); let _registry = lock_file_for(®istry_path, SIDECAR_LOCK_TIMEOUT)?; - let prefix = format!("{}-lease-", lock_name(url)); + let cohort_id = current_or_create_recovery_cohort(runtime, url)?; + let all_leases_prefix = format!("{}-lease-", lock_name(url)); + let prefix = format!("{all_leases_prefix}{cohort_id}-"); let mut active = false; let entries = fs::read_dir(runtime).map_err(|error| { format!( @@ -123,7 +149,7 @@ impl EndpointLease { let Some(name) = name.to_str() else { continue; }; - if !name.starts_with(&prefix) || !name.ends_with(".lock") { + if !name.starts_with(&all_leases_prefix) || !name.ends_with(".lock") { continue; } let path = entry.path(); @@ -142,7 +168,8 @@ impl EndpointLease { drop(file); let _ = fs::remove_file(path); } - Ok(LockAttempt::Contended) => active = true, + Ok(LockAttempt::Contended) if name.starts_with(&prefix) => active = true, + Ok(LockAttempt::Contended) => {} Err(error) => { return Err(format!( "failed to inspect bootstrap lease {}: {error}", @@ -188,6 +215,9 @@ impl EndpointLease { Ok(Self { file: Some(file), path, + runtime: runtime.to_path_buf(), + url: url.to_string(), + cohort_id, fresh_epoch: !active, }) } @@ -195,15 +225,102 @@ impl EndpointLease { pub(crate) fn fresh_epoch(&self) -> bool { self.fresh_epoch } + + pub(crate) fn cohort_id(&self) -> &str { + &self.cohort_id + } } impl Drop for EndpointLease { fn drop(&mut self) { + // Acquisition and release share this registry lock. This closes the handoff race where a + // new lease could observe the old lease as active immediately before it disappeared and + // become the sole client of an exhausted recovery epoch. + let Ok(_registry) = lock_file_for( + &lease_registry_path(&self.runtime, &self.url), + SIDECAR_LOCK_TIMEOUT, + ) else { + // Releasing without the registry would reintroduce the last-client handoff race. + // Conservatively retain the advisory lock until process exit; a later process can + // clean up the lease file after the operating system releases the descriptor. + if let Some(file) = self.file.take() { + std::mem::forget(file); + } + return; + }; drop(self.file.take()); let _ = fs::remove_file(&self.path); } } +fn lease_registry_path(runtime: &Path, url: &str) -> PathBuf { + runtime.join(format!("{}-leases.lock", lock_name(url))) +} + +fn recovery_cohort_path(runtime: &Path, url: &str) -> PathBuf { + runtime.join(format!("sidecar-{}.cohort.json", lock_name(url))) +} + +fn current_or_create_recovery_cohort(runtime: &Path, url: &str) -> Result { + if let Some(cohort) = read_recovery_cohort(runtime, url)? { + return Ok(cohort.cohort_id); + } + let cohort = RecoveryCohort::new(url); + write_recovery_cohort(runtime, &cohort)?; + Ok(cohort.cohort_id) +} + +fn read_recovery_cohort(runtime: &Path, url: &str) -> Result, String> { + let path = recovery_cohort_path(runtime, url); + let raw = match fs::read(&path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "failed to read gateway recovery cohort {}: {error}", + path.display() + )); + } + }; + let cohort = serde_json::from_slice::(&raw).map_err(|error| { + format!( + "invalid gateway recovery cohort {}: {error}", + path.display() + ) + })?; + if cohort.service != "nemo-relay" + || cohort.bootstrap_protocol != BOOTSTRAP_PROTOCOL_VERSION + || cohort.url != url + || cohort.cohort_id.is_empty() + { + return Err(format!( + "incompatible gateway recovery cohort {}", + path.display() + )); + } + Ok(Some(cohort)) +} + +fn write_recovery_cohort(runtime: &Path, cohort: &RecoveryCohort) -> Result<(), String> { + let path = recovery_cohort_path(runtime, &cohort.url); + let bytes = serde_json::to_vec(cohort) + .map_err(|error| format!("failed to encode gateway recovery cohort: {error}"))?; + atomic_write(&path, &bytes) +} + +pub(super) fn validate_recovery_cohort( + runtime: &Path, + url: &str, + expected: &str, +) -> Result<(), String> { + match read_recovery_cohort(runtime, url)? { + Some(cohort) if cohort.cohort_id == expected => Ok(()), + _ => Err(format!( + "shared Relay gateway lifecycle at {url} was retired by an integration update" + )), + } +} + pub(super) fn read_owner_record(path: &Path) -> Result, String> { let raw = match fs::read(path) { Ok(raw) => raw, @@ -279,6 +396,11 @@ pub(crate) fn lock_endpoint(runtime: &Path, url: &str) -> Result Result { + let path = lock_path(runtime, url); + lock_file_for_mode(&path, SIDECAR_LOCK_TIMEOUT, false) +} + pub(crate) fn lock_endpoint_for( runtime: &Path, url: &str, @@ -289,10 +411,19 @@ pub(crate) fn lock_endpoint_for( } fn lock_file_for(path: &Path, timeout: Duration) -> Result { + lock_file_for_mode(path, timeout, true) +} + +fn lock_file_for_mode(path: &Path, timeout: Duration, exclusive: bool) -> Result { let lock = open_lock(path)?; let deadline = Instant::now() + timeout; loop { - match try_lock_exclusive(&lock) { + let result = if exclusive { + try_lock_exclusive(&lock) + } else { + try_lock_shared(&lock) + }; + match result { Ok(LockAttempt::Acquired) => return Ok(lock), Ok(LockAttempt::Contended) => { if Instant::now() >= deadline { @@ -320,6 +451,7 @@ pub(super) fn recovery_path(runtime: &Path, url: &str) -> PathBuf { pub(super) fn read_recovery_epoch( runtime: &Path, url: &str, + cohort_id: &str, ) -> Result, String> { let path = recovery_path(runtime, url); let raw = match fs::read(&path) { @@ -337,6 +469,7 @@ pub(super) fn read_recovery_epoch( if epoch.service != "nemo-relay" || epoch.bootstrap_protocol != BOOTSTRAP_PROTOCOL_VERSION || epoch.url != url + || epoch.cohort_id != cohort_id || epoch.instance_id.is_empty() || epoch.restarts > 1 { @@ -469,13 +602,84 @@ pub(crate) fn validate_owner( } } -pub(crate) fn stop_owned(target_url: &str) -> Result<(), String> { +/// Stop a plugin-owned endpoint and begin a fresh recovery cohort as one serialized operation. +/// +/// The endpoint and lease-registry locks prevent a heartbeat or hook from recovering the old +/// gateway between shutdown and cohort rotation. If shutdown fails, both lifecycle files are +/// restored byte-for-byte so the still-active clients retain their original recovery budget. +pub(crate) fn stop_owned_and_reset(target_url: &str) -> Result<(), String> { + stop_owned_and_reset_after_rotation(target_url, || {}) +} + +fn stop_owned_and_reset_after_rotation( + target_url: &str, + after_rotation: impl FnOnce(), +) -> Result<(), String> { + let runtime = state_dir()?; + create_private_runtime_dir(&runtime).map_err(|error| { + format!( + "failed to create bootstrap state directory {}: {error}", + runtime.display() + ) + })?; + let _endpoint = lock_endpoint(&runtime, target_url)?; + let _registry = lock_file_for( + &lease_registry_path(&runtime, target_url), + SIDECAR_LOCK_TIMEOUT, + )?; + let cohort_path = recovery_cohort_path(&runtime, target_url); + let epoch_path = recovery_path(&runtime, target_url); + let cohort_snapshot = read_optional_bytes(&cohort_path)?; + let epoch_snapshot = read_optional_bytes(&epoch_path)?; + let replacement = RecoveryCohort::new(target_url); + let result = write_recovery_cohort(&runtime, &replacement) + .and_then(|()| remove_optional_state_file(&epoch_path)) + .and_then(|()| { + after_rotation(); + stop_owned_matching_records(&runtime, target_url, true) + }); + if let Err(error) = result { + let mut rollback_errors = Vec::new(); + if let Err(restore_error) = restore_optional_bytes(&cohort_path, cohort_snapshot.as_deref()) + { + rollback_errors.push(restore_error); + } + if let Err(restore_error) = restore_optional_bytes(&epoch_path, epoch_snapshot.as_deref()) { + rollback_errors.push(restore_error); + } + return if rollback_errors.is_empty() { + Err(error) + } else { + Err(format!( + "{error}; additionally failed to restore gateway recovery state: {}", + rollback_errors.join("; ") + )) + }; + } + Ok(()) +} + +#[cfg(test)] +fn stop_owned(target_url: &str) -> Result<(), String> { let runtime = state_dir()?; + stop_owned_matching_records(&runtime, target_url, false) +} + +fn stop_owned_matching_records( + runtime: &Path, + target_url: &str, + endpoint_is_locked: bool, +) -> Result<(), String> { let mut errors = Vec::new(); - for owner_path in owner_paths(&runtime)? { + for owner_path in owner_paths(runtime)? { match read_owner_record(&owner_path) { Ok(Some(owner)) if owner.url == target_url => { - if let Err(error) = stop_owned_record(&runtime, &owner_path) { + let result = if endpoint_is_locked { + stop_owned_record_after_lock(runtime, &owner_path) + } else { + stop_owned_record(runtime, &owner_path) + }; + if let Err(error) = result { errors.push(error); } } @@ -490,6 +694,29 @@ pub(crate) fn stop_owned(target_url: &str) -> Result<(), String> { } } +fn read_optional_bytes(path: &Path) -> Result>, String> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("failed to read {}: {error}", path.display())), + } +} + +fn restore_optional_bytes(path: &Path, bytes: Option<&[u8]>) -> Result<(), String> { + match bytes { + Some(bytes) => atomic_write(path, bytes), + None => remove_optional_state_file(path), + } +} + +fn remove_optional_state_file(path: &Path) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("failed to remove {}: {error}", path.display())), + } +} + pub(crate) fn owner_paths(runtime: &Path) -> Result, String> { let entries = match fs::read_dir(runtime) { Ok(entries) => entries, @@ -542,6 +769,10 @@ pub(crate) fn stop_owned_record(runtime: &Path, owner_path: &Path) -> Result<(), return Ok(()); }; let _lock = lock_endpoint(runtime, &initial_owner.url)?; + stop_owned_record_after_lock(runtime, owner_path) +} + +fn stop_owned_record_after_lock(runtime: &Path, owner_path: &Path) -> Result<(), String> { let Some(owner) = read_owner_record(owner_path)? else { return Ok(()); }; diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 282646a08..b3f7bed3c 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -22,6 +22,15 @@ fn gateway_bin() -> &'static str { env!("CARGO_BIN_EXE_nemo-relay") } +const ACTIVE_GENERATION_TOKEN: &str = "active-generation"; + +fn write_active_generation(temp: &std::path::Path) -> std::path::PathBuf { + let generation = temp.join("plugin/.nemo-relay-generation"); + std::fs::create_dir_all(generation.parent().unwrap()).unwrap(); + std::fs::write(&generation, format!("{ACTIVE_GENERATION_TOKEN}\n")).unwrap(); + generation +} + fn toml_basic_string(value: &str) -> String { let escaped = value .chars() @@ -165,6 +174,65 @@ fn cli_version_exits_successfully() { assert!(String::from_utf8_lossy(&output.stdout).contains("nemo-relay ")); } +#[test] +fn managed_mcp_launch_removes_unresolved_environment_placeholders_before_cli_parsing() { + let output = Command::new(gateway_bin()) + .env("NEMO_RELAY_MCP_GENERATION_FILE", "/tmp/managed-generation") + .env("NEMO_RELAY_MCP_GENERATION", "managed-token") + .env( + "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", + "${NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES}", + ) + .env( + "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES", + "${NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES}", + ) + .args(["agents", "--json"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice::(&output.stdout).unwrap(); +} + +#[test] +fn ordinary_cli_launch_does_not_hide_invalid_environment_values() { + let output = Command::new(gateway_bin()) + .env_remove("NEMO_RELAY_MCP_GENERATION_FILE") + .env_remove("NEMO_RELAY_MCP_GENERATION") + .env( + "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", + "${NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES}", + ) + .args(["agents", "--json"]) + .output() + .unwrap(); + + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("invalid digit found in string")); +} + +#[test] +fn managed_mcp_launch_rejects_unresolved_generation_placeholders() { + let output = Command::new(gateway_bin()) + .env( + "NEMO_RELAY_MCP_GENERATION_FILE", + "${NEMO_RELAY_MCP_GENERATION_FILE}", + ) + .env("NEMO_RELAY_MCP_GENERATION", "${NEMO_RELAY_MCP_GENERATION}") + .arg("mcp") + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("generation"), "{stderr}"); +} + #[test] fn cli_mcp_help_describes_lifecycle_bound_native_gateway() { let output = Command::new(gateway_bin()) @@ -206,8 +274,8 @@ fn cli_mcp_starts_gateway_before_initialize_and_exits_cleanly() { .stderr(Stdio::piped()) .spawn() .unwrap(); - let owner = wait_for_owned_sidecar(temp.path(), "codex", None); - let address = sidecar_address(temp.path(), "codex"); + let owner = wait_for_owned_sidecar(temp.path(), None); + let address = sidecar_address(temp.path()); let mut stdin = child.stdin.take().unwrap(); stdin .write_all( @@ -230,8 +298,8 @@ fn cli_mcp_starts_gateway_before_initialize_and_exits_cleanly() { serde_json::json!("nemo-relay") ); let log = std::fs::read_to_string( - find_runtime_file(temp.path(), "codex-sidecar.log") - .expect("Codex sidecar log should exist"), + find_runtime_file(temp.path(), "gateway-sidecar.log") + .expect("gateway sidecar log should exist"), ) .unwrap(); assert!(log.contains("Gateway http://127.0.0.1:")); @@ -263,13 +331,61 @@ fn cli_mcp_starts_gateway_even_when_stdio_closes_before_request() { String::from_utf8_lossy(&output.stderr) ); assert!(output.stdout.is_empty()); - assert!(find_runtime_file(temp.path(), "codex-sidecar.log").is_some()); - let owner = wait_for_owned_sidecar(temp.path(), "codex", None); - let address = sidecar_address(temp.path(), "codex"); + assert!(find_runtime_file(temp.path(), "gateway-sidecar.log").is_some()); + let owner = wait_for_owned_sidecar(temp.path(), None); + let address = sidecar_address(temp.path()); stop_owned_sidecar(&owner); wait_for_port_closed(address); } +#[test] +fn cli_mcp_rejects_an_unauthenticated_transparent_gateway() { + let temp = tempfile::tempdir().unwrap(); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":2,"instance_id":"transparent"}}"#, + env!("CARGO_PKG_VERSION") + ); + let (gateway_url, received) = spawn_single_request_server(200, &body); + let mut child = Command::new(gateway_bin()) + .args(["--bind", "127.0.0.1:1", "mcp"]) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .env("NEMO_RELAY_TRANSPARENT_RUN", "1") + .env("NEMO_RELAY_GATEWAY_URL", &gateway_url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all( + b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\"}}\n", + ) + .unwrap(); + + let output = wait_child_with_output(child); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("authenticated NeMo Relay gateway"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stdout.is_empty()); + assert!( + received + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .starts_with("GET /healthz ") + ); + assert!(find_runtime_file(temp.path(), "gateway-sidecar.log").is_none()); + assert!(find_runtime_file(temp.path(), "codex.owner.json").is_none()); +} + #[cfg(unix)] #[test] fn cli_internal_hermes_install_writes_mcp_hooks_trust_and_doctor_ready_state() { @@ -313,10 +429,7 @@ fn cli_internal_hermes_install_writes_mcp_hooks_trust_and_doctor_ready_state() { serde_yaml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap(); let server = &config["mcp_servers"]["nemo-relay"]; assert_eq!(server["command"], gateway_bin()); - assert_eq!( - server["args"], - serde_json::json!(["mcp", "--agent", "hermes"]) - ); + assert_eq!(server["args"], serde_json::json!(["mcp"])); assert_eq!(server["env"]["NEMO_RELAY_GATEWAY_BIND"], "127.0.0.1:47632"); assert_eq!(server["env"]["OPENAI_API_KEY"], "${OPENAI_API_KEY}"); assert!( @@ -390,30 +503,38 @@ fn cli_internal_hermes_install_writes_mcp_hooks_trust_and_doctor_ready_state() { } fn start_mcp_client(temp: &std::path::Path, bind: SocketAddr) -> (Child, ChildStdin) { - start_mcp_client_for_agent(temp, bind, "codex") + start_mcp_client_with_idle_timeout(temp, bind, "1") } -fn start_mcp_client_for_agent( +fn start_mcp_client_with_idle_timeout( temp: &std::path::Path, bind: SocketAddr, - agent: &str, + idle_timeout_secs: &str, ) -> (Child, ChildStdin) { - start_mcp_client_with_idle_timeout(temp, bind, agent, "1") + start_mcp_client_with_generation(temp, bind, idle_timeout_secs, None) } -fn start_mcp_client_with_idle_timeout( +fn start_mcp_client_with_generation( temp: &std::path::Path, bind: SocketAddr, - agent: &str, idle_timeout_secs: &str, + generation: Option<&std::path::Path>, ) -> (Child, ChildStdin) { - let mut child = Command::new(gateway_bin()) - .args(["--bind", &bind.to_string(), "mcp", "--agent", agent]) + let mut command = Command::new(gateway_bin()); + command + .args(["--bind", &bind.to_string(), "mcp"]) .env("HOME", temp) .env("XDG_CONFIG_HOME", temp.join("xdg")) .env("XDG_RUNTIME_DIR", temp.join("runtime")) .env("TMPDIR", temp) - .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", idle_timeout_secs) + .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", idle_timeout_secs); + if let Some(generation) = generation { + let token = std::fs::read_to_string(generation).unwrap(); + command + .env("NEMO_RELAY_MCP_GENERATION_FILE", generation) + .env("NEMO_RELAY_MCP_GENERATION", token.trim()); + } + let mut child = command .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -455,8 +576,13 @@ fn cli_hooks_and_mcp_share_the_same_persistent_identity_for_each_host() { let address = probe.local_addr().unwrap(); drop(probe); let gateway_url = format!("http://{address}"); + let generation = write_active_generation(temp.path()); let mut hook = Command::new(gateway_bin()) .args(["hook-forward", agent, "--gateway-url", &gateway_url]) + .arg("--generation-file") + .arg(&generation) + .arg("--generation-token") + .arg(ACTIVE_GENERATION_TOKEN) .env("HOME", temp.path()) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) @@ -479,12 +605,11 @@ fn cli_hooks_and_mcp_share_the_same_persistent_identity_for_each_host() { String::from_utf8_lossy(&hook_output.stderr) ); - let (mut mcp, mcp_stdin) = - start_mcp_client_with_idle_timeout(temp.path(), address, agent, "10"); + let (mut mcp, mcp_stdin) = start_mcp_client_with_idle_timeout(temp.path(), address, "10"); drop(mcp_stdin); assert!(wait_child(&mut mcp).success()); - let owner = wait_for_owned_sidecar(temp.path(), agent, None); + let owner = wait_for_owned_sidecar(temp.path(), None); assert_eq!(owner["url"], gateway_url); stop_owned_sidecar(&owner); wait_for_port_closed(address); @@ -524,14 +649,27 @@ fn fake_bootstrap_proof(key: &[u8], fingerprint: &str, nonce: &str) -> String { } fn run_fake_bootstrap_listener(proof: FakeBootstrapProof) -> (Output, Vec) { - run_fake_bootstrap_listener_with_hook_delay(proof, None) + run_fake_bootstrap_listener_with_options(proof, None, false) } fn run_fake_bootstrap_listener_with_hook_delay( proof: FakeBootstrapProof, hook_delay: Option, +) -> (Output, Vec) { + run_fake_bootstrap_listener_with_options(proof, hook_delay, false) +} + +fn run_forward_only_fake_bootstrap_listener(proof: FakeBootstrapProof) -> (Output, Vec) { + run_fake_bootstrap_listener_with_options(proof, None, true) +} + +fn run_fake_bootstrap_listener_with_options( + proof: FakeBootstrapProof, + hook_delay: Option, + forward_only: bool, ) -> (Output, Vec) { let temp = tempfile::tempdir().unwrap(); + let generation = write_active_generation(temp.path()); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let address = listener.local_addr().unwrap(); @@ -584,15 +722,34 @@ fn run_fake_bootstrap_listener_with_hook_delay( r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":2,"instance_id":"test-instance"}}"#, env!("CARGO_PKG_VERSION") ); + let keep_alive = matches!(proof, FakeBootstrapProof::Valid) + && bootstrap_request_header(&request, "connection") + .is_some_and(|value| value.eq_ignore_ascii_case("keep-alive")); stream .write_all( format!( - "HTTP/1.1 200 OK\r\n{proof_header}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() + "HTTP/1.1 200 OK\r\n{proof_header}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: {}\r\n\r\n{body}", + body.len(), + if keep_alive { "keep-alive" } else { "close" } ) .as_bytes(), ) .unwrap(); + if keep_alive { + let request = read_http_request(&mut stream); + server_requests.lock().unwrap().push(request); + if let Some(delay) = hook_delay { + thread::sleep(delay); + } + let body = r#"{"continue":true}"#; + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ); + } } else { if let Some(delay) = hook_delay { thread::sleep(delay); @@ -609,13 +766,23 @@ fn run_fake_bootstrap_listener_with_hook_delay( } }); - let mut child = Command::new(gateway_bin()) - .args([ - "hook-forward", - "codex", - "--gateway-url", - &format!("http://{address}"), - ]) + let mut command = Command::new(gateway_bin()); + command.args([ + "hook-forward", + "codex", + "--gateway-url", + &format!("http://{address}"), + ]); + if forward_only { + command.arg("--forward-only"); + } else { + command + .arg("--generation-file") + .arg(&generation) + .arg("--generation-token") + .arg(ACTIVE_GENERATION_TOKEN); + } + let mut child = command .env("HOME", temp.path()) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) @@ -698,6 +865,7 @@ fn run_codex_hook_with_launch_resolution_error( payload: &[u8], invalid_idle_timeout: bool, ) -> Output { + let generation = write_active_generation(temp); let mut command = Command::new(gateway_bin()); command .args([ @@ -706,6 +874,10 @@ fn run_codex_hook_with_launch_resolution_error( "--gateway-url", "http://127.0.0.1:1", ]) + .arg("--generation-file") + .arg(generation) + .arg("--generation-token") + .arg(ACTIVE_GENERATION_TOKEN) .env("HOME", temp) .env("XDG_CONFIG_HOME", temp.join("xdg")) .env("XDG_RUNTIME_DIR", temp.join("runtime")) @@ -757,10 +929,10 @@ fn cli_codex_hook_launch_resolution_error_retains_default_payload_cap() { } } -fn sidecar_address(temp: &std::path::Path, agent: &str) -> SocketAddr { +fn sidecar_address(temp: &std::path::Path) -> SocketAddr { let deadline = Instant::now() + Duration::from_secs(5); loop { - let log_path = find_runtime_file(temp, &format!("{agent}-sidecar.log")); + let log_path = find_runtime_file(temp, "gateway-sidecar.log"); if let Some(log_path) = log_path.as_ref() && let Ok(log) = std::fs::read_to_string(log_path) && let Some(address) = log.lines().find_map(|line| { @@ -837,34 +1009,115 @@ fn wait_child(child: &mut Child) -> ExitStatus { if Instant::now() >= deadline { let _ = child.kill(); let _ = child.wait(); - let mut stderr = String::new(); - if let Some(mut child_stderr) = child.stderr.take() { - let _ = child_stderr.read_to_string(&mut stderr); - } - panic!("child process did not exit within 10 seconds; stderr: {stderr}"); + panic!("child process did not exit within 10 seconds"); } thread::sleep(Duration::from_millis(20)); } } fn wait_child_with_output(mut child: Child) -> Output { + fn read_pipe( + pipe: Option, + ) -> mpsc::Receiver>> { + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let result = match pipe { + Some(mut pipe) => { + let mut bytes = Vec::new(); + pipe.read_to_end(&mut bytes).map(|_| bytes) + } + None => Ok(Vec::new()), + }; + let _ = sender.send(result); + }); + receiver + } + + let stdout = read_pipe(child.stdout.take()); + let stderr = read_pipe(child.stderr.take()); let deadline = Instant::now() + Duration::from_secs(10); - loop { - if child.try_wait().unwrap().is_some() { - return child.wait_with_output().unwrap(); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; } if Instant::now() >= deadline { let _ = child.kill(); - let output = child.wait_with_output().unwrap(); - panic!( - "child process did not exit within 10 seconds; stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); + let _ = child.wait(); + panic!("child process did not exit within 10 seconds"); } thread::sleep(Duration::from_millis(20)); + }; + let remaining = || deadline.saturating_duration_since(Instant::now()); + let stdout = stdout + .recv_timeout(remaining()) + .expect("child stdout remained open after process exit") + .unwrap(); + let stderr = stderr + .recv_timeout(remaining()) + .expect("child stderr remained open after process exit") + .unwrap(); + Output { + status, + stdout, + stderr, } } +fn run_persistent_hook( + temp: &std::path::Path, + address: SocketAddr, + generation: &std::path::Path, + fail_closed: bool, +) -> Output { + run_persistent_hook_with_token( + temp, + address, + generation, + ACTIVE_GENERATION_TOKEN, + fail_closed, + ) +} + +fn run_persistent_hook_with_token( + temp: &std::path::Path, + address: SocketAddr, + generation: &std::path::Path, + generation_token: &str, + fail_closed: bool, +) -> Output { + let mut command = Command::new(gateway_bin()); + command + .args([ + "hook-forward", + "codex", + "--gateway-url", + &format!("http://{address}"), + "--generation-file", + ]) + .arg(generation) + .arg("--generation-token") + .arg(generation_token) + .env("HOME", temp) + .env("XDG_CONFIG_HOME", temp.join("xdg")) + .env("XDG_RUNTIME_DIR", temp.join("runtime")) + .env("TMPDIR", temp) + .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if fail_closed { + command.arg("--fail-closed"); + } + let mut child = command.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"{\"session_id\":\"hook-session\",\"hook_event_name\":\"SessionStart\"}") + .unwrap(); + wait_child_with_output(child) +} + fn wait_for_port_closed(address: SocketAddr) { let deadline = Instant::now() + Duration::from_secs(10); loop { @@ -879,11 +1132,7 @@ fn wait_for_port_closed(address: SocketAddr) { } } -fn wait_for_owned_sidecar( - temp: &std::path::Path, - agent: &str, - previous_pid: Option, -) -> serde_json::Value { +fn wait_for_owned_sidecar(temp: &std::path::Path, previous_pid: Option) -> serde_json::Value { let deadline = Instant::now() + Duration::from_secs(10); loop { for path in find_runtime_files_matching(temp, "sidecar-", ".owner.json") { @@ -898,7 +1147,7 @@ fn wait_for_owned_sidecar( } assert!( Instant::now() < deadline, - "owned {agent} sidecar was not published under {}", + "owned gateway sidecar was not published under {}", temp.display() ); thread::sleep(Duration::from_millis(20)); @@ -952,53 +1201,38 @@ fn relay_health(address: SocketAddr) -> serde_json::Value { #[test] fn cli_mcp_clients_share_gateway_until_final_idle_shutdown() { - for (first_agent, second_agent) in [ - ("codex", "claude"), - ("claude", "codex"), - ("codex", "hermes"), - ("hermes", "codex"), - ("claude", "hermes"), - ("hermes", "claude"), - ] { - let temp = tempfile::tempdir().unwrap(); - let (mut first, first_stdin) = - start_mcp_client_for_agent(temp.path(), "127.0.0.1:0".parse().unwrap(), first_agent); - let address = sidecar_address(temp.path(), first_agent); - let (mut second, second_stdin) = - start_mcp_client_for_agent(temp.path(), address, second_agent); - - drop(first_stdin); - assert!(wait_child(&mut first).success()); - let health = relay_health(address); - assert_eq!(health["service"], "nemo-relay"); - assert_eq!(health["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(health["bootstrap_protocol"], 2); - assert!( - health["instance_id"] - .as_str() - .is_some_and(|instance_id| !instance_id.is_empty()), - "the shared gateway should publish its process identity" - ); - assert!( - find_runtime_file(temp.path(), &format!("{second_agent}-sidecar.log")).is_none(), - "the second MCP client should adopt the first host's gateway" - ); + let temp = tempfile::tempdir().unwrap(); + let (mut first, first_stdin) = start_mcp_client(temp.path(), "127.0.0.1:0".parse().unwrap()); + let address = sidecar_address(temp.path()); + let (mut second, second_stdin) = start_mcp_client(temp.path(), address); + + drop(first_stdin); + assert!(wait_child(&mut first).success()); + let health = relay_health(address); + assert_eq!(health["service"], "nemo-relay"); + assert_eq!(health["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(health["bootstrap_protocol"], 2); + assert!( + health["instance_id"] + .as_str() + .is_some_and(|instance_id| !instance_id.is_empty()), + "the shared gateway should publish its process identity" + ); - drop(second_stdin); - assert!(wait_child(&mut second).success()); - wait_for_port_closed(address); - } + drop(second_stdin); + assert!(wait_child(&mut second).success()); + wait_for_port_closed(address); } #[test] fn cli_mcp_restarts_one_stopped_gateway_then_fails_after_the_second_stop() { let temp = tempfile::tempdir().unwrap(); let (mut client, _stdin) = start_mcp_client(temp.path(), "127.0.0.1:0".parse().unwrap()); - let first = wait_for_owned_sidecar(temp.path(), "codex", None); + let first = wait_for_owned_sidecar(temp.path(), None); let first_pid = first["pid"].as_u64().unwrap(); stop_owned_sidecar(&first); - let second = wait_for_owned_sidecar(temp.path(), "codex", Some(first_pid)); + let second = wait_for_owned_sidecar(temp.path(), Some(first_pid)); assert_ne!(second["pid"], first["pid"]); stop_owned_sidecar(&second); @@ -1012,13 +1246,9 @@ fn cli_mcp_restarts_one_stopped_gateway_then_fails_after_the_second_stop() { #[test] fn cli_mcp_staggered_clients_share_one_endpoint_restart_budget() { let temp = tempfile::tempdir().unwrap(); - let (mut fast_client, _fast_stdin) = start_mcp_client_with_idle_timeout( - temp.path(), - "127.0.0.1:0".parse().unwrap(), - "codex", - "1", - ); - let first = wait_for_owned_sidecar(temp.path(), "codex", None); + let (mut fast_client, _fast_stdin) = + start_mcp_client_with_idle_timeout(temp.path(), "127.0.0.1:0".parse().unwrap(), "1"); + let first = wait_for_owned_sidecar(temp.path(), None); let first_pid = first["pid"].as_u64().unwrap(); let address = first["url"] .as_str() @@ -1030,10 +1260,10 @@ fn cli_mcp_staggered_clients_share_one_endpoint_restart_budget() { // Offset the heartbeat phases while keeping the persistent fingerprint identical. thread::sleep(Duration::from_millis(150)); let (mut slow_client, _slow_stdin) = - start_mcp_client_with_idle_timeout(temp.path(), address, "claude", "1"); + start_mcp_client_with_idle_timeout(temp.path(), address, "1"); stop_owned_sidecar(&first); - let second = wait_for_owned_sidecar(temp.path(), "codex", Some(first_pid)); + let second = wait_for_owned_sidecar(temp.path(), Some(first_pid)); let second_pid = second["pid"].as_u64().unwrap(); stop_owned_sidecar(&second); @@ -1054,6 +1284,198 @@ fn cli_mcp_staggered_clients_share_one_endpoint_restart_budget() { ); } +#[test] +fn cli_hook_recovery_consumes_the_same_restart_as_an_overlapping_mcp() { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("plugin/.nemo-relay-generation"); + std::fs::create_dir_all(generation.parent().unwrap()).unwrap(); + std::fs::write(&generation, "active-generation\n").unwrap(); + let (mut mcp, _stdin) = start_mcp_client_with_generation( + temp.path(), + "127.0.0.1:0".parse().unwrap(), + "1", + Some(&generation), + ); + let first = wait_for_owned_sidecar(temp.path(), None); + let first_pid = first["pid"].as_u64().unwrap(); + let address = first["url"] + .as_str() + .unwrap() + .strip_prefix("http://") + .unwrap() + .parse::() + .unwrap(); + + stop_owned_sidecar(&first); + let hook = run_persistent_hook(temp.path(), address, &generation, true); + assert!( + hook.status.success(), + "hook recovery failed: {}", + String::from_utf8_lossy(&hook.stderr) + ); + let second = wait_for_owned_sidecar(temp.path(), Some(first_pid)); + let second_pid = second["pid"].as_u64().unwrap(); + stop_owned_sidecar(&second); + + assert!(!wait_child(&mut mcp).success()); + let unexpected = find_runtime_files_matching(temp.path(), "sidecar-", ".owner.json") + .into_iter() + .filter_map(|path| std::fs::read(path).ok()) + .filter_map(|raw| serde_json::from_slice::(&raw).ok()) + .any(|owner| { + owner["pid"] + .as_u64() + .is_some_and(|pid| pid != first_pid && pid != second_pid) + }); + assert!(!unexpected, "the MCP performed an unaccounted third launch"); +} + +#[test] +fn cli_retired_persistent_hook_cannot_restart_the_gateway() { + let temp = tempfile::tempdir().unwrap(); + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = probe.local_addr().unwrap(); + drop(probe); + let generation = temp.path().join("plugin/.nemo-relay-generation"); + std::fs::create_dir_all(generation.parent().unwrap()).unwrap(); + std::fs::write(&generation, "retired:old-generation\n").unwrap(); + + let hook = run_persistent_hook(temp.path(), address, &generation, true); + + assert!(!hook.status.success()); + assert!( + String::from_utf8_lossy(&hook.stderr).contains("has been retired"), + "{}", + String::from_utf8_lossy(&hook.stderr) + ); + TcpListener::bind(address).expect("a retired hook unexpectedly bound the gateway endpoint"); +} + +#[test] +fn cli_cached_mcp_and_hook_cannot_adopt_a_replacement_at_the_same_generation_path() { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("plugin/.nemo-relay-generation"); + std::fs::create_dir_all(generation.parent().unwrap()).unwrap(); + let cached_token = "cached-generation"; + std::fs::write(&generation, "replacement-generation\n").unwrap(); + + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = probe.local_addr().unwrap(); + drop(probe); + let mcp = Command::new(gateway_bin()) + .args(["--bind", &address.to_string(), "mcp"]) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .env("NEMO_RELAY_MCP_GENERATION_FILE", &generation) + .env("NEMO_RELAY_MCP_GENERATION", cached_token) + .output() + .unwrap(); + assert!(!mcp.status.success()); + assert!( + String::from_utf8_lossy(&mcp.stderr).contains("has been retired"), + "{}", + String::from_utf8_lossy(&mcp.stderr) + ); + drop(TcpListener::bind(address).expect("a stale MCP unexpectedly started the gateway")); + + let hook = + run_persistent_hook_with_token(temp.path(), address, &generation, cached_token, true); + assert!(!hook.status.success()); + assert!( + String::from_utf8_lossy(&hook.stderr).contains("has been retired"), + "{}", + String::from_utf8_lossy(&hook.stderr) + ); + TcpListener::bind(address).expect("a stale hook unexpectedly started the gateway"); +} + +#[test] +fn cli_unfenced_persistent_hook_cannot_start_the_gateway() { + let temp = tempfile::tempdir().unwrap(); + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = probe.local_addr().unwrap(); + drop(probe); + let gateway_url = format!("http://{address}"); + let mut hook = Command::new(gateway_bin()) + .args([ + "hook-forward", + "codex", + "--gateway-url", + &gateway_url, + "--fail-closed", + ]) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + hook.stdin + .take() + .unwrap() + .write_all(b"{\"session_id\":\"legacy-hook\",\"hook_event_name\":\"SessionStart\"}") + .unwrap(); + + let output = wait_child_with_output(hook); + + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("missing its install-generation fence"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + TcpListener::bind(address).expect("an unfenced hook unexpectedly started the gateway"); +} + +#[test] +fn cli_path_only_persistent_hook_requires_its_expected_generation_identity() { + let temp = tempfile::tempdir().unwrap(); + let generation = write_active_generation(temp.path()); + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = probe.local_addr().unwrap(); + drop(probe); + let mut hook = Command::new(gateway_bin()) + .args([ + "hook-forward", + "codex", + "--gateway-url", + &format!("http://{address}"), + "--generation-file", + ]) + .arg(&generation) + .arg("--fail-closed") + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + hook.stdin + .take() + .unwrap() + .write_all(b"{\"session_id\":\"legacy-hook\",\"hook_event_name\":\"SessionStart\"}") + .unwrap(); + + let output = wait_child_with_output(hook); + + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("missing its expected install-generation identity"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + TcpListener::bind(address).expect("a path-only hook unexpectedly started the gateway"); +} + #[test] fn cli_agents_json_emits_supported_agent_shapes() { let temp = tempfile::tempdir().unwrap(); @@ -2516,6 +2938,366 @@ fn cli_run_rejects_zero_body_limit_env() { assert!(stderr.contains("greater than 0")); } +#[cfg(unix)] +#[test] +fn cli_transparent_run_preserves_interactive_terminal_job_control() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let xdg = temp.path().join("xdg"); + let runtime = temp.path().join("runtime"); + for directory in [&home, &xdg, &runtime] { + std::fs::create_dir_all(directory).unwrap(); + } + let agent = temp.path().join("interactive-codex"); + std::fs::write( + &agent, + r#"#!/usr/bin/env python3 +import os +import signal +import sys +import time + +delay_after_continue = False + +def handle_continue(_signal, _frame): + if delay_after_continue: + print("AGENT_BG_DELAY", flush=True) + time.sleep(1.5) + +signal.signal(signal.SIGCONT, handle_continue) + +if sys.argv[1:] == ["--version"]: + print("codex-cli 0.143.0") + raise SystemExit(0) + +print(f"AGENT_READY:{os.getppid()}:{os.getpgrp()}", flush=True) +for line in sys.stdin: + value = line.strip() + if value == "delay-next-continue": + delay_after_continue = True + print("AGENT_DELAY_ARMED", flush=True) + else: + print(f"AGENT_READ:{value}", flush=True) +"#, + ) + .unwrap(); + std::fs::set_permissions(&agent, std::fs::Permissions::from_mode(0o755)).unwrap(); + let config = temp.path().join("config.toml"); + std::fs::write( + &config, + format!( + "[agents.codex]\ncommand = {}\n", + toml_basic_string(agent.to_string_lossy().as_ref()) + ), + ) + .unwrap(); + let driver = temp.path().join("pty-driver.py"); + std::fs::write( + &driver, + r#"import errno +import os +import pty +import re +import select +import signal +import shlex +import sys +import termios +import time + +relay, config, home, xdg, runtime = sys.argv[1:] +pid, master = pty.fork() +if pid == 0: + env = os.environ.copy() + env.update({ + "HOME": home, + "XDG_CONFIG_HOME": xdg, + "XDG_RUNTIME_DIR": runtime, + "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS": "1", + "PS1": "RELAY_SHELL> ", + "ENV": "/dev/null", + "BASH_ENV": "/dev/null", + }) + os.execve("/bin/sh", ["sh", "-i"], env) + +attributes = termios.tcgetattr(master) +attributes[3] &= ~(termios.ECHO | getattr(termios, "ECHONL", 0)) +termios.tcsetattr(master, termios.TCSANOW, attributes) + +buffer = bytearray() +cursor = 0 +reaped = False +master_open = True +relay_pid = None +relay_group = None +agent_group = None + +def read_until(token, timeout=10): + global cursor + deadline = time.monotonic() + timeout + expected = token.encode() + while time.monotonic() < deadline: + index = buffer.find(expected, cursor) + if index >= 0: + observed = bytes(buffer[cursor:index + len(expected)]) + cursor = index + len(expected) + return observed.decode(errors="replace") + ready, _, _ = select.select([master], [], [], 0.1) + if not ready: + continue + try: + chunk = os.read(master, 4096) + except OSError as error: + if error.errno == errno.EIO: + break + raise + if not chunk: + break + buffer.extend(chunk) + raise AssertionError(f"did not observe {token!r}; output={buffer.decode(errors='replace')!r}") + +def wait_until_present(token, timeout=10): + deadline = time.monotonic() + timeout + expected = token.encode() + while time.monotonic() < deadline: + if expected in buffer: + return + ready, _, _ = select.select([master], [], [], 0.1) + if not ready: + continue + chunk = os.read(master, 4096) + if not chunk: + break + buffer.extend(chunk) + raise AssertionError(f"did not observe {token!r}; output={buffer.decode(errors='replace')!r}") + +def safe_kill_group(process_group): + if process_group is None or process_group <= 0 or process_group == pid: + return + try: + os.killpg(process_group, signal.SIGKILL) + except ProcessLookupError: + pass + +try: + read_until("RELAY_SHELL> ") + relay_command = " ".join(shlex.quote(value) for value in [relay, "--config", config, "run", "--agent", "codex"]) + # Keep a non-exec wrapper in Relay's shell job so suspension must target the whole group. + wrapped_command = f"{relay_command}; relay_status=$?; exit $relay_status" + os.write(master, f"/bin/sh -c {shlex.quote(wrapped_command)}\n".encode()) + + read_until("AGENT_READY:") + read_until("\n") + match = re.search(rb"AGENT_READY:(\d+):(\d+)", buffer) + assert match is not None, buffer + relay_pid = int(match.group(1)) + agent_group = int(match.group(2)) + relay_group = os.getpgid(relay_pid) + assert relay_group != agent_group, (relay_group, agent_group, buffer) + assert os.tcgetpgrp(master) == agent_group, (os.tcgetpgrp(master), agent_group, buffer) + + os.write(master, b"first-line\n") + read_until("AGENT_READ:first-line") + + os.write(master, b"\x1a") + read_until("RELAY_SHELL> ") + assert os.tcgetpgrp(master) == pid, (os.tcgetpgrp(master), pid, buffer) + + os.write(master, b"bg\n") + read_until("RELAY_SHELL> ") + time.sleep(0.1) + assert os.tcgetpgrp(master) == pid, (os.tcgetpgrp(master), pid, buffer) + os.write(master, b"echo BG_SHELL_OK\n") + read_until("BG_SHELL_OK") + read_until("RELAY_SHELL> ") + os.write(master, b"jobs\n") + read_until("Stopped") + read_until("RELAY_SHELL> ") + + os.write(master, b"fg\n") + os.write(master, b"second-line\n") + read_until("AGENT_READ:second-line") + + # Exercise `fg` while the background agent is still running instead of already stopped on a + # terminal read. Relay must notice that its owner group became foreground and transfer the + # terminal to the child without requiring a second `fg`. + os.write(master, b"delay-next-continue\n") + read_until("AGENT_DELAY_ARMED") + os.write(master, b"\x1a") + read_until("RELAY_SHELL> ") + os.write(master, b"bg\n") + read_until("RELAY_SHELL> ") + wait_until_present("AGENT_BG_DELAY") + assert os.tcgetpgrp(master) == pid, (os.tcgetpgrp(master), pid, buffer) + os.write(master, b"fg\n") + os.write(master, b"third-line\n") + read_until("AGENT_READ:third-line") + + os.write(master, b"\x03") + read_until("RELAY_SHELL> ") + os.write(master, b"exit\n") + os.close(master) + master_open = False + observed, status = os.waitpid(pid, 0) + reaped = True + assert observed == pid and (os.WIFEXITED(status) or os.WIFSIGNALED(status)), status +finally: + if not reaped: + safe_kill_group(agent_group) + safe_kill_group(relay_group) + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + if master_open: + os.close(master) + master_open = False + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass + if master_open: + os.close(master) +"#, + ) + .unwrap(); + + let output = Command::new("python3") + .arg(&driver) + .arg(gateway_bin()) + .arg(&config) + .arg(&home) + .arg(&xdg) + .arg(&runtime) + .output() + .unwrap(); + + assert!( + output.status.success(), + "PTY driver failed:\nstdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(unix)] +#[test] +fn cli_transparent_run_forwards_non_tty_termination_to_the_agent_tree() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let xdg = temp.path().join("xdg"); + let runtime = temp.path().join("runtime"); + for directory in [&home, &xdg, &runtime] { + std::fs::create_dir_all(directory).unwrap(); + } + let agent = temp.path().join("non-tty-codex"); + std::fs::write( + &agent, + r#"#!/usr/bin/env python3 +import os +import subprocess +import sys +import time + +if sys.argv[1:] == ["--version"]: + print("codex-cli 0.143.0") + raise SystemExit(0) + +descendant = subprocess.Popen(["sleep", "30"]) +pid_path = os.environ["NEMO_RELAY_TEST_AGENT_PIDS"] +temporary_pid_path = f"{pid_path}.tmp-{os.getpid()}" +with open(temporary_pid_path, "w") as output: + output.write(f"{os.getpid()} {descendant.pid}") +os.replace(temporary_pid_path, pid_path) +while True: + time.sleep(1) +"#, + ) + .unwrap(); + std::fs::set_permissions(&agent, std::fs::Permissions::from_mode(0o755)).unwrap(); + let config = temp.path().join("config.toml"); + std::fs::write( + &config, + format!( + "[agents.codex]\ncommand = {}\n", + toml_basic_string(agent.to_string_lossy().as_ref()) + ), + ) + .unwrap(); + + for (signal, signal_name) in [ + (libc::SIGHUP, "SIGHUP"), + (libc::SIGINT, "SIGINT"), + (libc::SIGQUIT, "SIGQUIT"), + (libc::SIGTERM, "SIGTERM"), + ] { + let pids = temp.path().join(format!("agent-pids-{signal_name}")); + let mut relay = Command::new(gateway_bin()) + .args([ + "--config", + config.to_str().unwrap(), + "run", + "--agent", + "codex", + ]) + .env("HOME", &home) + .env("XDG_CONFIG_HOME", &xdg) + .env("XDG_RUNTIME_DIR", &runtime) + .env("NEMO_RELAY_TEST_AGENT_PIDS", &pids) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(10); + while !pids.is_file() { + if Instant::now() >= deadline { + let _ = relay.kill(); + let output = relay.wait_with_output().unwrap(); + panic!( + "agent PID file was not created for {signal_name}; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + thread::sleep(Duration::from_millis(20)); + } + let supervised_pids = std::fs::read_to_string(&pids) + .unwrap() + .split_whitespace() + .map(|value| value.parse::().unwrap()) + .collect::>(); + assert_eq!(supervised_pids.len(), 2); + + // SAFETY: Relay's PID is live and owned by this test; each signal is caught and forwarded. + assert_eq!(unsafe { libc::kill(relay.id() as i32, signal) }, 0); + let status = wait_child(&mut relay); + assert!(!status.success()); + + for pid in supervised_pids { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + // SAFETY: Signal zero is a read-only existence check for the recorded child PID. + let result = unsafe { libc::kill(pid, 0) }; + if result == -1 + && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) + { + break; + } + assert!( + Instant::now() < deadline, + "coding-agent process {pid} survived Relay {signal_name}" + ); + thread::sleep(Duration::from_millis(20)); + } + } + } +} + #[test] fn cli_hook_forward_posts_payload_headers_and_prints_response() { let (server_url, received) = spawn_single_request_server(200, r#"{"continue":true}"#); @@ -2544,7 +3326,7 @@ fn cli_hook_forward_posts_payload_headers_and_prints_response() { .write_all(br#"{"hook_event_name":"sessionStart"}"#) .unwrap(); let output = child.wait_with_output().unwrap(); - let request = received.recv().unwrap(); + let request = received.recv_timeout(Duration::from_secs(2)).unwrap(); assert!(output.status.success()); assert_eq!( @@ -2557,6 +3339,234 @@ fn cli_hook_forward_posts_payload_headers_and_prints_response() { assert!(request.contains(r#"{"hook_event_name":"sessionStart"}"#)); } +#[test] +fn cli_forward_only_unfenced_hook_posts_to_an_authenticated_gateway_without_recovery() { + let (output, requests) = run_forward_only_fake_bootstrap_listener(FakeBootstrapProof::Valid); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + requests + .iter() + .any(|request| request.starts_with("POST /hooks/codex ")) + ); +} + +#[test] +fn cli_forward_only_unfenced_hook_rejects_a_foreign_listener_before_posting() { + let (output, requests) = run_forward_only_fake_bootstrap_listener(FakeBootstrapProof::Missing); + + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("foreign process")); + assert!(requests.iter().all(|request| !request.starts_with("POST "))); +} + +#[test] +fn cli_forward_only_never_reconnects_payload_after_authenticated_connection_closes() { + let temp = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let key_path = temp + .path() + .join("xdg") + .join("nemo-relay") + .join("bootstrap") + .join("fingerprint-hmac.key"); + std::fs::create_dir_all(key_path.parent().unwrap()).unwrap(); + let key = [0x5a_u8; 32]; + std::fs::write(&key_path, key).unwrap(); + let stopped = Arc::new(AtomicBool::new(false)); + let requests = Arc::new(Mutex::new(Vec::new())); + let server_stopped = stopped.clone(); + let server_requests = requests.clone(); + let server = thread::spawn(move || -> Result { + listener.set_nonblocking(true).unwrap(); + for phase in 0..2 { + let deadline = Instant::now() + Duration::from_secs(4); + let (mut stream, _) = loop { + if server_stopped.load(Ordering::Relaxed) { + return Err(format!( + "hook-forward exited before authenticated health phase {phase}" + )); + } + match listener.accept() { + Ok(connection) => break connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return Err(format!( + "timed out waiting for authenticated health phase {phase}" + )); + } + thread::sleep(Duration::from_millis(5)); + } + Err(error) => return Err(format!("bootstrap listener failed: {error}")), + } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let request = read_http_request(&mut stream); + server_requests.lock().unwrap().push(request.clone()); + if request.starts_with("POST ") { + return Err(format!( + "received lifecycle payload before authenticated health phase {phase}" + )); + } + if !request.starts_with("GET /healthz ") { + return Err(format!( + "unexpected request during authenticated health phase {phase}: {request}" + )); + } + let fingerprint = + bootstrap_request_header(&request, "x-nemo-relay-bootstrap-fingerprint") + .ok_or_else(|| format!("health phase {phase} omitted its fingerprint"))?; + let nonce = bootstrap_request_header(&request, "x-nemo-relay-bootstrap-nonce") + .ok_or_else(|| format!("health phase {phase} omitted its nonce"))?; + let proof = fake_bootstrap_proof(&key, fingerprint, nonce); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":2,"instance_id":"phase-{phase}"}}"#, + env!("CARGO_PKG_VERSION") + ); + let connection = if phase == 0 { "close" } else { "keep-alive" }; + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: {connection}\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .map_err(|error| format!("health phase {phase} response failed: {error}"))?; + // After proving identity on the second connection, close it before reading a body. + // The same listening port now behaves as the replacement foreign process. + } + let replacement_deadline = Instant::now() + Duration::from_secs(12); + while !server_stopped.load(Ordering::Relaxed) && Instant::now() < replacement_deadline { + match listener.accept() { + Ok((mut stream, _)) => { + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let request = read_http_request(&mut stream); + server_requests.lock().unwrap().push(request); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("replacement listener failed: {error}"), + } + } + Ok(2) + }); + + let mut child = Command::new(gateway_bin()) + .args([ + "hook-forward", + "codex", + "--gateway-url", + &format!("http://{address}"), + "--forward-only", + "--fail-closed", + ]) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"{\"session_id\":\"replacement-race\"}") + .unwrap(); + let output = wait_child_with_output(child); + stopped.store(true, Ordering::Relaxed); + let authenticated_phases = server.join().unwrap().unwrap(); + let requests = Arc::try_unwrap(requests).unwrap().into_inner().unwrap(); + + assert_eq!(authenticated_phases, 2); + assert!(!output.status.success()); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("POST ")) + .count(), + 0, + "replacement listener received a lifecycle payload: {requests:#?}" + ); +} + +#[test] +fn cli_transparent_run_suppresses_persistent_hooks_and_rejects_a_foreign_gateway() { + let persistent = Command::new(gateway_bin()) + .args([ + "hook-forward", + "codex", + "--gateway-url", + "http://127.0.0.1:1", + "--forward-only", + "--fail-closed", + ]) + .env("NEMO_RELAY_TRANSPARENT_RUN", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .and_then(|mut child| { + child + .stdin + .take() + .unwrap() + .write_all(b"{\"secret\":\"must-not-leave-stdin\"}")?; + child.wait_with_output() + }) + .unwrap(); + assert!(persistent.status.success()); + assert!(persistent.stdout.is_empty()); + assert!(persistent.stderr.is_empty()); + + let (server_url, received) = spawn_single_request_server(200, r#"{"continue":true}"#); + let owned = Command::new(gateway_bin()) + .args([ + "hook-forward", + "codex", + "--transparent-run", + "--fail-closed", + ]) + .env("NEMO_RELAY_TRANSPARENT_RUN", "1") + .env("NEMO_RELAY_GATEWAY_URL", &server_url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .and_then(|mut child| { + child + .stdin + .take() + .unwrap() + .write_all(b"{\"session_id\":\"owned\"}")?; + child.wait_with_output() + }) + .unwrap(); + assert!(!owned.status.success()); + assert!( + String::from_utf8_lossy(&owned.stderr).contains("verified hook forward failed"), + "{}", + String::from_utf8_lossy(&owned.stderr) + ); + let request = received.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(request.starts_with("GET /healthz ")); + assert!(!request.contains(r#"{"session_id":"owned"}"#)); +} + #[test] fn cli_hook_forward_bypasses_ambient_proxies_for_loopback_delivery() { let (server_url, received) = spawn_single_request_server(200, r#"{"continue":true}"#); @@ -2602,7 +3612,7 @@ fn cli_hook_forward_hermes_shell_hook_returns_empty_object() { .write_all(br#"{"session_id":"smoke-hermes","hook_event_name":"on_session_start"}"#) .unwrap(); let output = child.wait_with_output().unwrap(); - let request = received.recv().unwrap(); + let request = received.recv_timeout(Duration::from_secs(2)).unwrap(); assert!(output.status.success()); assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), r#"{}"#); @@ -2625,7 +3635,7 @@ fn cli_hook_forward_reports_http_failure_when_fail_closed() { .unwrap(); child.stdin.take().unwrap().write_all(b"{}").unwrap(); let output = child.wait_with_output().unwrap(); - let request = received.recv().unwrap(); + let request = received.recv_timeout(Duration::from_secs(2)).unwrap(); assert!(!output.status.success()); assert!(request.contains("POST /hooks/hermes HTTP/1.1")); @@ -2648,7 +3658,7 @@ fn cli_hook_forward_exits_two_for_guardrail_rejection() { .unwrap(); child.stdin.take().unwrap().write_all(b"{}").unwrap(); let output = child.wait_with_output().unwrap(); - let request = received.recv().unwrap(); + let request = received.recv_timeout(Duration::from_secs(2)).unwrap(); assert_eq!(output.status.code(), Some(2)); assert!(request.contains("POST /hooks/codex HTTP/1.1")); diff --git a/crates/cli/tests/coverage/agent_process_tests.rs b/crates/cli/tests/coverage/agent_process_tests.rs index 887f02d80..3a5065e6b 100644 --- a/crates/cli/tests/coverage/agent_process_tests.rs +++ b/crates/cli/tests/coverage/agent_process_tests.rs @@ -3,6 +3,24 @@ use super::*; +#[cfg(unix)] +async fn wait_for_published_pid(path: &std::path::Path, process: &str) -> i32 { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if let Some(pid) = std::fs::read_to_string(path) + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + { + return pid; + } + assert!( + std::time::Instant::now() < deadline, + "{process} did not publish a complete PID" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } +} + #[test] fn wrapper_probe_uses_last_host_token_and_validates_opaque_wrappers() { assert_eq!( @@ -55,6 +73,242 @@ fn platform_resolution_supports_explicit_paths_and_windows_pathext() { ); } +#[cfg(unix)] +#[tokio::test] +async fn supervised_wait_terminates_descendants_left_by_a_wrapper() { + let temp = tempfile::tempdir().unwrap(); + let descendant_pid_path = temp.path().join("descendant.pid"); + let argv = vec![ + "sh".into(), + "-c".into(), + "sleep 30 & echo $! > \"$1\"; exit 0".into(), + "sh".into(), + descendant_pid_path.display().to_string(), + ]; + let mut command = tokio_command(&argv); + let mut child = SupervisedChild::spawn(&mut command).await.unwrap(); + + let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()) + .await + .expect("wrapper did not exit") + .unwrap(); + + assert!(status.success()); + let pid = wait_for_published_pid(&descendant_pid_path, "wrapper descendant").await; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + // SAFETY: Signal 0 performs an existence check and does not alter the target process. + let result = unsafe { libc::kill(pid, 0) }; + if result == -1 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { + break; + } + assert!( + std::time::Instant::now() < deadline, + "wrapper descendant {pid} survived normal wrapper exit" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } +} + +#[cfg(unix)] +#[tokio::test] +async fn supervision_error_terminates_and_reaps_the_child_tree_first() { + let temp = tempfile::tempdir().unwrap(); + let child_pid_path = temp.path().join("child.pid"); + let argv = vec![ + "sh".into(), + "-c".into(), + "echo $$ > \"$1\"; exec sleep 30".into(), + "sh".into(), + child_pid_path.display().to_string(), + ]; + let mut command = tokio_command(&argv); + let mut child = SupervisedChild::spawn(&mut command).await.unwrap(); + let pid = wait_for_published_pid(&child_pid_path, "supervised child").await; + + let error = child + .inject_wait_error_for_test(std::io::Error::other("injected wait failure")) + .await + .unwrap_err(); + + assert!(error.to_string().contains("injected wait failure")); + // SAFETY: Signal zero only checks whether the reaped test child still exists. + assert_eq!(unsafe { libc::kill(pid, 0) }, -1); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ESRCH) + ); +} + +#[cfg(windows)] +#[tokio::test] +async fn windows_supervision_assigns_before_a_wrapper_can_spawn_a_descendant() { + let temp = tempfile::tempdir().unwrap(); + let descendant_pid_path = temp.path().join("descendant.pid"); + let release_path = temp.path().join("release-wrapper"); + let wrapper = temp.path().join("spawn-descendant.ps1"); + std::fs::write( + &wrapper, + r#"$ErrorActionPreference = 'Stop' +$start = [System.Diagnostics.ProcessStartInfo]::new() +$start.FileName = (Get-Process -Id $PID).Path +$start.Arguments = '-NoProfile -NonInteractive -Command "Start-Sleep -Seconds 30"' +$start.UseShellExecute = $false +$start.CreateNoWindow = $true +$start.RedirectStandardInput = $true +$start.RedirectStandardOutput = $true +$start.RedirectStandardError = $true +$child = [System.Diagnostics.Process]::Start($start) +Set-Content -LiteralPath $args[0] -Value $child.Id -Encoding ASCII -NoNewline +$deadline = [DateTime]::UtcNow.AddSeconds(15) +while (-not (Test-Path -LiteralPath $args[1])) { + if ([DateTime]::UtcNow -ge $deadline) { + throw 'Relay test did not release the wrapper' + } + Start-Sleep -Milliseconds 20 +} +"#, + ) + .unwrap(); + let argv = vec![ + "powershell.exe".into(), + "-NoProfile".into(), + "-NonInteractive".into(), + "-ExecutionPolicy".into(), + "Bypass".into(), + "-File".into(), + wrapper.display().to_string(), + descendant_pid_path.display().to_string(), + release_path.display().to_string(), + ]; + let mut command = tokio_command(&argv); + command + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + let mut child = SupervisedChild::spawn(&mut command).await.unwrap(); + + let publish_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let descendant_pid = loop { + if let Some(process_id) = read_windows_process_id(&descendant_pid_path) { + break process_id; + } + if std::time::Instant::now() >= publish_deadline { + let _ = child.terminate().await; + panic!("PowerShell wrapper did not publish its child PID"); + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + }; + let descendant = match WindowsTestProcess::open(descendant_pid) { + Ok(descendant) => descendant, + Err(error) => { + let _ = child.terminate().await; + panic!("could not retain wrapper descendant {descendant_pid}: {error}"); + } + }; + std::fs::write(release_path, b"ready").unwrap(); + + let status = match tokio::time::timeout(std::time::Duration::from_secs(15), child.wait()).await + { + Ok(status) => status.unwrap(), + Err(_) => { + let _ = child.terminate().await; + panic!("PowerShell wrapper did not exit"); + } + }; + + assert!(status.success()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let escaped = loop { + if !descendant.is_active().unwrap() { + break false; + } + if std::time::Instant::now() >= deadline { + break true; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + }; + if escaped { + descendant.terminate(); + } + assert!( + !escaped, + "wrapper descendant {descendant_pid} survived Job Object termination" + ); +} + +#[cfg(windows)] +fn read_windows_process_id(path: &std::path::Path) -> Option { + std::fs::read_to_string(path).ok()?.parse().ok() +} + +#[cfg(windows)] +struct WindowsTestProcess { + handle: windows_sys::Win32::Foundation::HANDLE, +} + +#[cfg(windows)] +impl WindowsTestProcess { + fn open(process_id: u32) -> std::io::Result { + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, + }; + + // SAFETY: The wrapper published this live descendant PID while waiting for the test to + // release it. Holding this handle prevents PID reuse from redirecting later cleanup. + let handle = unsafe { + OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, + 0, + process_id, + ) + }; + if handle.is_null() { + Err(std::io::Error::last_os_error()) + } else { + Ok(Self { handle }) + } + } + + fn is_active(&self) -> std::io::Result { + use windows_sys::Win32::Foundation::STILL_ACTIVE; + use windows_sys::Win32::System::Threading::GetExitCodeProcess; + + let mut exit_code = 0; + // SAFETY: `handle` remains live for this guard and `exit_code` is writable storage. + if unsafe { GetExitCodeProcess(self.handle, &mut exit_code) } == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(exit_code == STILL_ACTIVE as u32) + } + } + + fn terminate(&self) { + use windows_sys::Win32::System::Threading::{TerminateProcess, WaitForSingleObject}; + + if matches!(self.is_active(), Ok(false)) { + return; + } + // SAFETY: `handle` identifies the original finite test descendant and was opened with + // termination and synchronization rights. + unsafe { + TerminateProcess(self.handle, 1); + WaitForSingleObject(self.handle, 5_000); + } + } +} + +#[cfg(windows)] +impl Drop for WindowsTestProcess { + fn drop(&mut self) { + use windows_sys::Win32::Foundation::CloseHandle; + + self.terminate(); + // SAFETY: The guard uniquely owns this handle and closes it exactly once. + unsafe { CloseHandle(self.handle) }; + } +} + #[cfg(windows)] #[test] fn windows_command_shim_preserves_metacharacter_arguments() { diff --git a/crates/cli/tests/coverage/coding_agent_tests.rs b/crates/cli/tests/coverage/coding_agent_tests.rs index d2e5860ad..8447d0273 100644 --- a/crates/cli/tests/coverage/coding_agent_tests.rs +++ b/crates/cli/tests/coverage/coding_agent_tests.rs @@ -6,10 +6,12 @@ use super::*; #[test] fn agent_descriptors_are_complete_and_unique() { let arguments = CodingAgent::ALL.map(CodingAgent::as_arg); + let install_arguments = CodingAgent::ALL.map(CodingAgent::install_arg); let executables = CodingAgent::ALL.map(CodingAgent::executable); let hook_paths = CodingAgent::ALL.map(CodingAgent::hook_path); assert_eq!(arguments, ["claude", "codex", "hermes"]); + assert_eq!(install_arguments, ["claude-code", "codex", "hermes"]); assert_eq!(executables, ["claude", "codex", "hermes"]); assert_eq!( hook_paths, @@ -18,6 +20,24 @@ fn agent_descriptors_are_complete_and_unique() { assert_eq!(CodingAgent::ClaudeCode.label(), "Claude Code"); assert_eq!(CodingAgent::Codex.label(), "Codex"); assert_eq!(CodingAgent::Hermes.label(), "Hermes Agent"); + assert_eq!(CodingAgent::ClaudeCode.hook_events().len(), 14); + assert_eq!(CodingAgent::Codex.hook_events().len(), 10); + assert_eq!(CodingAgent::Hermes.hook_events().len(), 13); + assert!(!CodingAgent::ClaudeCode.uses_direct_hook_entries()); + assert!(!CodingAgent::Codex.uses_direct_hook_entries()); + assert!(CodingAgent::Hermes.uses_direct_hook_entries()); + for agent in CodingAgent::ALL { + let events = agent.hook_events(); + assert!(events.iter().all(|event| !event.is_empty())); + assert_eq!( + events + .iter() + .collect::>() + .len(), + events.len(), + "{agent:?} declares duplicate lifecycle events" + ); + } } #[test] diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index fce617cfe..f3a2f760c 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -1598,6 +1598,189 @@ fn managed_bootstrap_environment_is_not_forwarded_from_codex() { assert!(!names.iter().any(|name| name.contains("BOOTSTRAP"))); } +#[test] +fn mcp_environment_policy_handles_unresolved_values_and_historical_names_per_platform() { + assert!( + crate::mcp_environment::unresolved_self_placeholder_for_platform( + "AWS_ROLE_ARN", + "${AWS_ROLE_ARN}", + false, + ) + ); + assert!( + !crate::mcp_environment::unresolved_self_placeholder_for_platform( + "AWS_ROLE_ARN", + "${aws_role_arn}", + false, + ) + ); + assert!( + crate::mcp_environment::unresolved_self_placeholder_for_platform( + "AWS_ROLE_ARN", + "${aws_role_arn}", + true, + ) + ); + assert!( + !crate::mcp_environment::unresolved_self_placeholder_for_platform( + "AWS_ROLE_ARN", + "real-value", + true, + ) + ); + + for allowed in ["AWS_PROFILE", "NEMO_RELAY_CUSTOM", "OTEL_CUSTOM"] { + assert!( + crate::mcp_environment::previously_forwardable_name_for_platform(allowed, false), + "rejected {allowed}" + ); + } + assert!(crate::mcp_environment::previously_forwardable_name_for_platform("Aws_Custom", true,)); + for rejected in [ + "UNRELATED_SECRET", + "NEMO_RELAY_WORKER_TOKEN", + "NEMO_RELAY_TEST_CAPTURE", + ] { + assert!( + !crate::mcp_environment::previously_forwardable_name_for_platform(rejected, true), + "accepted {rejected}" + ); + } +} + +#[test] +fn transparent_gateway_fingerprint_is_stable_and_endpoint_specific() { + let first = transparent_gateway_fingerprint("http://127.0.0.1:41001"); + let repeated = transparent_gateway_fingerprint("http://127.0.0.1:41001"); + let second = transparent_gateway_fingerprint("http://127.0.0.1:41002"); + + assert_eq!(first, repeated); + assert_ne!(first, second); + assert!(first.starts_with("transparent-sha256:")); + assert_eq!(first.len(), "transparent-sha256:".len() + 64); +} + +#[test] +fn bootstrap_health_proofs_and_client_tokens_reject_every_malformed_shape() { + let key = BootstrapChallengeKey::from_bytes(&[7_u8; BOOTSTRAP_HMAC_KEY_BYTES]); + let other = BootstrapChallengeKey::from_bytes(&[8_u8; BOOTSTRAP_HMAC_KEY_BYTES]); + let fingerprint = "hmac-sha256:fixture"; + let nonce = "nonce"; + let proof = key.proof(fingerprint, nonce); + + assert!(key.verify(fingerprint, nonce, &proof)); + assert!(!key.verify("other", nonce, &proof)); + assert!(!key.verify(fingerprint, "other", &proof)); + for malformed in [ + "missing-prefix", + "hmac-sha256:short", + "hmac-sha256:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + ] { + assert!(!key.verify(fingerprint, nonce, malformed)); + } + + let token = key.client_token(); + assert!(key.verify_client_token(&token)); + assert!(!other.verify_client_token(&token)); + for malformed in [ + "missing-prefix", + "hmac-sha256:short", + "hmac-sha256:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + ] { + assert!(!key.verify_client_token(malformed)); + } + + assert!( + !verify_python_environment_attestation("source", "environment", "missing-prefix").unwrap() + ); + assert!( + !verify_python_environment_attestation("source", "environment", "hmac-sha256:short") + .unwrap() + ); +} + +#[test] +fn bootstrap_hmac_state_reports_invalid_path_and_existing_key_shapes() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&xdg).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + + let root_error = load_or_create_bootstrap_hmac_key_at(Path::new("/")).unwrap_err(); + assert!( + root_error.to_string().contains("no parent directory"), + "{root_error}" + ); + + let parent_file = temp.path().join("not-a-directory"); + std::fs::write(&parent_file, b"file").unwrap(); + let parent_error = load_or_create_bootstrap_hmac_key_at(&parent_file.join("key")).unwrap_err(); + assert!( + parent_error.to_string().contains("failed to create"), + "{parent_error}" + ); + + let directory_key = temp.path().join("directory-key"); + std::fs::create_dir(&directory_key).unwrap(); + let open_error = load_or_create_bootstrap_hmac_key_at(&directory_key).unwrap_err(); + assert!( + open_error.to_string().contains("failed to open"), + "{open_error}" + ); + + let configured_key = xdg + .join("nemo-relay") + .join("bootstrap") + .join("fingerprint-hmac.key"); + std::fs::create_dir_all(configured_key.parent().unwrap()).unwrap(); + std::fs::write(&configured_key, b"short").unwrap(); + let existing_error = BootstrapChallengeKey::load_existing() + .err() + .expect("corrupt existing bootstrap key was accepted"); + assert!( + existing_error.to_string().contains("invalid length 5"), + "{existing_error}" + ); +} + +#[cfg(unix)] +#[test] +fn bounded_identity_reader_reports_missing_unreadable_and_invalid_utf8_inputs() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing"); + let missing_error = read_bounded_regular_file(&missing, "fixture").unwrap_err(); + assert!( + missing_error.contains("failed to inspect"), + "{missing_error}" + ); + + let unreadable = temp.path().join("unreadable"); + std::fs::write(&unreadable, b"contents").unwrap(); + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap(); + let unreadable_result = read_bounded_regular_file(&unreadable, "fixture"); + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o600)).unwrap(); + let unreadable_error = unreadable_result.unwrap_err(); + assert!( + unreadable_error.contains("failed to read"), + "{unreadable_error}" + ); + + let manifest = temp.path().join("invalid-utf8.toml"); + std::fs::write(&manifest, [0xff_u8]).unwrap(); + let utf8_error = load_bounded_dynamic_plugin_manifest_bytes(&manifest).unwrap_err(); + assert!( + utf8_error.to_string().contains("is not UTF-8"), + "{utf8_error}" + ); + + assert_eq!( + resolve_dynamic_plugin_relative_path(Path::new("/manifest.toml"), "/artifact"), + PathBuf::from("/artifact") + ); +} + #[test] fn persistent_server_resolution_excludes_project_config_and_fingerprints_credentials() { let temp = tempfile::tempdir().unwrap(); @@ -1686,12 +1869,8 @@ fn plugin_launch_carries_effective_hook_limit_below_and_above_default() { format!("[gateway]\nmax_hook_payload_bytes = {limit}\n"), ) .unwrap(); - for agent in [CodingAgent::Codex, CodingAgent::ClaudeCode] { - let launch = - crate::sidecar::resolve_plugin_gateway(agent, &ServerArgs::default(), bind) - .unwrap(); - assert_eq!(launch.max_hook_payload_bytes, limit); - } + let launch = crate::sidecar::resolve_plugin_gateway(&ServerArgs::default(), bind).unwrap(); + assert_eq!(launch.max_hook_payload_bytes, limit); } } @@ -1719,6 +1898,27 @@ fn bootstrap_hmac_key_creation_is_concurrency_safe_and_stable() { assert_eq!(std::fs::metadata(path).unwrap().len(), 32); } +#[cfg(windows)] +#[test] +fn bootstrap_hmac_key_uses_and_repairs_a_private_windows_dacl() { + let temp = tempfile::tempdir().unwrap(); + set_test_windows_dacl(temp.path(), "D:P(A;;FA;;;WD)"); + let path = temp.path().join("state/fingerprint-hmac.key"); + + let original = load_or_create_bootstrap_hmac_key_at(&path).unwrap(); + + assert!(crate::file_io::windows_path_is_private(path.parent().unwrap()).unwrap()); + assert!(crate::file_io::windows_path_is_private(&path).unwrap()); + + set_test_windows_dacl(&path, "D:P(A;;FA;;;WD)"); + assert!(!crate::file_io::windows_path_is_private(&path).unwrap()); + + let reloaded = load_or_create_bootstrap_hmac_key_at(&path).unwrap(); + + assert_eq!(reloaded, original); + assert!(crate::file_io::windows_path_is_private(&path).unwrap()); +} + #[test] fn bootstrap_hmac_key_lock_wait_is_bounded_under_synchronized_contention() { let temp = tempfile::tempdir().unwrap(); @@ -1869,6 +2069,18 @@ fn persistent_hook_identity_authenticates_python_marker_without_rehashing_enviro "persistent hook identity must trust only the authenticated environment marker" ); + let resolved = load_shared_config_scoped(None, None, true).unwrap(); + let active = active_dynamic_plugin_components(None, &resolved).unwrap(); + assert_eq!(active.len(), 1); + assert!(active[0].activation_snapshot.is_some()); + let snapshot_fingerprint = persistent_bootstrap_fingerprint(&resolved, &active).unwrap(); + assert!(snapshot_fingerprint.starts_with("hmac-sha256:")); + assert!( + crate::plugins::lifecycle::test_python_environment_digest_calls() > 0, + "activation must verify the complete environment before snapshotting it" + ); + crate::plugins::lifecycle::reset_test_python_environment_digest_calls(); + std::fs::write( environment.join("site-packages/fixture.py"), b"fixture = 'mutated'\n", @@ -1882,7 +2094,6 @@ fn persistent_hook_identity_authenticates_python_marker_without_rehashing_enviro ); assert_eq!(before.bootstrap_fingerprint, after.bootstrap_fingerprint); - let resolved = load_shared_config_scoped(None, None, true).unwrap(); let error = active_dynamic_plugin_components(None, &resolved) .unwrap_err() .to_string(); @@ -2567,3 +2778,52 @@ unknown_component = "error" Some("warn") ); } + +#[cfg(windows)] +fn set_test_windows_dacl(path: &std::path::Path, sddl: &str) { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, + SetFileSecurityW, + }; + + let sddl = std::ffi::OsStr::new(sddl) + .encode_wide() + .chain(Some(0)) + .collect::>(); + let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: The SDDL is NUL-terminated and the output pointer is valid. + assert_ne!( + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + std::ptr::null_mut(), + ) + }, + 0, + "{}", + std::io::Error::last_os_error() + ); + let path = path + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect::>(); + // SAFETY: The path and descriptor remain valid for the duration of the call. + let result = unsafe { + SetFileSecurityW( + path.as_ptr(), + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + descriptor, + ) + }; + // SAFETY: The descriptor was allocated by ConvertStringSecurityDescriptor... above. + unsafe { LocalFree(descriptor.cast()) }; + assert_ne!(result, 0, "{}", std::io::Error::last_os_error()); +} diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index 41dd8eb1d..2d2468f74 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -826,7 +826,9 @@ fn configuration_and_path_helpers_cover_direct_paths_and_fallbacks() { crate::agent_process::resolve_executable("/definitely/missing"), None ); - let binary = temp.path().join("agent-bin"); + let binary = temp + .path() + .join(format!("agent-bin{}", std::env::consts::EXE_SUFFIX)); std::fs::write(&binary, "").unwrap(); assert_eq!( crate::agent_process::resolve_executable(binary.to_str().unwrap()).as_deref(), diff --git a/crates/cli/tests/coverage/file_io_tests.rs b/crates/cli/tests/coverage/file_io_tests.rs index 47fa6c93b..f61d2b73b 100644 --- a/crates/cli/tests/coverage/file_io_tests.rs +++ b/crates/cli/tests/coverage/file_io_tests.rs @@ -33,6 +33,186 @@ fn lock_attempts_distinguish_contention_from_errors() { fs2::FileExt::unlock(&waiter).unwrap(); } +#[cfg(unix)] +#[test] +fn private_atomic_write_ignores_a_permissive_umask() { + use std::os::unix::fs::PermissionsExt; + + struct UmaskGuard(libc::mode_t); + impl Drop for UmaskGuard { + fn drop(&mut self) { + // SAFETY: Restores the process umask while the environment-test mutex is held. + unsafe { libc::umask(self.0) }; + } + } + + let _lock = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + // SAFETY: The process-global umask is serialized by the environment-test mutex. + let previous = unsafe { libc::umask(0) }; + let _guard = UmaskGuard(previous); + let directory = tempdir().unwrap(); + let path = directory.path().join("secret.toml"); + + atomic_write_private(&path, b"secret\n").unwrap(); + + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); +} + +#[cfg(unix)] +#[test] +fn ordinary_atomic_write_preserves_existing_permissions() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, b"old\n").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); + + atomic_write(&path, b"new\n").unwrap(); + + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o640 + ); +} + +#[cfg(windows)] +#[test] +fn private_atomic_write_does_not_inherit_a_broad_parent_dacl() { + let directory = tempdir().unwrap(); + set_windows_dacl(directory.path(), "D:P(A;;FA;;;WD)"); + let path = directory.path().join("secret.toml"); + + atomic_write_private(&path, b"old-secret\n").unwrap(); + atomic_write_private(&path, b"new-secret\n").unwrap(); + + let parent = windows_sddl(directory.path()); + let file = windows_sddl(&path); + assert!( + parent.contains("WD"), + "parent DACL was not broadly readable: {parent}" + ); + assert!(file.contains("D:P"), "file DACL is not protected: {file}"); + assert!( + file.contains("OW") || file.contains("S-1-3-4"), + "file DACL does not grant its owner access: {file}" + ); + assert!( + !file.contains("WD"), + "file inherited Everyone access: {file}" + ); +} + +#[cfg(windows)] +#[test] +fn failed_windows_atomic_replacement_keeps_the_original_target() { + use std::os::windows::fs::OpenOptionsExt; + + let directory = tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, b"original\n").unwrap(); + let held = OpenOptions::new() + .read(true) + .share_mode(0) + .open(&path) + .unwrap(); + + let error = atomic_write(&path, b"replacement\n").unwrap_err(); + drop(held); + + assert!(error.contains("failed to replace"), "{error}"); + assert_eq!(std::fs::read(&path).unwrap(), b"original\n"); + assert!(std::fs::read_dir(directory.path()).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .ends_with(".tmp") + })); +} + +#[cfg(windows)] +fn set_windows_dacl(path: &std::path::Path, sddl: &str) { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, + SetFileSecurityW, + }; + + let sddl = windows_wide(sddl); + let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: The SDDL is NUL-terminated and the output pointer is valid. + assert_ne!( + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + std::ptr::null_mut(), + ) + }, + 0, + "{}", + std::io::Error::last_os_error() + ); + let path = windows_wide(path.as_os_str()); + // SAFETY: The path and descriptor are valid for the duration of the call. + let result = unsafe { + SetFileSecurityW( + path.as_ptr(), + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + descriptor, + ) + }; + // SAFETY: The descriptor was allocated by ConvertStringSecurityDescriptor... above. + unsafe { LocalFree(descriptor.cast()) }; + assert_ne!(result, 0, "{}", std::io::Error::last_os_error()); +} + +#[cfg(windows)] +fn windows_sddl(path: &std::path::Path) -> String { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::{DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR}; + + let mut descriptor = read_windows_dacl(path).unwrap(); + let mut rendered = std::ptr::null_mut(); + let mut rendered_len = 0; + // SAFETY: The self-relative descriptor buffer is valid, and both output pointers reference + // writable storage. The returned UTF-16 allocation is released below. + assert_ne!( + unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor.as_mut_ptr().cast::() as PSECURITY_DESCRIPTOR, + SDDL_REVISION_1, + DACL_SECURITY_INFORMATION, + &mut rendered, + &mut rendered_len, + ) + }, + 0, + "{}", + std::io::Error::last_os_error() + ); + // SAFETY: The API returned `rendered_len` initialized UTF-16 code units. + let value = String::from_utf16_lossy(unsafe { + std::slice::from_raw_parts(rendered, rendered_len as usize) + }); + // SAFETY: `rendered` was allocated by ConvertSecurityDescriptor... above. + unsafe { LocalFree(rendered.cast()) }; + value +} + #[cfg(windows)] #[test] fn windows_lock_violation_is_normalized_as_contention() { diff --git a/crates/cli/tests/coverage/gateway_tests.rs b/crates/cli/tests/coverage/gateway_tests.rs index 86aabda9e..db2bdcd33 100644 --- a/crates/cli/tests/coverage/gateway_tests.rs +++ b/crates/cli/tests/coverage/gateway_tests.rs @@ -798,6 +798,7 @@ async fn passthrough_rejects_unsupported_provider_path_directly() { config: config.clone(), bootstrap_fingerprint: None, bootstrap_challenge_key: None, + require_provider_client_token: false, http: test_http_client(), sessions: SessionManager::new(config), last_activity: std::sync::Arc::new(std::sync::Mutex::new(std::time::Instant::now())), @@ -831,6 +832,7 @@ async fn models_rejects_non_get_requests_directly() { config: config.clone(), bootstrap_fingerprint: None, bootstrap_challenge_key: None, + require_provider_client_token: false, http: test_http_client(), sessions: SessionManager::new(config), last_activity: std::sync::Arc::new(std::sync::Mutex::new(std::time::Instant::now())), diff --git a/crates/cli/tests/coverage/hermes_tests.rs b/crates/cli/tests/coverage/hermes_tests.rs index d9d5a49f9..8796bb929 100644 --- a/crates/cli/tests/coverage/hermes_tests.rs +++ b/crates/cli/tests/coverage/hermes_tests.rs @@ -10,10 +10,17 @@ use serde_json::{Value, json}; use super::*; use crate::config::CodingAgent; +const TEST_GENERATION_TOKEN: &str = "test-generation"; + fn relay_binary(root: &Path) -> PathBuf { let path = root.join("NeMo Relay's bin").join("nemo-relay"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, b"relay").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } path } @@ -75,20 +82,52 @@ fn install_uses_the_native_hermes_allowlist_lock() { #[test] fn hook_command_round_trips_paths_and_recognizes_owned_legacy_spellings() { let relay = Path::new("/tmp/NeMo $Relay`test'/bin/nemo-relay"); + let generation = Path::new("/tmp/generation"); assert_eq!( - persistent_hook_command_for_platform(relay, false), - "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward hermes --gateway-url http://127.0.0.1:47632" + persistent_hook_command_for_platform(relay, generation, TEST_GENERATION_TOKEN, false), + "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward hermes --gateway-url http://127.0.0.1:47632 --generation-file /tmp/generation --generation-token test-generation" ); assert_eq!( - persistent_hook_command_for_platform( + crate::installer::decode_windows_hook_command(&persistent_hook_command_for_platform( Path::new(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"), + Path::new(r"C:\Temp\generation"), + TEST_GENERATION_TOKEN, true, - ), - r#""C:\Program Files\NeMo 100%%cd:~,%%\bin\nemo-relay.exe" hook-forward hermes --gateway-url http://127.0.0.1:47632"# + )) + .unwrap(), + vec![ + r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe", + "hook-forward", + "hermes", + "--gateway-url", + crate::sidecar::DEFAULT_URL, + "--generation-file", + r"C:\Temp\generation", + "--generation-token", + TEST_GENERATION_TOKEN, + ] ); assert_eq!( - crate::installer::transparent_hook_forward_command(relay, CodingAgent::Hermes), - "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward hermes" + crate::installer::transparent_hook_forward_command_for_platform( + relay, + CodingAgent::Hermes, + "http://127.0.0.1:1234", + false, + ), + "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward hermes --gateway-url http://127.0.0.1:1234 --transparent-run" + ); + let encoded = persistent_hook_command_for_platform( + Path::new(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"), + Path::new(r"C:\Temp\generation"), + TEST_GENERATION_TOKEN, + true, + ); + let encoded_codex = crate::installer::persistent_hook_forward_command_for_platform( + Path::new(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"), + CodingAgent::Codex, + Path::new(r"C:\Temp\generation"), + TEST_GENERATION_TOKEN, + true, ); for command in [ "nemo-relay hook-forward hermes", @@ -96,6 +135,7 @@ fn hook_command_round_trips_paths_and_recognizes_owned_legacy_spellings() { "'/old/plugin-shim hook hermes/nemo-relay' plugin-shim hook hermes", "'/old install/nemo-relay' plugin-shim hook hermes --gateway-url http://127.0.0.1:47632", r#""C:\Program Files\NeMo 100%%\nemo-relay.exe" plugin-shim hook hermes"#, + &encoded, ] { assert!( is_managed_hook_command(command), @@ -107,13 +147,14 @@ fn hook_command_round_trips_paths_and_recognizes_owned_legacy_spellings() { "echo nemo-relay hook-forward hermes", "nemo-relay plugin-shim hook codex", "nemo-relay-safe plugin-shim hook hermes", + &encoded_codex, ] { assert!(!is_managed_hook_command(command), "overmatched: {command}"); } } #[test] -fn forwarded_environment_is_minimal_and_includes_explicit_config_references() { +fn forwarded_environment_includes_static_dynamic_and_config_referenced_names() { let environment = vec![ "AWS_REGION".into(), "NEMO_RELAY_CUSTOM".into(), @@ -133,9 +174,10 @@ fn forwarded_environment_is_minimal_and_includes_explicit_config_references() { assert!(names.contains(&"NEMO_RELAY_CUSTOM".into())); assert!(names.contains(&"CUSTOM_EXPORT_TOKEN".into())); assert!(names.contains(&"AWS_PRIVATE_SECRET".into())); + assert!(names.contains(&"AWS_PROFILE".into())); + assert!(names.contains(&"OTEL_EXPORTER_OTLP_ENDPOINT".into())); assert!(!names.contains(&"NEMO_RELAY_WORKER_TOKEN".into())); assert!(!names.contains(&"UNRELATED_SECRET".into())); - assert!(!names.contains(&"OTEL_EXPORTER_OTLP_ENDPOINT".into())); } #[test] @@ -143,7 +185,7 @@ fn persistent_config_migrates_owned_state_and_preserves_unrelated_config() { let temp = tempfile::tempdir().unwrap(); let relay = relay_binary(temp.path()); let generation = temp.path().join(GENERATION_FILE_NAME); - let command = persistent_hook_command(&relay); + let command = persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); let existing = r#" model: keep-me mcp_servers: @@ -168,6 +210,7 @@ hooks: &relay, &command, &generation, + TEST_GENERATION_TOKEN, &["AWS_REGION".into()], ) .unwrap(); @@ -179,7 +222,12 @@ hooks: ); assert_eq!( merged["mcp_servers"][MCP_SERVER_NAME], - expected_mcp_server(&relay, &generation, &["AWS_REGION".into()]) + expected_mcp_server( + &relay, + &generation, + TEST_GENERATION_TOKEN, + &["AWS_REGION".into()] + ) ); assert_eq!( merged["mcp_servers"][MCP_SERVER_NAME]["env"]["AWS_REGION"], @@ -205,7 +253,7 @@ hooks: merged["hooks"]["custom_event"][0]["command"], json!("keep-custom") ); - for event in HERMES_HOOK_EVENTS { + for event in CodingAgent::Hermes.hook_events() { let groups = merged["hooks"][event].as_array().unwrap(); assert_eq!( groups @@ -223,7 +271,7 @@ fn persistent_config_rejects_a_foreign_server_with_the_reserved_name() { let temp = tempfile::tempdir().unwrap(); let relay = relay_binary(temp.path()); let generation = temp.path().join(GENERATION_FILE_NAME); - let command = persistent_hook_command(&relay); + let command = persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); let existing = r#" model: keep-me mcp_servers: @@ -232,9 +280,16 @@ mcp_servers: args: [serve] "#; - let error = persistent_config(Some(existing), &relay, &command, &generation, &[]) - .unwrap_err() - .to_string(); + let error = persistent_config( + Some(existing), + &relay, + &command, + &generation, + TEST_GENERATION_TOKEN, + &[], + ) + .unwrap_err() + .to_string(); assert!(error.contains("not managed by Relay"), "{error}"); assert!(error.contains("rename or remove"), "{error}"); @@ -266,7 +321,8 @@ fn foreign_reserved_server_aborts_install_before_any_file_changes() { fn trusted_hooks_migrates_only_relay_approvals_and_records_every_event() { let temp = tempfile::tempdir().unwrap(); let relay = relay_binary(temp.path()); - let command = persistent_hook_command(&relay); + let generation = temp.path().join(GENERATION_FILE_NAME); + let command = persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); let existing = json!({ "schema": 7, "approvals": [ @@ -291,8 +347,8 @@ fn trusted_hooks_migrates_only_relay_approvals_and_records_every_event() { .iter() .any(|entry| entry["command"] == json!("custom-hook")) ); - assert_eq!(approvals.len(), HERMES_HOOK_EVENTS.len() + 1); - for event in HERMES_HOOK_EVENTS { + assert_eq!(approvals.len(), CodingAgent::Hermes.hook_events().len() + 1); + for event in CodingAgent::Hermes.hook_events() { let entries = approvals .iter() .filter(|entry| entry["event"] == json!(event) && entry["command"] == json!(command)) @@ -310,13 +366,29 @@ fn trusted_hooks_migrates_only_relay_approvals_and_records_every_event() { fn verification_rejects_relay_handlers_and_approvals_on_unexpected_events() { let temp = tempfile::tempdir().unwrap(); let relay = relay_binary(temp.path()); - let command = persistent_hook_command(&relay); let generation = temp.path().join(GENERATION_FILE_NAME); - let mut config = persistent_config(None, &relay, &command, &generation, &[]).unwrap(); + let command = persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); + let mut config = persistent_config( + None, + &relay, + &command, + &generation, + TEST_GENERATION_TOKEN, + &[], + ) + .unwrap(); config["hooks"]["unexpected_event"] = json!([{"command": command, "timeout": 30}]); let error = verify_hook_definitions(&config, &command).unwrap_err(); assert!(error.contains("unexpected Relay hook")); - let mut malformed = persistent_config(None, &relay, &command, &generation, &[]).unwrap(); + let mut malformed = persistent_config( + None, + &relay, + &command, + &generation, + TEST_GENERATION_TOKEN, + &[], + ) + .unwrap(); malformed["hooks"]["unexpected_event"] = json!({"command": command}); let error = verify_hook_definitions(&malformed, &command).unwrap_err(); assert!(error.contains("must be an array")); @@ -357,10 +429,31 @@ fn install_is_verified_idempotent_and_rotates_the_generation() { install_persistent_with(paths.clone(), &relay, &environment, None, now, atomic_write) .unwrap(); assert_eq!(written, paths.all()); - let first_generation = std::fs::read_to_string(&paths.generation).unwrap(); + let first_generation = + crate::install_generation::InstallGeneration::capture(paths.generation.clone()) + .unwrap() + .token() + .to_owned(); + let first_config = yaml(&paths.config); + let first_command = first_config["hooks"]["on_session_start"][0]["command"] + .as_str() + .unwrap() + .to_string(); + assert_eq!( + first_config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_TOKEN_ENV], + json!(first_generation) + ); + assert!(crate::hook_assertions::command_has_arguments( + &first_command, + &["--generation-token", &first_generation] + )); install_persistent_with(paths.clone(), &relay, &environment, None, now, atomic_write).unwrap(); - let second_generation = std::fs::read_to_string(&paths.generation).unwrap(); + let second_generation = + crate::install_generation::InstallGeneration::capture(paths.generation.clone()) + .unwrap() + .token() + .to_owned(); assert_ne!(first_generation, second_generation); let config = yaml(&paths.config); @@ -377,6 +470,22 @@ fn install_is_verified_idempotent_and_rotates_the_generation() { config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_FILE_ENV], json!(paths.generation.display().to_string()) ); + assert_eq!( + config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_TOKEN_ENV], + json!(second_generation) + ); + assert_ne!( + first_config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_TOKEN_ENV], + config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_TOKEN_ENV] + ); + assert!(crate::hook_assertions::command_has_arguments( + &first_command, + &["--generation-token", &first_generation] + )); + assert!(!crate::hook_assertions::command_has_arguments( + &first_command, + &["--generation-token", &second_generation] + )); assert_eq!( config["mcp_servers"][MCP_SERVER_NAME]["env"]["OTEL_SERVICE_NAME"], json!("${OTEL_SERVICE_NAME}") @@ -386,7 +495,59 @@ fn install_is_verified_idempotent_and_rotates_the_generation() { .as_array() .unwrap() .len(), - HERMES_HOOK_EVENTS.len() + CodingAgent::Hermes.hook_events().len() + ); +} + +#[test] +fn reinstall_verifies_generation_through_the_existing_retirement_transaction() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + let first_token = InstallGeneration::capture(paths.generation.clone()) + .unwrap() + .token() + .to_owned(); + let mut retirement = GenerationRetirement::acquire(&paths.generation) + .unwrap() + .unwrap(); + retirement.invalidate_for_replacement().unwrap(); + + let result = install_persistent_with_generation( + paths.clone(), + &relay, + &[], + None, + Some(&retirement), + UNIX_EPOCH, + atomic_write, + ); + finish_generation_mutation(result, Some(&mut retirement), "install").unwrap(); + drop(retirement); + + let second_token = InstallGeneration::capture(paths.generation) + .unwrap() + .token() + .to_owned(); + assert_ne!(first_token, second_token); +} + +#[test] +fn diagnosis_rejects_a_stale_mcp_generation_identity() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + let mut config = yaml(&paths.config); + config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_TOKEN_ENV] = json!("stale-generation"); + std::fs::write(&paths.config, serde_yaml::to_string(&config).unwrap()).unwrap(); + + let error = diagnose_persistent(&paths.config).unwrap_err(); + + assert!( + error.contains("expected generation identity is stale"), + "{error}" ); } @@ -439,6 +600,108 @@ fn install_rolls_back_config_allowlist_and_generation_after_write_failure() { } } +#[cfg(unix)] +#[test] +fn install_rollback_restores_original_file_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + let originals = [ + (&paths.config, b"model: original\n".as_slice(), 0o640), + ( + &paths.allowlist, + b"{\"approvals\":[{\"event\":\"x\",\"command\":\"custom\"}]}\n".as_slice(), + 0o644, + ), + ( + &paths.generation, + b"original-generation\n".as_slice(), + 0o600, + ), + ]; + for (path, bytes, mode) in originals { + std::fs::write(path, bytes).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); + } + let expected_modes = paths + .all() + .map(|path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777); + let writes = Cell::new(0); + + install_persistent_with( + paths.clone(), + &relay, + &[], + None, + UNIX_EPOCH, + |path, bytes| { + let write = writes.get() + 1; + writes.set(write); + if write == 3 { + return Err("injected config write failure".into()); + } + atomic_write(path, bytes)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|error| error.to_string()) + }, + ) + .unwrap_err(); + + for (index, path) in paths.all().iter().enumerate() { + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + expected_modes[index], + "{}", + path.display() + ); + } +} + +#[test] +fn composed_install_rollback_restores_the_visible_preexisting_generation() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + let previous = + crate::install_generation::InstallGeneration::capture(paths.generation.clone()).unwrap(); + let mut retirement = GenerationRetirement::acquire(&paths.generation) + .unwrap() + .unwrap(); + retirement.invalidate_for_replacement().unwrap(); + let writes = Cell::new(0); + + let result = install_persistent_with( + paths.clone(), + &relay, + &[], + None, + UNIX_EPOCH, + |path, bytes| { + let write = writes.get() + 1; + writes.set(write); + if write == 3 { + return Err("injected composed install failure".into()); + } + atomic_write(path, bytes) + }, + ); + let error = finish_generation_mutation(result, Some(&mut retirement), "install") + .unwrap_err() + .to_string(); + + assert!( + error.contains("injected composed install failure"), + "{error}" + ); + previous.verify_current().unwrap(); + crate::install_generation::InstallGeneration::capture(paths.generation).unwrap(); +} + #[test] fn install_rolls_back_after_post_write_verification_failure() { let temp = tempfile::tempdir().unwrap(); @@ -557,6 +820,43 @@ fn uninstall_rolls_back_every_file_when_commit_fails() { } } +#[test] +fn composed_uninstall_rollback_restores_the_visible_preexisting_generation() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + std::fs::write(&paths.config, "model: keep\n").unwrap(); + std::fs::write(&paths.allowlist, "{\"owner\":\"keep\"}\n").unwrap(); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + let previous = + crate::install_generation::InstallGeneration::capture(paths.generation.clone()).unwrap(); + let mut retirement = GenerationRetirement::acquire(&paths.generation) + .unwrap() + .unwrap(); + retirement.invalidate_for_replacement().unwrap(); + let writes = Cell::new(0); + + let result = uninstall_persistent_with(paths.clone(), |path, bytes| { + let write = writes.get() + 1; + writes.set(write); + if write == 2 { + return Err("injected composed uninstall failure".into()); + } + atomic_write(path, bytes) + }); + let error = finish_generation_mutation(result, Some(&mut retirement), "uninstall") + .unwrap_err() + .to_string(); + + assert!( + error.contains("injected composed uninstall failure"), + "{error}" + ); + previous.verify_current().unwrap(); + crate::install_generation::InstallGeneration::capture(paths.generation).unwrap(); +} + #[test] fn uninstall_noops_without_creating_a_hermes_home() { let temp = tempfile::tempdir().unwrap(); @@ -598,7 +898,12 @@ fn persistent_state_detection_recognizes_each_relay_owned_surface() { std::fs::write( &roots[1].config, serde_yaml::to_string(&json!({ - "mcp_servers": {MCP_SERVER_NAME: expected_mcp_server(&relay, &roots[1].generation, &[])} + "mcp_servers": {MCP_SERVER_NAME: expected_mcp_server( + &relay, + &roots[1].generation, + TEST_GENERATION_TOKEN, + &[] + )} })) .unwrap(), ) @@ -607,7 +912,11 @@ fn persistent_state_detection_recognizes_each_relay_owned_surface() { &roots[2].config, serde_yaml::to_string(&json!({ "hooks": { - "on_session_start": [{"command": persistent_hook_command(&relay)}] + "on_session_start": [{"command": persistent_hook_command( + &relay, + &roots[2].generation, + TEST_GENERATION_TOKEN + ).unwrap()}] } })) .unwrap(), @@ -618,7 +927,11 @@ fn persistent_state_detection_recognizes_each_relay_owned_surface() { serde_json::to_vec(&json!({ "approvals": [{ "event": "on_session_start", - "command": persistent_hook_command(&relay) + "command": persistent_hook_command( + &relay, + &roots[3].generation, + TEST_GENERATION_TOKEN + ).unwrap() }] })) .unwrap(), @@ -638,7 +951,12 @@ fn persistent_state_detection_recognizes_each_relay_owned_surface() { fn transparent_config_suppresses_only_the_managed_mcp_and_uses_one_relay_hook() { let temp = tempfile::tempdir().unwrap(); let relay = relay_binary(temp.path()); - let command = crate::installer::transparent_hook_forward_command(&relay, CodingAgent::Hermes); + let command = crate::installer::transparent_hook_forward_command( + &relay, + CodingAgent::Hermes, + "http://127.0.0.1:1234", + ) + .unwrap(); let existing = format!( r#" mcp_servers: @@ -654,15 +972,17 @@ hooks: "#, relay = relay.display() ); - let patched: Value = - serde_yaml::from_str(&transparent_config(&existing, &relay).unwrap()).unwrap(); + let patched: Value = serde_yaml::from_str( + &transparent_config(&existing, &relay, "http://127.0.0.1:1234").unwrap(), + ) + .unwrap(); assert!(patched["mcp_servers"].get(MCP_SERVER_NAME).is_none()); assert_eq!( patched["mcp_servers"]["filesystem"]["command"], json!("fs-mcp") ); - for event in HERMES_HOOK_EVENTS { + for event in CodingAgent::Hermes.hook_events() { let groups = patched["hooks"][event].as_array().unwrap(); assert_eq!( groups diff --git a/crates/cli/tests/coverage/hook_assertions.rs b/crates/cli/tests/coverage/hook_assertions.rs new file mode 100644 index 000000000..18d2edce4 --- /dev/null +++ b/crates/cli/tests/coverage/hook_assertions.rs @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use serde_json::Value; + +pub(crate) fn command_has_arguments(command: &str, expected: &[&str]) -> bool { + let arguments = crate::installer::decode_windows_hook_command(command) + .or_else(|| shell_words::split(command).ok()); + arguments.is_some_and(|arguments| { + arguments.windows(expected.len()).any(|window| { + window + .iter() + .map(String::as_str) + .eq(expected.iter().copied()) + }) + }) +} + +pub(crate) fn value_has_command_arguments(value: &Value, expected: &[&str]) -> bool { + match value { + Value::String(_) => false, + Value::Array(values) => values + .iter() + .any(|value| value_has_command_arguments(value, expected)), + Value::Object(values) => values.iter().any(|(name, value)| { + if name == "command" { + value + .as_str() + .is_some_and(|command| command_has_arguments(command, expected)) + } else { + value_has_command_arguments(value, expected) + } + }), + Value::Null | Value::Bool(_) | Value::Number(_) => false, + } +} + +#[test] +fn command_matching_requires_complete_arguments() { + assert!(command_has_arguments( + "'/opt/NeMo Relay/nemo-relay' hook-forward codex --transparent-run", + &["hook-forward", "codex", "--transparent-run"] + )); + assert!(!command_has_arguments( + "nemo-relay hook-forward codex --transparent-run-disabled", + &["hook-forward", "codex", "--transparent-run"] + )); +} + +#[test] +fn structured_matching_ignores_non_command_metadata() { + let value = serde_json::json!({ + "description": "nemo-relay hook-forward codex --transparent-run", + "handler": { + "command": "nemo-relay hook-forward codex --transparent-run-disabled" + } + }); + assert!(!value_has_command_arguments( + &value, + &["hook-forward", "codex", "--transparent-run"] + )); +} diff --git a/crates/cli/tests/coverage/install_generation_tests.rs b/crates/cli/tests/coverage/install_generation_tests.rs index 24a543c8e..3d736982b 100644 --- a/crates/cli/tests/coverage/install_generation_tests.rs +++ b/crates/cli/tests/coverage/install_generation_tests.rs @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::fs::{File, OpenOptions}; +#[cfg(unix)] +use std::fs::File; use tempfile::tempdir; @@ -9,17 +10,83 @@ use super::*; #[test] fn generation_markers_have_one_canonical_encoding() { - let active = GenerationMarker::Active("generation-a".into()); - let retired = GenerationMarker::Retired("generation-a".into()); + let lock_path = PathBuf::from("generation.lock"); + let active = GenerationMarker::active("generation-a", &lock_path); + let retired = active.retired(); assert_eq!(active.token(), "generation-a"); assert_eq!(retired.token(), "generation-a"); - assert_eq!(active.encoded(), "generation-a\n"); - assert_eq!(retired.encoded(), "retired:generation-a\n"); + let active_encoded = active.encoded(); + let retired_encoded = retired.encoded(); + assert_eq!(active_encoded.lines().next(), Some("generation-a")); + assert_eq!(retired_encoded.lines().next(), Some("retired:generation-a")); + assert_eq!( + decode_lock_path( + active_encoded + .lines() + .nth(1) + .unwrap() + .strip_prefix(GENERATION_LOCK_PATH_PREFIX) + .unwrap() + ) + .unwrap(), + lock_path + ); assert!(!active.is_retired()); assert!(retired.is_retired()); } +#[test] +fn an_explicit_missing_external_lock_is_rejected_without_creating_it() { + let dir = tempdir().unwrap(); + let marker_path = dir.path().join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("missing-external.lock"); + let marker = GenerationMarker::active("generation-a", &lock_path); + std::fs::write(&marker_path, marker.encoded()).unwrap(); + + let error = InstallGeneration::capture(marker_path).unwrap_err(); + + assert!( + error.contains("failed to open MCP install generation lock"), + "{error}" + ); + assert!(!lock_path.exists()); +} + +#[test] +fn an_explicit_relative_external_lock_path_is_rejected() { + let dir = tempdir().unwrap(); + let marker_path = dir.path().join(GENERATION_FILE_NAME); + let marker = GenerationMarker::active("generation-a", "relative-generation.lock"); + std::fs::write(&marker_path, marker.encoded()).unwrap(); + + let error = InstallGeneration::capture(marker_path).unwrap_err(); + + assert!(error.contains("non-absolute external lock path"), "{error}"); +} + +#[test] +fn an_explicit_empty_external_lock_is_rejected_without_modifying_it() { + let dir = tempdir().unwrap(); + let marker_path = dir.path().join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("empty-external.lock"); + std::fs::write(&lock_path, []).unwrap(); + let marker = GenerationMarker::active("generation-a", &lock_path); + std::fs::write(&marker_path, marker.encoded()).unwrap(); + + let error = InstallGeneration::capture(marker_path.clone()).unwrap_err(); + assert!(error.contains("generation lock"), "{error}"); + assert!(error.contains("is empty"), "{error}"); + assert_eq!(std::fs::read(&lock_path).unwrap(), b""); + + let error = GenerationRetirement::acquire(&marker_path) + .err() + .expect("empty external lock was accepted"); + assert!(error.contains("generation lock"), "{error}"); + assert!(error.contains("is empty"), "{error}"); + assert_eq!(std::fs::read(lock_path).unwrap(), b""); +} + #[test] fn retirement_without_invalidation_only_releases_the_lock() { let dir = tempdir().unwrap(); @@ -57,38 +124,443 @@ fn active_generation_guard_fences_retirement_until_startup_finishes() { ); } +#[test] +fn guarded_capture_fences_retirement_without_a_reacquisition_gap() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let (_generation, guard) = InstallGeneration::capture_guarded(path.clone()).unwrap(); + + let error = GenerationRetirement::acquire_with_timeout(&path, Duration::from_millis(20)) + .err() + .expect("retirement entered between generation capture and its guard"); + assert!(error.contains("timed out waiting"), "{error}"); + + drop(guard); + assert!( + GenerationRetirement::acquire_with_timeout(&path, Duration::from_secs(1)) + .unwrap() + .is_some() + ); +} + +#[test] +fn expected_token_rejects_a_stale_launcher_after_same_path_rotation() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + let stale_token = write_new_generation_with_token(&path).unwrap(); + let current_token = write_new_generation_with_token(&path).unwrap(); + + let error = InstallGeneration::capture_guarded_expected(path.clone(), &stale_token) + .err() + .expect("stale launcher adopted the replacement generation"); + + assert!(error.contains("has been retired"), "{error}"); + let (generation, guard) = + InstallGeneration::capture_guarded_expected(path.clone(), ¤t_token).unwrap(); + assert_eq!(generation.token(), current_token); + assert_eq!( + InstallGeneration::capture(path).unwrap().token(), + current_token + ); + drop(guard); +} + +#[test] +fn generation_environment_requires_and_verifies_the_complete_identity_pair() { + struct EnvironmentRestore { + file: Option, + token: Option, + } + impl Drop for EnvironmentRestore { + fn drop(&mut self) { + // SAFETY: This test holds the repository-wide environment mutex. + unsafe { + match self.file.take() { + Some(value) => std::env::set_var(GENERATION_FILE_ENV, value), + None => std::env::remove_var(GENERATION_FILE_ENV), + } + match self.token.take() { + Some(value) => std::env::set_var(GENERATION_TOKEN_ENV, value), + None => std::env::remove_var(GENERATION_TOKEN_ENV), + } + } + } + } + + let _environment = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _restore = EnvironmentRestore { + file: std::env::var_os(GENERATION_FILE_ENV), + token: std::env::var_os(GENERATION_TOKEN_ENV), + }; + // SAFETY: This test holds the repository-wide environment mutex. + unsafe { + std::env::remove_var(GENERATION_FILE_ENV); + std::env::remove_var(GENERATION_TOKEN_ENV); + } + assert!( + InstallGeneration::capture_guarded_from_env() + .unwrap() + .is_none() + ); + + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + let token = write_new_generation_with_token(&path).unwrap(); + // SAFETY: This test holds the repository-wide environment mutex. + unsafe { std::env::set_var(GENERATION_FILE_ENV, &path) }; + let error = InstallGeneration::capture_guarded_from_env() + .err() + .expect("generation path without identity was accepted"); + assert!(error.contains(GENERATION_TOKEN_ENV), "{error}"); + + // SAFETY: This test holds the repository-wide environment mutex. + unsafe { + std::env::remove_var(GENERATION_FILE_ENV); + std::env::set_var(GENERATION_TOKEN_ENV, &token); + } + let error = InstallGeneration::capture_guarded_from_env() + .err() + .expect("generation identity without path was accepted"); + assert!(error.contains(GENERATION_FILE_ENV), "{error}"); + + // SAFETY: This test holds the repository-wide environment mutex. + unsafe { std::env::set_var(GENERATION_FILE_ENV, &path) }; + let (generation, guard) = InstallGeneration::capture_guarded_from_env() + .unwrap() + .unwrap(); + assert_eq!(generation.token(), token); + drop(guard); + + // SAFETY: This test holds the repository-wide environment mutex. + unsafe { std::env::set_var(GENERATION_TOKEN_ENV, "stale-generation") }; + let error = InstallGeneration::capture_guarded_from_env() + .err() + .expect("stale generation identity was accepted"); + assert!(error.contains("has been retired"), "{error}"); +} + +#[test] +fn guarded_capture_rejects_a_marker_and_lock_replaced_after_open() { + let dir = tempdir().unwrap(); + let old_path = dir.path().join("old").join(GENERATION_FILE_NAME); + let path = dir.path().join("visible").join(GENERATION_FILE_NAME); + write_new_generation(&old_path).unwrap(); + write_new_generation(&path).unwrap(); + std::fs::write(&old_path, "same-marker\n").unwrap(); + std::fs::write(&path, "same-marker\n").unwrap(); + let old_marker = open_generation(&old_path).unwrap(); + let old_lock = open_generation_lock(&old_path).unwrap(); + + // Model a force install promoting generation B after capture opened generation A's files. + // Both markers intentionally match, so the visible lock-file identity is what must reject the + // stale handle pair. Distinct paths model the post-promotion view without relying on replacing + // an open file, which Windows does not permit through MoveFileEx. + + let observed = read_generation_marker(&old_marker, &old_path).unwrap(); + let error = InstallGeneration::capture_guarded_open_files_with_lock( + path.clone(), + old_marker, + old_lock, + observed, + ) + .err() + .expect("capture adopted a new marker through the old generation lock"); + + assert!(error.contains("has been retired"), "{error}"); + assert!(error.contains(&path.display().to_string()), "{error}"); +} + +#[test] +fn rollback_does_not_publish_the_old_token_through_a_replacement_lock() { + let dir = tempdir().unwrap(); + let plugin = dir.path().join("plugin"); + let backup = dir.path().join("plugin-backup"); + let path = plugin.join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("generation-transaction.lock"); + write_new_generation_with_token_at(&path, &lock_path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + + std::fs::rename(&plugin, &backup).unwrap(); + let replacement_token = write_staged_generation_with_token(&path, &lock_path).unwrap(); + let replacement_marker = std::fs::read(&path).unwrap(); + + let error = retirement.restore_after_rollback().unwrap_err(); + + assert!(error.contains("lock identity changed"), "{error}"); + assert_eq!(std::fs::read(&path).unwrap(), replacement_marker); + drop(retirement); + assert_eq!( + InstallGeneration::capture(path).unwrap().token(), + replacement_token + ); +} + +#[test] +fn staged_generation_lock_remains_held_across_marketplace_promotion() { + let dir = tempdir().unwrap(); + let staged_plugin = dir.path().join("staged").join("plugin"); + let live_plugin = dir.path().join("live").join("plugin"); + let staged_marker = staged_plugin.join(GENERATION_FILE_NAME); + let live_marker = live_plugin.join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("replacement-generation.lock"); + write_new_generation_with_token_at(&staged_marker, &lock_path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&staged_marker) + .unwrap() + .unwrap(); + + std::fs::create_dir_all(live_plugin.parent().unwrap()).unwrap(); + std::fs::rename(&staged_plugin, &live_plugin).unwrap(); + retirement.retarget_promoted_marker(&live_marker).unwrap(); + + let error = GenerationRetirement::acquire_with_timeout(&live_marker, Duration::from_millis(20)) + .err() + .expect("promoted generation escaped its staged transaction lock"); + assert!(error.contains("timed out waiting"), "{error}"); + + drop(retirement); + assert!( + GenerationRetirement::acquire_with_timeout(&live_marker, Duration::from_secs(1)) + .unwrap() + .is_some() + ); +} + +#[test] +fn legacy_sibling_lock_can_be_released_for_tree_move_and_reacquired_for_rollback() { + let dir = tempdir().unwrap(); + let plugin = dir.path().join("plugin"); + let backup = dir.path().join("plugin-backup"); + let marker_path = plugin.join(GENERATION_FILE_NAME); + write_legacy_generation(&marker_path, "generation-a").unwrap(); + let mut retirement = GenerationRetirement::acquire(&marker_path) + .unwrap() + .unwrap(); + retirement.invalidate_for_replacement().unwrap(); + retirement.release_legacy_lock_for_tree_mutation().unwrap(); + + std::fs::rename(&plugin, &backup).unwrap(); + std::fs::rename(&backup, &plugin).unwrap(); + retirement.restore_after_rollback().unwrap(); + + assert_eq!( + InstallGeneration::capture(marker_path).unwrap().token(), + "generation-a" + ); +} + +#[test] +fn relative_legacy_marker_reencodes_an_absolute_lock_for_rollback() { + let current_dir = std::env::current_dir().unwrap(); + let dir = tempfile::Builder::new() + .prefix(".relay-relative-generation-") + .tempdir_in(¤t_dir) + .unwrap(); + let relative_root = dir.path().strip_prefix(¤t_dir).unwrap(); + let plugin = relative_root.join("plugin"); + let backup = relative_root.join("plugin-backup"); + let marker_path = plugin.join(GENERATION_FILE_NAME); + assert!(!marker_path.is_absolute()); + write_legacy_generation(&marker_path, "generation-a").unwrap(); + let mut retirement = GenerationRetirement::acquire(&marker_path) + .unwrap() + .unwrap(); + + retirement.invalidate_for_replacement().unwrap(); + let retired = read_generation_marker_path(&marker_path).unwrap(); + assert!(retired.is_retired()); + assert!(retired.lock_path().is_absolute()); + retirement.release_legacy_lock_for_tree_mutation().unwrap(); + std::fs::rename(&plugin, &backup).unwrap(); + std::fs::rename(&backup, &plugin).unwrap(); + retirement.restore_after_rollback().unwrap(); + + assert_eq!( + InstallGeneration::capture(marker_path).unwrap().token(), + "generation-a" + ); +} + +#[test] +fn generation_lock_identity_is_independent_of_path_spelling() { + let dir = tempdir().unwrap(); + let alias_dir = dir.path().join("alias"); + std::fs::create_dir(&alias_dir).unwrap(); + let marker_path = dir.path().join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("generation-transaction.lock"); + write_new_generation_with_token_at(&marker_path, &lock_path).unwrap(); + let retirement = GenerationRetirement::acquire(&marker_path) + .unwrap() + .unwrap(); + let aliased_lock_path = alias_dir.join("..").join("generation-transaction.lock"); + + assert_ne!(retirement.lock_path(), aliased_lock_path); + assert!(retirement.uses_lock_path(&aliased_lock_path).unwrap()); +} + +#[test] +fn installer_reads_the_promoted_token_through_its_existing_transaction() { + let dir = tempdir().unwrap(); + let plugin = dir.path().join("plugin"); + let backup = dir.path().join("plugin-backup"); + let marker_path = plugin.join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("generation-transaction.lock"); + write_new_generation_with_token_at(&marker_path, &lock_path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&marker_path) + .unwrap() + .unwrap(); + retirement.invalidate_for_replacement().unwrap(); + std::fs::rename(&plugin, &backup).unwrap(); + let replacement = write_staged_generation_with_token(&marker_path, &lock_path).unwrap(); + + assert_eq!(retirement.active_visible_token().unwrap(), replacement); + retirement.commit_replacement(); +} + +#[cfg(windows)] +#[test] +fn windows_visible_lock_validation_uses_the_owning_locked_handle() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let lock_path = generation_lock_path(&path); + let file = open_generation_lock(&path).unwrap(); + lock_exclusive_with_timeout(&file, &path, Duration::from_secs(1)).unwrap(); + let identity = ensure_generation_lock_identity_locked(&file, &lock_path).unwrap(); + + assert!(visible_generation_lock_matches(&file, &lock_path, &identity).unwrap()); + + unlock_file(&file).unwrap(); +} + +#[cfg(windows)] +#[test] +fn windows_retirement_lock_survives_tree_rename_restore_and_removal() { + let dir = tempdir().unwrap(); + let plugin = dir.path().join("plugin"); + let backup = dir.path().join("plugin-backup"); + let path = plugin.join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("generation-transaction.lock"); + write_new_generation_with_token_at(&path, &lock_path).unwrap(); + let original = std::fs::read(&path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + + std::fs::rename(&plugin, &backup).unwrap(); + std::fs::rename(&backup, &plugin).unwrap(); + retirement.restore_after_rollback().unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), original); + + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + retirement.commit_replacement(); + std::fs::remove_dir_all(&plugin).unwrap(); + assert!(!plugin.exists()); +} + +#[test] +fn guarded_capture_rejects_a_rolled_back_marker_with_the_replacement_lock() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let old_marker = open_generation(&path).unwrap(); + + // Model capture opening marker A, then opening generation B's lock while B is promoted. The + // installer rolls the visible tree back to A before capture obtains the shared lock. Marker + // equality alone would adopt A while retaining B's unlinked lock. + let replacement_path = dir.path().join("replacement-generation"); + write_new_generation(&replacement_path).unwrap(); + let replacement_lock = open_generation_lock(&replacement_path).unwrap(); + + let observed = read_generation_marker(&old_marker, &path).unwrap(); + let error = InstallGeneration::capture_guarded_open_files_with_lock( + path.clone(), + old_marker, + replacement_lock, + observed, + ) + .err() + .expect("capture adopted the rolled-back marker through the replacement lock"); + + assert!(error.contains("has been retired"), "{error}"); + assert!(error.contains(&path.display().to_string()), "{error}"); +} + #[test] fn rollback_can_restore_with_the_original_lock_still_held() { let dir = tempdir().unwrap(); let path = dir.path().join(GENERATION_FILE_NAME); std::fs::write(&path, "retired:generation-a\n").unwrap(); - let lock = OpenOptions::new() - .read(true) - .write(true) - .open(&path) - .unwrap(); + let lock = open_generation_lock(&path).unwrap(); + lock_exclusive_with_timeout(&lock, &path, Duration::from_secs(1)).unwrap(); + let lock_id = + ensure_generation_lock_identity_locked(&lock, &generation_lock_path(&path)).unwrap(); let mut retirement = GenerationRetirement { lock: Some(lock), + lock_id, path: path.clone(), - original: GenerationMarker::Active("generation-a".into()), + original: GenerationMarker::active("generation-a", generation_lock_path(&path)), changed: true, + committed: false, + lock_released_for_tree_mutation: false, }; retirement.restore_after_rollback().unwrap(); - assert_eq!(std::fs::read_to_string(path).unwrap(), "generation-a\n"); + assert_eq!( + read_generation_marker_path(&path).unwrap(), + GenerationMarker::active("generation-a", generation_lock_path(&path)) + ); assert!(!retirement.changed); } +#[test] +fn rollback_restores_the_visible_path_after_atomic_marker_replacement() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let original_bytes = std::fs::read(&path).unwrap(); + let original = InstallGeneration::capture(path.clone()).unwrap(); + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + + atomic_write(&path, retirement.original.retired().encoded().as_bytes()).unwrap(); + + retirement.restore_after_rollback().unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), original_bytes); + + // A rollback must not replace the inode that old clients use for fencing. Otherwise a later + // retirement can lock the visible generation while an old client remains guarded through an + // unlinked inode (an ABA race). + let guard = original.guard_current().unwrap(); + let error = GenerationRetirement::acquire_with_timeout(&path, Duration::from_millis(20)) + .err() + .expect("retirement bypassed the pre-rollback generation guard"); + assert!(error.contains("timed out waiting"), "{error}"); + + drop(guard); + assert!( + GenerationRetirement::acquire_with_timeout(&path, Duration::from_secs(1)) + .unwrap() + .is_some() + ); +} + #[test] fn invalidation_requires_a_live_exclusive_lock() { let dir = tempdir().unwrap(); let path = dir.path().join(GENERATION_FILE_NAME); let mut retirement = GenerationRetirement { lock: None, + lock_id: uuid::Uuid::nil().to_string(), path: path.clone(), - original: GenerationMarker::Active("generation-a".into()), + original: GenerationMarker::active("generation-a", generation_lock_path(&path)), changed: false, + committed: false, + lock_released_for_tree_mutation: false, }; let error = retirement.invalidate_for_replacement().unwrap_err(); @@ -98,33 +570,99 @@ fn invalidation_requires_a_live_exclusive_lock() { } #[test] -fn rollback_reports_when_an_invalidated_marker_cannot_be_reopened() { +fn failed_invalidation_restores_a_marker_changed_by_partial_io() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let original = std::fs::read(&path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + + let error = retirement + .invalidate_with(|path, _retired| { + std::fs::write(path, "").unwrap(); + Err("injected generation write failure after truncation".into()) + }) + .unwrap_err(); + + assert!( + error.contains("injected generation write failure"), + "{error}" + ); + assert_eq!(std::fs::read(&path).unwrap(), original); + assert!(!retirement.changed); +} + +#[test] +fn an_already_retired_generation_keeps_its_replacement_transaction_lock() { + let dir = tempdir().unwrap(); + let marker_path = dir.path().join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("generation-transaction.lock"); + write_new_generation_with_token_at(&marker_path, &lock_path).unwrap(); + let mut first = GenerationRetirement::acquire(&marker_path) + .unwrap() + .unwrap(); + first.invalidate_for_replacement().unwrap(); + first.commit_replacement(); + drop(first); + + let mut retry = GenerationRetirement::acquire(&marker_path) + .unwrap() + .unwrap(); + retry.invalidate_for_replacement().unwrap(); + let error = GenerationRetirement::acquire_with_timeout(&marker_path, Duration::from_millis(20)) + .err() + .expect("already-retired retry released its transaction lock"); + assert!(error.contains("timed out waiting"), "{error}"); + + let replacement = write_staged_generation_with_token(&marker_path, &lock_path).unwrap(); + assert_eq!(retry.active_visible_token().unwrap(), replacement); +} + +#[test] +fn dropping_an_uncommitted_retirement_restores_the_original_marker() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let original = std::fs::read(&path).unwrap(); + + { + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + } + + assert_eq!(std::fs::read(path).unwrap(), original); +} + +#[test] +fn rollback_requires_the_original_transaction_lock() { let dir = tempdir().unwrap(); let path = dir.path().join("missing-generation"); let mut retirement = GenerationRetirement { lock: None, + lock_id: uuid::Uuid::nil().to_string(), path: path.clone(), - original: GenerationMarker::Active("generation-a".into()), + original: GenerationMarker::active("generation-a", generation_lock_path(&path)), changed: true, + committed: false, + lock_released_for_tree_mutation: false, }; let error = retirement.restore_after_rollback().unwrap_err(); - assert!(error.contains("failed to reopen"), "{error}"); + assert!(error.contains("has no transaction lock"), "{error}"); assert!(error.contains(&path.display().to_string()), "{error}"); } #[test] fn marker_replacement_preserves_the_operation_in_io_errors() { let dir = tempdir().unwrap(); - let path = dir.path().join(GENERATION_FILE_NAME); - std::fs::write(&path, "generation-a\n").unwrap(); - let mut read_only = File::open(&path).unwrap(); + let parent = dir.path().join("not-a-directory"); + std::fs::write(&parent, "file").unwrap(); + let path = parent.join(GENERATION_FILE_NAME); let error = replace_generation_marker( - &mut read_only, &path, - &GenerationMarker::Retired("generation-a".into()), + &GenerationMarker::active("generation-a", generation_lock_path(&path)).retired(), "invalidate", ) .unwrap_err(); @@ -164,7 +702,410 @@ fn generation_creation_reports_an_unwritable_target_shape() { let error = write_new_generation(dir.path()).unwrap_err(); - assert!(error.contains("failed to write"), "{error}"); + assert!(error.contains("failed to replace"), "{error}"); +} + +#[test] +fn generation_creation_provisions_a_stable_sibling_lock() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + + write_new_generation(&path).unwrap(); + + assert!(generation_lock_path(&path).is_file()); + uuid::Uuid::parse_str(&read_generation_lock_identity_path(&path).unwrap()).unwrap(); +} + +#[test] +fn an_empty_generation_lock_is_initialized_before_capture() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + std::fs::write(generation_lock_path(&path), "").unwrap(); + + InstallGeneration::capture(path.clone()).unwrap(); + + uuid::Uuid::parse_str(&read_generation_lock_identity_path(&path).unwrap()).unwrap(); +} + +#[test] +fn a_malformed_generation_lock_identity_is_rejected() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + std::fs::write(generation_lock_path(&path), "not-a-uuid\n").unwrap(); + + let capture_error = InstallGeneration::capture(path.clone()).unwrap_err(); + assert!( + capture_error.contains("invalid identity"), + "{capture_error}" + ); + + let retirement_error = GenerationRetirement::acquire(&path) + .err() + .expect("retirement accepted a malformed lock identity"); + assert!( + retirement_error.contains("invalid identity"), + "{retirement_error}" + ); +} + +#[test] +fn generation_marker_parser_rejects_every_malformed_record_shape() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + let absolute_lock = dir.path().join("generation.lock"); + let encoded_lock = encode_lock_path(&absolute_lock); + let cases = [ + (String::new(), "is empty"), + ( + format!("{}\n", "x".repeat(MAX_GENERATION_TOKEN_BYTES + 1)), + "token in", + ), + ( + format!("generation-a\nwrong-prefix:{encoded_lock}\n"), + "invalid lock-path record", + ), + ( + format!("generation-a\n{GENERATION_LOCK_PATH_PREFIX}{encoded_lock}\nunexpected\n"), + "unexpected trailing records", + ), + ( + format!("generation-a\n{GENERATION_LOCK_PATH_PREFIX}not-base64!\n"), + "invalid lock path", + ), + ( + format!("generation-a\n{GENERATION_LOCK_PATH_PREFIX}\n"), + "lock path is empty", + ), + ( + format!("{}\n", "x".repeat(MAX_GENERATION_MARKER_BYTES + 1)), + "byte limit", + ), + ]; + + for (contents, expected) in cases { + std::fs::write(&path, contents).unwrap(); + let error = read_generation_marker_path(&path).unwrap_err(); + assert!( + error.contains(expected), + "expected {expected:?} in {error:?}" + ); + } + let oversized = std::fs::read_to_string(&path).unwrap(); + assert_eq!(oversized.len(), MAX_GENERATION_MARKER_BYTES + 2); + assert!( + read_generation_marker_path(&path) + .unwrap_err() + .contains("byte limit") + ); +} + +#[test] +fn generation_lock_identity_reader_rejects_oversized_records() { + let dir = tempdir().unwrap(); + let marker = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&marker).unwrap(); + let lock = generation_lock_path(&marker); + std::fs::write(&lock, "x".repeat(MAX_GENERATION_LOCK_ID_BYTES + 1)).unwrap(); + + let capture_error = InstallGeneration::capture(marker.clone()).unwrap_err(); + assert!(capture_error.contains("byte limit"), "{capture_error}"); + let retirement_error = GenerationRetirement::acquire(&marker) + .err() + .expect("retirement accepted an oversized lock identity"); + assert!( + retirement_error.contains("byte limit"), + "{retirement_error}" + ); +} + +#[cfg(unix)] +#[test] +fn generation_environment_rejects_non_unicode_expected_identity() { + use std::os::unix::ffi::OsStringExt; + + struct Restore { + path: Option, + token: Option, + } + impl Drop for Restore { + fn drop(&mut self) { + // SAFETY: The test holds the process-wide environment lock. + unsafe { + match self.path.take() { + Some(value) => std::env::set_var(GENERATION_FILE_ENV, value), + None => std::env::remove_var(GENERATION_FILE_ENV), + } + match self.token.take() { + Some(value) => std::env::set_var(GENERATION_TOKEN_ENV, value), + None => std::env::remove_var(GENERATION_TOKEN_ENV), + } + } + } + } + + let _environment = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _restore = Restore { + path: std::env::var_os(GENERATION_FILE_ENV), + token: std::env::var_os(GENERATION_TOKEN_ENV), + }; + unsafe { + std::env::set_var(GENERATION_FILE_ENV, "/unused/generation"); + std::env::set_var( + GENERATION_TOKEN_ENV, + std::ffi::OsString::from_vec(vec![0xff]), + ); + } + + let error = InstallGeneration::capture_guarded_from_env() + .err() + .expect("non-Unicode expected identity was accepted"); + assert!(error.contains("not valid Unicode"), "{error}"); +} + +#[test] +fn retirement_state_machine_rejects_invalid_transitions_without_losing_its_lock() { + let dir = tempdir().unwrap(); + let legacy_marker = dir.path().join("legacy").join(GENERATION_FILE_NAME); + write_legacy_generation(&legacy_marker, "generation-a").unwrap(); + let mut legacy = GenerationRetirement::acquire(&legacy_marker) + .unwrap() + .unwrap(); + + let active_release_error = legacy.release_legacy_lock_for_tree_mutation().unwrap_err(); + assert!( + active_release_error.contains("cannot release active"), + "{active_release_error}" + ); + assert!( + !legacy + .uses_lock_path(&dir.path().join("missing.lock")) + .unwrap() + ); + let invalid_shape = dir.path().join("not-a-directory"); + std::fs::write(&invalid_shape, b"file").unwrap(); + let inspection_error = legacy + .uses_lock_path(&invalid_shape.join("nested").join("lock")) + .unwrap_err(); + assert!( + inspection_error.contains("failed to inspect"), + "{inspection_error}" + ); + + legacy.invalidate_for_replacement().unwrap(); + assert!( + legacy + .active_visible_token() + .unwrap_err() + .contains("retired") + ); + legacy.release_legacy_lock_for_tree_mutation().unwrap(); + legacy.release_legacy_lock_for_tree_mutation().unwrap(); + legacy.restore_after_rollback().unwrap(); + + let external_marker = dir.path().join("external").join(GENERATION_FILE_NAME); + let external_lock = dir.path().join("external-generation.lock"); + write_new_generation_with_token_at(&external_marker, &external_lock).unwrap(); + let mut external = GenerationRetirement::acquire(&external_marker) + .unwrap() + .unwrap(); + external.release_legacy_lock_for_tree_mutation().unwrap(); + let replacement_lock = dir.path().join("replacement.lock"); + write_new_generation_with_token_at(&external_marker, &replacement_lock).unwrap(); + assert!( + external + .active_visible_token() + .unwrap_err() + .contains("retired") + ); + let marker_error = external + .retarget_promoted_marker(&external_marker) + .unwrap_err(); + assert!(marker_error.contains("marker changed"), "{marker_error}"); + external.commit_replacement(); + let committed_error = external + .retarget_promoted_marker(&external_marker) + .unwrap_err(); + assert!( + committed_error.contains("cannot retarget mutated"), + "{committed_error}" + ); +} + +#[test] +fn captured_generation_rejects_visible_marker_rotation_on_every_verification_path() { + let dir = tempdir().unwrap(); + let marker = dir.path().join(GENERATION_FILE_NAME); + let lock = dir.path().join("generation.lock"); + write_new_generation_with_token_at(&marker, &lock).unwrap(); + let generation = InstallGeneration::capture(marker.clone()).unwrap(); + + write_staged_generation_with_token(&marker, &lock).unwrap(); + + assert!( + generation + .try_verify_current() + .unwrap_err() + .contains("retired") + ); + assert!( + generation + .guard_current() + .err() + .expect("rotated marker was guarded") + .contains("retired") + ); +} + +#[cfg(unix)] +#[test] +fn promoted_generation_rejects_replaced_external_lock_inode() { + let dir = tempdir().unwrap(); + let marker = dir.path().join(GENERATION_FILE_NAME); + let lock = dir.path().join("generation.lock"); + write_new_generation_with_token_at(&marker, &lock).unwrap(); + let mut retirement = GenerationRetirement::acquire(&marker).unwrap().unwrap(); + + std::fs::remove_file(&lock).unwrap(); + std::fs::write(&lock, format!("{}\n", uuid::Uuid::now_v7())).unwrap(); + + let error = retirement.retarget_promoted_marker(&marker).unwrap_err(); + assert!(error.contains("lock identity changed"), "{error}"); +} + +#[test] +fn retirement_reports_marker_inspection_errors_and_repeated_invalidation_is_idempotent() { + let dir = tempdir().unwrap(); + let invalid_parent = dir.path().join("not-a-directory"); + std::fs::write(&invalid_parent, b"file").unwrap(); + let inspection_error = + GenerationRetirement::acquire(&invalid_parent.join("nested").join(GENERATION_FILE_NAME)) + .err() + .expect("invalid marker parent was accepted"); + assert!( + inspection_error.contains("failed to inspect"), + "{inspection_error}" + ); + + let marker = dir.path().join("valid").join(GENERATION_FILE_NAME); + write_new_generation(&marker).unwrap(); + let mut retirement = GenerationRetirement::acquire(&marker).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + let retired = std::fs::read(&marker).unwrap(); + retirement.invalidate_for_replacement().unwrap(); + assert_eq!(std::fs::read(&marker).unwrap(), retired); + retirement.restore_after_rollback().unwrap(); +} + +#[test] +fn failed_invalidation_aggregates_a_failed_marker_restore() { + let dir = tempdir().unwrap(); + let plugin = dir.path().join("plugin"); + let marker = plugin.join(GENERATION_FILE_NAME); + let lock = dir.path().join("external.lock"); + write_new_generation_with_token_at(&marker, &lock).unwrap(); + let mut retirement = GenerationRetirement::acquire(&marker).unwrap().unwrap(); + + let error = retirement + .invalidate_with(|path, _retired| { + crate::file_io::fail_next_atomic_write(path); + Err("injected invalidation failure".into()) + }) + .unwrap_err(); + + assert!(error.contains("injected invalidation failure"), "{error}"); + assert!(error.contains("additionally"), "{error}"); + retirement.commit_replacement(); +} + +#[test] +fn generation_writers_report_each_invalid_parent_lock_and_identity_shape() { + let dir = tempdir().unwrap(); + let invalid_parent = dir.path().join("not-a-directory"); + std::fs::write(&invalid_parent, b"file").unwrap(); + let marker_under_file = invalid_parent.join(GENERATION_FILE_NAME); + let external_lock = dir.path().join("external.lock"); + + let legacy_error = write_legacy_generation(&marker_under_file, "generation-a").unwrap_err(); + assert!(legacy_error.contains("failed to create"), "{legacy_error}"); + let marker_parent_error = + write_new_generation_with_token_at(&marker_under_file, &external_lock).unwrap_err(); + assert!( + marker_parent_error.contains("failed to create"), + "{marker_parent_error}" + ); + let lock_parent_error = write_new_generation_with_token_at( + &dir.path().join("marker"), + &invalid_parent.join("lock"), + ) + .unwrap_err(); + assert!( + lock_parent_error.contains("failed to create"), + "{lock_parent_error}" + ); + let staged_error = + write_staged_generation_with_token(&marker_under_file, &external_lock).unwrap_err(); + assert!(staged_error.contains("failed to create"), "{staged_error}"); + + let malformed_lock = dir.path().join("malformed.lock"); + std::fs::write(&malformed_lock, b"not-a-uuid\n").unwrap(); + let malformed_error = + write_new_generation_with_token_at(&dir.path().join("malformed-marker"), &malformed_lock) + .unwrap_err(); + assert!( + malformed_error.contains("invalid identity"), + "{malformed_error}" + ); + + let directory_lock_error = + write_new_generation_with_token_at(&dir.path().join("directory-lock-marker"), dir.path()) + .unwrap_err(); + assert!( + directory_lock_error.contains("failed to open"), + "{directory_lock_error}" + ); +} + +#[test] +fn direct_generation_lock_identity_read_rejects_an_empty_legacy_lock() { + let dir = tempdir().unwrap(); + let marker = dir.path().join(GENERATION_FILE_NAME); + std::fs::write(&marker, b"generation-a\n").unwrap(); + std::fs::write(generation_lock_path(&marker), b"").unwrap(); + + let error = read_generation_lock_identity_path(&marker).unwrap_err(); + + assert!(error.contains("is empty"), "{error}"); +} + +#[cfg(windows)] +#[test] +fn windows_lock_path_decoder_rejects_odd_utf16_byte_length() { + let encoded = base64::engine::general_purpose::STANDARD.encode([0_u8]); + + let error = decode_lock_path(&encoded).unwrap_err(); + + assert!(error.contains("odd byte length"), "{error}"); +} + +#[test] +fn marker_rotation_preserves_the_lock_inode_identity() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let lock_id = read_generation_lock_identity_path(&path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + atomic_write(&path, b"generation-b\n").unwrap(); + retirement.commit_replacement(); + drop(retirement); + + InstallGeneration::capture(path.clone()).unwrap(); + + assert_eq!(read_generation_lock_identity_path(&path).unwrap(), lock_id); } #[cfg(unix)] diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs index 3878b1a97..c1d626546 100644 --- a/crates/cli/tests/coverage/installer_tests.rs +++ b/crates/cli/tests/coverage/installer_tests.rs @@ -3,6 +3,218 @@ use super::*; +struct BootstrapConfigHome { + _guard: std::sync::MutexGuard<'static, ()>, + previous: Option, +} + +impl BootstrapConfigHome { + fn enter(path: &std::path::Path) -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = std::env::var_os("XDG_CONFIG_HOME"); + // SAFETY: This scope holds the process-wide environment mutex. + unsafe { std::env::set_var("XDG_CONFIG_HOME", path) }; + Self { + _guard: guard, + previous, + } + } +} + +impl Drop for BootstrapConfigHome { + fn drop(&mut self) { + // SAFETY: This scope still holds the process-wide environment mutex. + unsafe { + match self.previous.take() { + Some(previous) => std::env::set_var("XDG_CONFIG_HOME", previous), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } + } +} + +#[tokio::test] +async fn transparent_hook_delivery_authenticates_the_wrapper_gateway() { + let _plugin_guard = crate::test_support::PLUGIN_CONFIG_TEST_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let _bootstrap_home = BootstrapConfigHome::enter(&temp.path().join("xdg")); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bind = listener.local_addr().unwrap(); + let gateway_url = format!("http://{bind}"); + let fingerprint = crate::config::transparent_gateway_fingerprint(&gateway_url); + let config = crate::config::GatewayConfig { + bind, + ..crate::config::GatewayConfig::default() + }; + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(crate::server::serve_transparent_listener_with_dynamic( + listener, + config, + Vec::new(), + fingerprint.clone(), + Some(shutdown_rx), + )); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let url = gateway_url.clone(); + let fingerprint = fingerprint.clone(); + if tokio::task::spawn_blocking(move || { + crate::sidecar::healthz_compatible(&url, &fingerprint) + }) + .await + .unwrap() + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("wrapper gateway did not become healthy"); + let command = HookForwardCommand { + agent: CodingAgent::Codex, + gateway_url: Some(gateway_url.clone()), + generation_file: None, + generation_token: None, + forward_only: false, + transparent_run: true, + profile: None, + session_metadata: None, + gateway_mode: None, + fail_closed: true, + }; + let gateway = transparent_gateway_spec(&gateway_url).unwrap(); + + let response = send_verified_hook_forward_request( + &command, + &gateway, + &gateway_url, + json!({ + "session_id": "verified-transparent-hook", + "hook_event_name": "SessionStart" + }) + .to_string(), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(response.status, 200); + let _ = shutdown_tx.send(()); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("wrapper gateway did not stop") + .unwrap() + .unwrap(); +} + +#[test] +fn hook_delivery_generation_guard_blocks_replacement_until_drop() { + let dir = tempfile::tempdir().unwrap(); + let generation_path = dir.path().join(".nemo-relay-generation"); + crate::install_generation::write_new_generation(&generation_path).unwrap(); + let generation = InstallGeneration::capture(generation_path.clone()).unwrap(); + let guard = hook_generation_guard(&generation).unwrap(); + + let error = crate::install_generation::GenerationRetirement::acquire_with_timeout( + &generation_path, + std::time::Duration::from_millis(25), + ) + .err() + .expect("hook delivery guard did not block generation retirement"); + assert!(error.contains("timed out waiting"), "{error}"); + + drop(guard); + assert!( + crate::install_generation::GenerationRetirement::acquire_with_timeout( + &generation_path, + std::time::Duration::from_secs(1), + ) + .unwrap() + .is_some() + ); +} + +#[test] +fn hook_gateway_retries_retirement_during_acquisition_and_delivery_pinning() { + let mut outcomes = std::collections::VecDeque::from([ + Err("shared Relay gateway lifecycle was retired by an integration update".to_string()), + Err("shared Relay gateway lifecycle was retired by an integration update".to_string()), + Ok("pinned"), + ]); + let mut attempts = 0; + + let result = retry_retired_gateway_cohort(|| { + attempts += 1; + outcomes.pop_front().unwrap() + }) + .unwrap(); + + assert_eq!(result, "pinned"); + assert_eq!(attempts, 3); +} + +#[test] +fn hook_gateway_does_not_retry_non_retirement_failures() { + let mut attempts = 0; + let error = retry_retired_gateway_cohort::<()>(|| { + attempts += 1; + Err("foreign listener".into()) + }) + .unwrap_err(); + + assert_eq!(error, "foreign listener"); + assert_eq!(attempts, 1); +} + +#[test] +fn hook_gateway_stops_retrying_retirement_at_its_deadline() { + let mut attempts = 0; + let error = retry_retired_gateway_cohort_until::<()>(Instant::now(), || { + attempts += 1; + Err("shared Relay gateway lifecycle was retired by an integration update".into()) + }) + .unwrap_err(); + + assert!(error.contains("retired by an integration update")); + assert_eq!(attempts, 1); +} + +#[test] +fn hook_gateway_retirement_retries_are_rate_limited() { + let mut attempts = 0; + let started = Instant::now(); + let current = std::cell::Cell::new(started); + let sleeps = std::cell::RefCell::new(Vec::new()); + let deadline = started + Duration::from_millis(125); + let error = retry_retired_gateway_cohort_with_clock::<()>( + deadline, + || current.get(), + |duration| { + sleeps.borrow_mut().push(duration); + current.set(current.get() + duration); + }, + || { + attempts += 1; + Err("shared Relay gateway lifecycle was retired by an integration update".into()) + }, + ) + .unwrap_err(); + + assert!(error.contains("retired by an integration update")); + assert_eq!(attempts, 4); + assert_eq!( + sleeps.into_inner(), + [ + Duration::from_millis(50), + Duration::from_millis(50), + Duration::from_millis(25), + ] + ); +} + #[test] fn hook_payload_reader_normalizes_blank_input_and_accepts_the_exact_limit() { assert_eq!(read_hook_payload_from(" \n\t".as_bytes(), 3).unwrap(), "{}"); @@ -43,13 +255,32 @@ fn explicit_persistent_destinations_ignore_ambient_urls() { let destination = resolve_hook_destination( Some("http://installed".into()), Some("http://dynamic".into()), + false, + false, ); assert_eq!(destination.gateway_url, "http://installed"); - assert!(destination.recover); + assert_eq!(destination.lifecycle, HookGatewayLifecycle::Recover); - let destination = resolve_hook_destination(None, Some("http://dynamic".into())); + let destination = resolve_hook_destination(None, Some("http://dynamic".into()), false, false); assert_eq!(destination.gateway_url, "http://dynamic"); - assert!(!destination.recover); + assert_eq!(destination.lifecycle, HookGatewayLifecycle::Transparent); + + let destination = resolve_hook_destination( + Some("http://source-plugin".into()), + Some("http://dynamic".into()), + true, + false, + ); + assert_eq!(destination.gateway_url, "http://source-plugin"); + assert_eq!(destination.lifecycle, HookGatewayLifecycle::Existing); + + let destination = resolve_hook_destination(None, Some("http://dynamic".into()), true, false); + assert_eq!(destination.gateway_url, crate::sidecar::DEFAULT_URL); + assert_eq!(destination.lifecycle, HookGatewayLifecycle::Existing); + + let destination = resolve_hook_destination(Some("http://embedded".into()), None, false, true); + assert_eq!(destination.gateway_url, "http://embedded"); + assert_eq!(destination.lifecycle, HookGatewayLifecycle::Transparent); } #[test] @@ -59,7 +290,7 @@ fn merge_hooks_is_idempotent_and_preserves_existing_entries() { "Stop": [{ "hooks": [{ "type": "command", "command": "existing" }] }] } }); - let generated = claude_hooks("nemo-relay hook-forward claude"); + let generated = generated_hooks(CodingAgent::ClaudeCode, "nemo-relay hook-forward claude"); let once = merge_hooks(existing, generated.clone()).unwrap(); let twice = merge_hooks(once.clone(), generated).unwrap(); assert_eq!(once, twice); @@ -75,9 +306,10 @@ fn merge_hooks_is_idempotent_and_preserves_existing_entries() { #[test] fn merge_hooks_rejects_malformed_shapes() { - assert!(merge_hooks(json!([]), codex_hooks("cmd")).is_err()); - assert!(merge_hooks(json!({ "hooks": [] }), codex_hooks("cmd")).is_err()); - assert!(merge_hooks(json!({ "hooks": { "Stop": {} } }), codex_hooks("cmd")).is_err()); + let generated = generated_hooks(CodingAgent::Codex, "cmd"); + assert!(merge_hooks(json!([]), generated.clone()).is_err()); + assert!(merge_hooks(json!({ "hooks": [] }), generated.clone()).is_err()); + assert!(merge_hooks(json!({ "hooks": { "Stop": {} } }), generated).is_err()); assert!(merge_hooks(json!({}), json!({ "hooks": [] })).is_err()); } @@ -121,22 +353,113 @@ fn generated_hook_dispatch_covers_all_agents() { assert!(generated_hooks(agent, "cmd")["hooks"].is_object()); } assert_eq!( - transparent_hook_forward_command(Path::new("nemo-relay"), CodingAgent::Hermes), - "nemo-relay hook-forward hermes" + transparent_hook_forward_command_for_platform( + Path::new("nemo-relay"), + CodingAgent::Hermes, + "http://127.0.0.1:1234", + false, + ), + "nemo-relay hook-forward hermes --gateway-url http://127.0.0.1:1234 --transparent-run" ); assert_eq!( - transparent_hook_forward_command(Path::new("/abs/path/to/nemo-relay"), CodingAgent::Codex), - "/abs/path/to/nemo-relay hook-forward codex" + transparent_hook_forward_command_for_platform( + Path::new("/abs/path/to/nemo-relay"), + CodingAgent::Codex, + "http://127.0.0.1:1234", + false, + ), + "/abs/path/to/nemo-relay hook-forward codex --gateway-url http://127.0.0.1:1234 --transparent-run" ); let relay = Path::new("/opt/NeMo Relay's & tools/nemo-relay"); assert_eq!( - transparent_hook_forward_command_for_platform(relay, CodingAgent::Codex, false), - r#"'/opt/NeMo Relay'\''s & tools/nemo-relay' hook-forward codex"# + transparent_hook_forward_command_for_platform( + relay, + CodingAgent::Codex, + "http://127.0.0.1:1234", + false + ), + r#"'/opt/NeMo Relay'\''s & tools/nemo-relay' hook-forward codex --gateway-url http://127.0.0.1:1234 --transparent-run"# + ); + let native = transparent_hook_forward_command( + Path::new("nemo-relay"), + CodingAgent::Hermes, + "http://127.0.0.1:1234", + ) + .unwrap(); + if cfg!(windows) { + assert_eq!( + decode_windows_hook_command(&native).unwrap(), + vec![ + String::from("nemo-relay"), + String::from("hook-forward"), + String::from("hermes"), + String::from("--gateway-url"), + String::from("http://127.0.0.1:1234"), + String::from("--transparent-run"), + ] + ); + } else { + assert_eq!( + native, + transparent_hook_forward_command_for_platform( + Path::new("nemo-relay"), + CodingAgent::Hermes, + "http://127.0.0.1:1234", + false, + ) + ); + } + let windows = transparent_hook_forward_command_for_platform( + relay, + CodingAgent::ClaudeCode, + "http://127.0.0.1:1234", + true, ); + let (launcher, encoded) = windows.rsplit_once(' ').unwrap(); assert_eq!( - transparent_hook_forward_command_for_platform(relay, CodingAgent::ClaudeCode, true), - r#""/opt/NeMo Relay's & tools/nemo-relay" hook-forward claude"# + launcher, + "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand" + ); + assert!( + !encoded.is_empty() + && encoded + .chars() + .all(|character| character.is_ascii_alphanumeric() + || matches!(character, '+' | '/' | '=')) ); + assert_eq!( + decode_windows_hook_command(&windows).unwrap(), + vec![ + relay.display().to_string(), + "hook-forward".into(), + "claude".into(), + "--gateway-url".into(), + "http://127.0.0.1:1234".into(), + "--transparent-run".into(), + ] + ); + assert!(decode_windows_hook_command("powershell.exe -EncodedCommand invalid").is_none()); + assert!( + decode_windows_hook_command( + "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand invalid payload" + ) + .is_none() + ); + let oversized = format!( + "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand {}", + "A".repeat(8_000) + ); + assert!(decode_windows_hook_command(&oversized).is_none()); + + let oversized_path = format!("C:/{}nemo-relay.exe", "long/".repeat(2_000)); + let error = encoded_windows_hook_command( + "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", + Path::new(&oversized_path), + &["hook-forward".into(), "codex".into()], + ) + .unwrap_err(); + assert!(error.contains("exceeds the 8000-character safety limit")); + assert!(error.contains("shorten the Relay or plugin installation path")); } #[test] @@ -214,14 +537,14 @@ fn packaged_plugin_hooks_use_expected_forwarding_commands() { assert_eq!( claude["hooks"]["SessionStart"][0]["hooks"][0]["command"], json!(format!( - "nemo-relay hook-forward claude --gateway-url {}", + "nemo-relay hook-forward claude --gateway-url {} --forward-only", crate::sidecar::DEFAULT_URL )) ); assert_eq!( codex["hooks"]["SessionStart"][0]["hooks"][0]["command"], json!(format!( - "nemo-relay hook-forward codex --gateway-url {}", + "nemo-relay hook-forward codex --gateway-url {} --forward-only", crate::sidecar::DEFAULT_URL )) ); @@ -230,7 +553,7 @@ fn packaged_plugin_hooks_use_expected_forwarding_commands() { generated_hooks( CodingAgent::ClaudeCode, &format!( - "nemo-relay hook-forward claude --gateway-url {}", + "nemo-relay hook-forward claude --gateway-url {} --forward-only", crate::sidecar::DEFAULT_URL ), )["hooks"] @@ -240,7 +563,7 @@ fn packaged_plugin_hooks_use_expected_forwarding_commands() { generated_hooks( CodingAgent::Codex, &format!( - "nemo-relay hook-forward codex --gateway-url {}", + "nemo-relay hook-forward codex --gateway-url {} --forward-only", crate::sidecar::DEFAULT_URL ), )["hooks"] @@ -294,7 +617,7 @@ fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { serde_json::from_str::(&std::fs::read_to_string(&codex_mcp_path).unwrap()).unwrap(); let server = &codex_mcp["nemo-relay"]; assert_eq!(server["command"], json!("nemo-relay")); - assert_eq!(server["args"], json!(["mcp", "--agent", "codex"])); + assert_eq!(server["args"], json!(["mcp"])); assert_eq!( server["env"], json!({"NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632"}) @@ -303,7 +626,11 @@ fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { assert_eq!(server["startup_timeout_sec"], json!(20)); assert_eq!( server["env_vars"], - json!(crate::mcp_environment::forwarded_names(Vec::new(), None)) + json!(crate::mcp_environment::forwarded_names_for_platform( + Vec::new(), + None, + false, + )) ); let claude_mcp_path = root.join("claude-code/.mcp.json"); @@ -311,7 +638,7 @@ fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { serde_json::from_str::(&std::fs::read_to_string(&claude_mcp_path).unwrap()).unwrap(); let claude_server = &claude_mcp["mcpServers"]["nemo-relay"]; assert_eq!(claude_server["command"], json!("nemo-relay")); - assert_eq!(claude_server["args"], json!(["mcp", "--agent", "claude"])); + assert_eq!(claude_server["args"], json!(["mcp"])); assert_eq!( claude_server["env"], json!({"NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632"}) diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index f45982bbb..cd7653d90 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -37,6 +37,14 @@ impl EnvScope { values: previous, } } + + fn without_managed_bootstrap() -> Self { + Self::set(&[ + (crate::sidecar::BOOTSTRAP_STATE_DIR_ENV, None), + ("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", None), + (crate::config::BOOTSTRAP_FINGERPRINT_ENV, None), + ]) + } } impl Drop for EnvScope { @@ -240,6 +248,7 @@ fn default_and_configured_command_helpers_cover_empty_and_all_agents() { #[test] fn prepares_codex_config_overrides() { + let _guard = current_dir_lock().lock().unwrap(); let resolved = ResolvedConfig { gateway: GatewayConfig::default(), agents: AgentConfigs::default(), @@ -254,6 +263,7 @@ fn prepares_codex_config_overrides() { ) .unwrap(); + assert!(!prepared.argv.iter().any(|arg| arg == "--profile")); assert!(prepared.argv.contains(&"features.hooks=true".into())); assert!( prepared @@ -285,6 +295,25 @@ fn prepares_codex_config_overrides() { .iter() .any(|arg| arg.contains("hooks.SessionStart")) ); + let trust = prepared + .argv + .iter() + .find(|arg| arg.starts_with("hooks.state={")) + .unwrap(); + let expected_hooks = generated_hooks(CodingAgent::Codex, "ignored")["hooks"] + .as_object() + .unwrap() + .len(); + assert_eq!( + trust.matches("trusted_hash=\"sha256:").count(), + expected_hooks + ); + assert_eq!(trust.matches("enabled=true").count(), expected_hooks); + assert!( + prepared + .env + .contains(&(crate::config::TRANSPARENT_RUN_ENV.into(), "1".into())) + ); let path = prepared .env .iter() @@ -306,6 +335,7 @@ fn prepares_codex_config_overrides() { { assert_eq!(entries.last(), Some(¤t_exe_dir)); } + prepared.restore().unwrap(); } #[test] @@ -335,6 +365,94 @@ fn prepares_codex_with_hooks_when_auth_missing() { assert!(prepared.argv.iter().any(|arg| arg == "features.hooks=true")); } +#[test] +fn codex_session_hook_trust_matches_codex_discovery_identity() { + let generated = generated_hooks(CodingAgent::Codex, "echo relay-probe"); + let group = generated["hooks"]["UserPromptSubmit"][0] + .as_object() + .unwrap(); + let handler = &group["hooks"].as_array().unwrap()[0]; + assert_eq!( + codex_command_hook_hash("user_prompt_submit", group, handler).unwrap(), + "sha256:83a9834ee494ffbd4acc85377c579d2c954f9797a9b8832924a326a6a44b0660" + ); + + let state = codex_session_hook_state_override(&generated).unwrap(); + assert_eq!( + state.matches("trusted_hash=\"sha256:").count(), + generated["hooks"].as_object().unwrap().len() + ); + assert!(state.contains("//config.toml:user_prompt_submit:0:0")); + assert!( + state.contains("sha256:83a9834ee494ffbd4acc85377c579d2c954f9797a9b8832924a326a6a44b0660") + ); + assert_eq!( + state.matches("enabled=true").count(), + generated["hooks"].as_object().unwrap().len() + ); + assert_eq!( + state.matches("enabled=false").count(), + generated["hooks"].as_object().unwrap().len() * 2 + ); + assert!( + state + .contains("nemo-relay-plugin@nemo-relay-local:hooks/hooks.json:user_prompt_submit:0:0") + ); + assert!(state.contains("nemo-relay-plugin@nemo-relay:hooks/hooks.json:user_prompt_submit:0:0")); +} + +#[test] +fn codex_preserves_profiles_and_prompt_arguments_without_temporary_config() { + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + let prepared = PreparedRun::new( + CodingAgent::Codex, + vec![ + "codex".into(), + "--profile".into(), + "root".into(), + "exec".into(), + "--profile=work".into(), + "ping".into(), + "--".into(), + "codex".into(), + ], + "http://127.0.0.1:1234", + &resolved, + false, + ) + .unwrap(); + + let separator = prepared.argv.iter().position(|arg| arg == "--").unwrap(); + assert_eq!(&prepared.argv[separator..], &["--", "codex"]); + assert!( + prepared.argv[..separator] + .windows(2) + .any(|pair| pair == ["--profile", "root"]) + ); + assert!( + prepared.argv[..separator] + .iter() + .any(|arg| arg == "--profile=work") + ); + assert_eq!( + prepared + .argv + .iter() + .filter(|arg| arg.as_str() == "codex") + .count(), + 2 + ); + assert!( + prepared.argv[1..separator] + .iter() + .any(|arg| arg == "features.hooks=true") + ); +} + #[test] fn exporter_destinations_describe_observability_outputs() { let gateway = GatewayConfig { @@ -483,28 +601,48 @@ fn exporter_destinations_cover_invalid_disabled_and_missing_plugin_configs() { } #[test] -fn insert_after_agent_uses_last_matching_agent_or_first_word_fallback() { +fn insert_after_host_uses_the_authoritative_executable_index() { let mut argv = vec![ "wrapper".to_string(), "codex".to_string(), - "subcommand".to_string(), - "/usr/local/bin/codex".to_string(), + "exec".to_string(), + "--".to_string(), + "codex".to_string(), ]; - insert_after_agent(&mut argv, CodingAgent::Codex, ["--config".to_string()]); + insert_after_host(&mut argv, 1, ["--config".to_string()]); assert_eq!( argv, - vec![ - "wrapper", - "codex", - "subcommand", - "/usr/local/bin/codex", - "--config" - ] + vec!["wrapper", "codex", "--config", "exec", "--", "codex"] ); +} - let mut wrapped = vec!["agent-wrapper".to_string(), "run".to_string()]; - insert_after_agent(&mut wrapped, CodingAgent::Hermes, ["--hook".to_string()]); - assert_eq!(wrapped, vec!["agent-wrapper", "--hook", "run"]); +#[test] +fn invocation_resolves_wrapper_host_before_appending_pass_through_arguments() { + let agents = AgentConfigs { + codex: AgentCommandConfig { + command: Some("wrapper -- codex".into()), + hooks_path: None, + }, + ..AgentConfigs::default() + }; + let command = RunCommand { + agent: Some(CodingAgent::Codex), + config: None, + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: false, + print: false, + command: vec!["exec".into(), "--".into(), "codex".into()], + }; + + let invocation = resolve_agent_invocation(&command, &agents).unwrap(); + assert_eq!(invocation.host_index, 2); + assert_eq!( + invocation.argv, + vec!["wrapper", "--", "codex", "exec", "--", "codex"] + ); } #[test] @@ -580,7 +718,7 @@ fn prepares_claude_dry_run_without_writing_plugin() { } #[test] -fn prepares_claude_dry_inserts_plugin_dir_after_last_agent_executable() { +fn prepares_claude_dry_inserts_plugin_dir_after_authoritative_agent_executable() { let resolved = ResolvedConfig { gateway: GatewayConfig::default(), agents: AgentConfigs::default(), @@ -664,7 +802,17 @@ fn prepares_hermes_hook_environment() { .find_map(|(name, value)| (name == "HERMES_HOME").then(|| PathBuf::from(value))) .expect("Hermes overlay path"); let hooks = std::fs::read_to_string(overlay.join("config.yaml")).unwrap(); - assert!(hooks.contains("hook-forward hermes")); + let hooks: serde_json::Value = serde_yaml::from_str(&hooks).unwrap(); + assert!(crate::hook_assertions::value_has_command_arguments( + &hooks, + &[ + "hook-forward", + "hermes", + "--gateway-url", + "http://127.0.0.1:1234", + "--transparent-run", + ], + )); assert!(overlay.join("state.db").exists()); assert_eq!( std::fs::read_to_string(overlay.join("cache/entry")).unwrap(), @@ -744,16 +892,32 @@ fn concurrent_hermes_runs_use_independent_overlays_without_mutating_user_config( let second_overlay = overlay(&second); assert_ne!(first_overlay, second_overlay); - assert!( - std::fs::read_to_string(first_overlay.join("config.yaml")) - .unwrap() - .contains("hook-forward hermes") - ); - assert!( - std::fs::read_to_string(second_overlay.join("config.yaml")) - .unwrap() - .contains("hook-forward hermes") - ); + let first_config: serde_json::Value = + serde_yaml::from_str(&std::fs::read_to_string(first_overlay.join("config.yaml")).unwrap()) + .unwrap(); + let second_config: serde_json::Value = + serde_yaml::from_str(&std::fs::read_to_string(second_overlay.join("config.yaml")).unwrap()) + .unwrap(); + assert!(crate::hook_assertions::value_has_command_arguments( + &first_config, + &[ + "hook-forward", + "hermes", + "--gateway-url", + "http://127.0.0.1:4001", + "--transparent-run", + ], + )); + assert!(crate::hook_assertions::value_has_command_arguments( + &second_config, + &[ + "hook-forward", + "hermes", + "--gateway-url", + "http://127.0.0.1:4002", + "--transparent-run", + ], + )); assert_eq!(std::fs::read_to_string(&config).unwrap(), original); first.restore().unwrap(); @@ -771,7 +935,13 @@ fn hermes_overlay_does_not_link_an_ancestor_entry_that_contains_it() { let overlay = source_home.path().join("overlay"); std::fs::create_dir(&overlay).unwrap(); - populate_hermes_overlay(&overlay, source_home.path(), &source_config).unwrap(); + populate_hermes_overlay( + &overlay, + source_home.path(), + &source_config, + "http://127.0.0.1:1234", + ) + .unwrap(); assert!(!overlay.join("overlay").exists()); assert!(overlay.join("config.yaml").exists()); @@ -889,8 +1059,17 @@ hooks: .find_map(|(name, value)| (name == "HERMES_HOME").then(|| PathBuf::from(value))) .unwrap(); let patched = std::fs::read_to_string(overlay.join("config.yaml")).unwrap(); - assert!(patched.contains("hook-forward hermes")); let patched_yaml: serde_json::Value = serde_yaml::from_str(&patched).unwrap(); + assert!(crate::hook_assertions::value_has_command_arguments( + &patched_yaml, + &[ + "hook-forward", + "hermes", + "--gateway-url", + "http://s", + "--transparent-run", + ], + )); assert!(patched_yaml["mcp_servers"].get("nemo-relay").is_none()); assert_eq!( patched_yaml["mcp_servers"]["filesystem"]["command"], @@ -924,12 +1103,161 @@ fn prepares_claude_temp_plugin() { .unwrap(); let plugin_dir = PathBuf::from(&prepared.argv[plugin_index + 1]); assert!(plugin_dir.join("hooks/hooks.json").exists()); + assert_eq!(prepared.argv[plugin_index + 2], "--settings"); + let settings_path = PathBuf::from(&prepared.argv[plugin_index + 3]); + let settings: serde_json::Value = + serde_json::from_slice(&std::fs::read(&settings_path).unwrap()).unwrap(); + assert_eq!( + settings["env"]["ANTHROPIC_BASE_URL"], + "http://127.0.0.1:1234" + ); + let hooks: serde_json::Value = + serde_json::from_slice(&std::fs::read(plugin_dir.join("hooks/hooks.json")).unwrap()) + .unwrap(); + assert!(crate::hook_assertions::value_has_command_arguments( + &hooks, + &[ + "hook-forward", + "claude", + "--gateway-url", + "http://127.0.0.1:1234", + "--transparent-run", + ], + )); assert!( prepared .env .contains(&("ANTHROPIC_BASE_URL".into(), "http://127.0.0.1:1234".into())) ); prepared.restore().unwrap(); + assert!(!plugin_dir.exists()); +} + +#[test] +fn claude_transparent_run_preserves_user_settings_and_prompt_boundary() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("claude-settings.json"); + let original = br#"{"model":"claude-user-setting-sentinel","enabledPlugins":{"other@market":true,"nemo-relay-plugin@nemo-relay-local":true},"env":{"PRIVATE":"kept"}}"#; + std::fs::write(&source, original).unwrap(); + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + let prepared = PreparedRun::new( + CodingAgent::ClaudeCode, + vec![ + "claude".into(), + "--settings".into(), + source.display().to_string(), + "--settings={\"model\":\"ignored-second-source\"}".into(), + "--print".into(), + "ping".into(), + "--".into(), + "--settings".into(), + "literal-prompt-value".into(), + ], + "http://127.0.0.1:1234", + &resolved, + false, + ) + .unwrap(); + + assert_eq!(prepared.argv[1], "--plugin-dir"); + assert_eq!(prepared.argv[3], "--settings"); + let overlay: serde_json::Value = + serde_json::from_slice(&std::fs::read(&prepared.argv[4]).unwrap()).unwrap(); + assert_eq!(overlay["model"], "claude-user-setting-sentinel"); + assert_eq!(overlay["enabledPlugins"]["other@market"], true); + assert_eq!( + overlay["enabledPlugins"]["nemo-relay-plugin@nemo-relay-local"], + true + ); + assert_eq!(overlay["env"]["PRIVATE"], "kept"); + assert_eq!( + overlay["env"]["ANTHROPIC_BASE_URL"], + "http://127.0.0.1:1234" + ); + assert_ne!(overlay["model"], "ignored-second-source"); + assert!( + prepared + .argv + .windows(2) + .any(|pair| { pair == ["--settings", source.to_string_lossy().as_ref()] }) + ); + assert!( + prepared + .argv + .iter() + .any(|arg| arg.contains("ignored-second-source")) + ); + let separator = prepared.argv.iter().position(|arg| arg == "--").unwrap(); + assert_eq!( + &prepared.argv[separator..], + &["--", "--settings", "literal-prompt-value"] + ); + assert_eq!(std::fs::read(&source).unwrap(), original); + prepared.restore().unwrap(); +} + +#[test] +fn claude_settings_overlay_handles_inline_json_and_rejects_malformed_sources() { + let inline = vec![ + "claude".into(), + "--settings={\"model\":\"kept\",\"env\":{\"PRIVATE\":\"yes\"}}".into(), + ]; + let overlay = claude_settings_overlay(&inline, 0, "http://127.0.0.1:4321").unwrap(); + assert_eq!(overlay["model"], "kept"); + assert_eq!(overlay["env"]["PRIVATE"], "yes"); + assert_eq!( + overlay["env"]["ANTHROPIC_BASE_URL"], + "http://127.0.0.1:4321" + ); + + let after_separator = vec![ + "claude".into(), + "--".into(), + "--settings".into(), + "prompt-value".into(), + ]; + let overlay = claude_settings_overlay(&after_separator, 0, "http://127.0.0.1:4321").unwrap(); + assert_eq!(overlay.as_object().unwrap().len(), 1); + + let missing = vec!["claude".into(), "--settings".into(), "--".into()]; + assert!( + claude_settings_overlay(&missing, 0, "http://127.0.0.1:4321") + .unwrap_err() + .to_string() + .contains("missing its value") + ); + + let malformed_env = vec!["claude".into(), "--settings={\"env\":true}".into()]; + assert!( + claude_settings_overlay(&malformed_env, 0, "http://127.0.0.1:4321") + .unwrap_err() + .to_string() + .contains("field `env` must be a JSON object") + ); +} + +#[test] +fn claude_prompt_named_like_the_host_does_not_capture_relay_flags() { + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + let prepared = PreparedRun::new( + CodingAgent::ClaudeCode, + vec!["claude".into(), "--".into(), "claude".into()], + "http://127.0.0.1:1234", + &resolved, + true, + ) + .unwrap(); + let separator = prepared.argv.iter().position(|arg| arg == "--").unwrap(); + assert_eq!(&prepared.argv[separator..], &["--", "claude"]); + assert_eq!(prepared.argv[1], "--plugin-dir"); } #[test] @@ -1110,16 +1438,90 @@ entrypoint = "acme.worker:create_plugin" #[tokio::test] async fn wait_for_health_reports_unready_gateway() { - let error = wait_for_health("http://127.0.0.1:1") + let error = wait_for_health("http://127.0.0.1:1", "test-fingerprint") .await .unwrap_err() .to_string(); - assert!(error.contains("gateway did not become ready")); + assert!(error.contains("gateway did not become ready"), "{error}"); +} + +#[cfg(unix)] +#[tokio::test] +async fn gateway_failure_terminates_the_agent_and_restores_private_state() { + let temp = tempfile::tempdir().unwrap(); + let wrapper_pid_path = temp.path().join("wrapper.pid"); + let descendant_pid_path = temp.path().join("descendant.pid"); + let script = temp.path().join("test-agent"); + std::fs::write( + &script, + "#!/bin/sh\necho $$ > \"$1\"\nsh -c 'echo $$ > \"$1\"; while :; do :; done' descendant \"$2\" &\nwait \"$!\"\n", + ) + .unwrap(); + make_executable(&script); + let overlay = temp.path().join("private-overlay"); + std::fs::create_dir_all(&overlay).unwrap(); + let prepared = PreparedRun { + argv: vec![ + script.display().to_string(), + wrapper_pid_path.display().to_string(), + descendant_pid_path.display().to_string(), + ], + host_index: 0, + env: Vec::new(), + temp_dirs: vec![overlay.clone()], + notes: Vec::new(), + }; + let observed_wrapper_pid_path = wrapper_pid_path.clone(); + let observed_descendant_pid_path = descendant_pid_path.clone(); + let task = tokio::spawn(async move { + for _ in 0..500 { + if observed_wrapper_pid_path.exists() && observed_descendant_pid_path.exists() { + return Err(CliError::Launch("injected gateway failure".into())); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(CliError::Launch( + "test agent did not publish its process ID".into(), + )) + }); + let (shutdown_tx, _shutdown_rx) = oneshot::channel(); + let running_server = RunningGateway { shutdown_tx, task }; + + let error = tokio::time::timeout( + Duration::from_secs(10), + supervise_prepared_run(&prepared, running_server), + ) + .await + .expect("agent supervision did not finish") + .unwrap_err() + .to_string(); + + assert!(error.contains("injected gateway failure"), "{error}"); + assert!(!overlay.exists()); + for pid_path in [wrapper_pid_path, descendant_pid_path] { + let pid = std::fs::read_to_string(pid_path).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + // SAFETY: Signal 0 performs an existence check and does not alter the target process. + let result = unsafe { libc::kill(pid.trim().parse().unwrap(), 0) }; + if result == -1 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { + break; + } + assert!( + std::time::Instant::now() < deadline, + "agent process {pid} was not reaped" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } } #[tokio::test] async fn execute_live_run_reports_gateway_startup_error_when_health_check_fails() { + let _guard = crate::test_support::PLUGIN_CONFIG_TEST_LOCK.lock().await; + let _env = EnvScope::without_managed_bootstrap(); + let _ = nemo_relay::plugin::clear_plugin_configuration(); let resolved = ResolvedConfig { gateway: GatewayConfig::default(), agents: AgentConfigs::default(), @@ -1164,6 +1566,9 @@ async fn execute_live_run_reports_gateway_startup_error_when_health_check_fails( #[tokio::test] async fn execute_live_run_removes_hermes_overlay_when_health_check_fails() { + let _guard = crate::test_support::PLUGIN_CONFIG_TEST_LOCK.lock().await; + let _env = EnvScope::without_managed_bootstrap(); + let _ = nemo_relay::plugin::clear_plugin_configuration(); let temp = tempfile::tempdir().unwrap(); let hooks_path = temp.path().join("hermes-home/config.yaml"); std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); @@ -1193,11 +1598,19 @@ async fn execute_live_run_removes_hermes_overlay_when_health_check_fails() { .iter() .find_map(|(name, value)| (name == "HERMES_HOME").then(|| PathBuf::from(value))) .unwrap(); - assert!( - std::fs::read_to_string(overlay.join("config.yaml")) - .unwrap() - .contains("hook-forward hermes") - ); + let overlay_config: serde_json::Value = + serde_yaml::from_str(&std::fs::read_to_string(overlay.join("config.yaml")).unwrap()) + .unwrap(); + assert!(crate::hook_assertions::value_has_command_arguments( + &overlay_config, + &[ + "hook-forward", + "hermes", + "--gateway-url", + "http://127.0.0.1:1234", + "--transparent-run", + ], + )); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let error = execute_live_run( @@ -1210,7 +1623,7 @@ async fn execute_live_run_removes_hermes_overlay_when_health_check_fails() { .unwrap_err() .to_string(); - assert!(error.contains("gateway did not become ready")); + assert!(error.contains("gateway did not become ready"), "{error}"); assert_eq!(std::fs::read_to_string(&hooks_path).unwrap(), original); assert!(!overlay.exists()); } diff --git a/crates/cli/tests/coverage/main_tests.rs b/crates/cli/tests/coverage/main_tests.rs index 300308451..4d163d0fc 100644 --- a/crates/cli/tests/coverage/main_tests.rs +++ b/crates/cli/tests/coverage/main_tests.rs @@ -86,27 +86,14 @@ fn completions_helper_reports_missing_shell_and_generates_requested_shell() { #[test] fn cli_parses_native_mcp_subcommand_and_bind_override() { let cli = Cli::try_parse_from(["nemo-relay", "mcp"]).unwrap(); - assert!(matches!( - cli.command, - Some(Command::Mcp(command)) if command.agent == CodingAgent::Codex - )); + assert!(matches!(cli.command, Some(Command::Mcp))); assert!(cli.server.bind.is_none()); let cli = Cli::try_parse_from(["nemo-relay", "--bind", "127.0.0.1:4041", "mcp"]).unwrap(); - assert!(matches!(cli.command, Some(Command::Mcp(_)))); + assert!(matches!(cli.command, Some(Command::Mcp))); assert_eq!(cli.server.bind.unwrap().to_string(), "127.0.0.1:4041"); - let cli = Cli::try_parse_from(["nemo-relay", "mcp", "--agent", "claude"]).unwrap(); - assert!(matches!( - cli.command, - Some(Command::Mcp(command)) if command.agent == CodingAgent::ClaudeCode - )); - - let cli = Cli::try_parse_from(["nemo-relay", "mcp", "--agent", "hermes"]).unwrap(); - assert!(matches!( - cli.command, - Some(Command::Mcp(command)) if command.agent == CodingAgent::Hermes - )); + assert!(Cli::try_parse_from(["nemo-relay", "mcp", "--agent", "codex"]).is_err()); } #[test] diff --git a/crates/cli/tests/coverage/mcp_gateway_tests.rs b/crates/cli/tests/coverage/mcp_gateway_tests.rs index ce5e93066..5d0074858 100644 --- a/crates/cli/tests/coverage/mcp_gateway_tests.rs +++ b/crates/cli/tests/coverage/mcp_gateway_tests.rs @@ -54,6 +54,240 @@ async fn production_heartbeat_recovers_after_one_thirty_second_interval() { monitor.abort(); } +#[tokio::test(start_paused = true)] +async fn lifecycle_retirement_is_checked_before_a_healthy_heartbeat() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let health_calls = Arc::new(AtomicUsize::new(0)); + let health_calls_for_probe = health_calls.clone(); + let monitor = tokio::spawn(maintain_gateway_instances_with_generation( + "127.0.0.1:47632".parse().unwrap(), + crate::sidecar::GatewayEndpoint { + address: "127.0.0.1:47632".parse().unwrap(), + url: "http://gateway".into(), + instance_id: "first".into(), + }, + Duration::from_secs(30), + move |_url, _expected| { + health_calls_for_probe.fetch_add(1, Ordering::SeqCst); + async { Ok(Some("replacement".into())) } + }, + |_address, _expected| async { panic!("retired lifecycle attempted recovery") }, + || async { Err(CliError::Launch("cohort retired".into())) }, + )); + + tokio::time::advance(Duration::from_secs(30)).await; + let error = monitor.await.unwrap().unwrap_err(); + + assert!(error.to_string().contains("cohort retired")); + assert_eq!(health_calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn lifecycle_retirement_during_health_is_checked_before_adoption() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + let retired = Arc::new(AtomicBool::new(false)); + let health_calls = Arc::new(AtomicUsize::new(0)); + let retired_during_health = retired.clone(); + let health_calls_for_probe = health_calls.clone(); + let retired_for_verification = retired.clone(); + let monitor = tokio::spawn(maintain_gateway_instances_with_generation( + "127.0.0.1:47632".parse().unwrap(), + crate::sidecar::GatewayEndpoint { + address: "127.0.0.1:47632".parse().unwrap(), + url: "http://gateway".into(), + instance_id: "first".into(), + }, + Duration::from_millis(1), + move |_url, _expected| { + health_calls_for_probe.fetch_add(1, Ordering::SeqCst); + retired_during_health.store(true, Ordering::SeqCst); + async { Ok(Some("replacement".into())) } + }, + |_address, _expected| async { panic!("retired lifecycle attempted recovery") }, + move || { + let retired = retired_for_verification.load(Ordering::SeqCst); + async move { + if retired { + Err(CliError::Launch("cohort retired during health".into())) + } else { + Ok(()) + } + } + }, + )); + + let error = tokio::time::timeout(Duration::from_secs(1), monitor) + .await + .unwrap() + .unwrap() + .unwrap_err(); + + assert!(error.to_string().contains("retired during health")); + assert_eq!(health_calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn production_lifecycle_verifier_rejects_a_rotated_cohort() { + use std::ffi::OsString; + + struct EnvRestore { + previous: Option, + } + impl Drop for EnvRestore { + fn drop(&mut self) { + // SAFETY: This test holds the repository-wide environment mutex. + unsafe { + match self.previous.take() { + Some(value) => { + std::env::set_var(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV, value) + } + None => std::env::remove_var(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV), + } + } + } + } + + let _environment = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let state = tempfile::tempdir().unwrap(); + let previous = std::env::var_os(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV); + // SAFETY: This test holds the repository-wide environment mutex. + unsafe { std::env::set_var(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV, state.path()) }; + let _restore = EnvRestore { previous }; + let url = "http://127.0.0.1:47632"; + let old = crate::sidecar::EndpointLease::acquire(state.path(), url).unwrap(); + let cohort = old.cohort_id().to_string(); + crate::sidecar::stop_owned_sidecar_and_reset(url).unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let error = runtime + .block_on(verify_lifecycle_async(None, cohort, url.into())) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("retired by an integration update"), + "{error}" + ); +} + +#[test] +fn generation_transaction_polling_is_cancellable_for_clean_mcp_shutdown() { + struct EnvRestore(Option); + impl Drop for EnvRestore { + fn drop(&mut self) { + // SAFETY: The test holds the repository-wide environment mutex. + unsafe { + match self.0.take() { + Some(value) => { + std::env::set_var(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV, value) + } + None => std::env::remove_var(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV), + } + } + } + } + + let _environment = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let dir = tempfile::tempdir().unwrap(); + let state = dir.path().join("state"); + let previous = std::env::var_os(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV); + // SAFETY: This test holds the repository-wide environment mutex. + unsafe { std::env::set_var(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV, &state) }; + let _restore = EnvRestore(previous); + let gateway_url = "http://127.0.0.1:47632"; + let endpoint_lease = crate::sidecar::EndpointLease::acquire(&state, gateway_url).unwrap(); + let cohort = endpoint_lease.cohort_id().to_string(); + let path = dir + .path() + .join(crate::install_generation::GENERATION_FILE_NAME); + crate::install_generation::write_new_generation(&path).unwrap(); + let generation = crate::install_generation::InstallGeneration::capture(path.clone()).unwrap(); + let mut retirement = crate::install_generation::GenerationRetirement::acquire(&path) + .unwrap() + .unwrap(); + retirement.invalidate_for_replacement().unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let verification = tokio::spawn(verify_lifecycle_async( + Some(generation), + cohort, + gateway_url.into(), + )); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(!verification.is_finished()); + verification.abort(); + let error = tokio::time::timeout(Duration::from_millis(250), verification) + .await + .expect("generation lifecycle poll ignored MCP cancellation") + .unwrap_err(); + assert!(error.is_cancelled()); + }); + retirement.restore_after_rollback().unwrap(); +} + +#[test] +fn endpoint_transaction_polling_is_cancellable_for_clean_mcp_shutdown() { + struct EnvRestore(Option); + impl Drop for EnvRestore { + fn drop(&mut self) { + // SAFETY: The test holds the repository-wide environment mutex. + unsafe { + match self.0.take() { + Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } + } + } + + let _environment = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let dir = tempfile::tempdir().unwrap(); + let previous = std::env::var_os("XDG_CONFIG_HOME"); + // SAFETY: The test holds the repository-wide environment mutex. + unsafe { std::env::set_var("XDG_CONFIG_HOME", dir.path()) }; + let _restore = EnvRestore(previous); + let state = crate::sidecar::sidecar_state_dir().unwrap(); + let url = "http://127.0.0.1:47632"; + let lease = crate::sidecar::EndpointLease::acquire(&state, url).unwrap(); + let cohort = lease.cohort_id().to_string(); + let transaction = crate::sidecar::lock_sidecar_endpoint(&state, url).unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let verification = tokio::spawn(verify_lifecycle_async(None, cohort, url.into())); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(!verification.is_finished()); + verification.abort(); + let error = tokio::time::timeout(Duration::from_millis(250), verification) + .await + .expect("endpoint lifecycle poll ignored MCP cancellation") + .unwrap_err(); + assert!(error.is_cancelled()); + }); + drop(transaction); +} + #[tokio::test] async fn concurrent_clients_consume_the_same_replacement_allowance() { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -149,10 +383,7 @@ async fn dropping_gateway_lease_aborts_its_monitor() { }); started_rx.await.unwrap(); - drop(GatewayLease { - monitor, - _endpoint_lease: None, - }); + drop(GatewayLease { monitor }); tokio::time::timeout(Duration::from_secs(1), dropped_rx) .await diff --git a/crates/cli/tests/coverage/mcp_tests.rs b/crates/cli/tests/coverage/mcp_tests.rs index ed04ff994..24e663e23 100644 --- a/crates/cli/tests/coverage/mcp_tests.rs +++ b/crates/cli/tests/coverage/mcp_tests.rs @@ -184,15 +184,7 @@ async fn mcp_session_serves_stdio_and_stops_heartbeat_on_eof() { let (client, server_io) = tokio::io::duplex(4096); let (client_reader, mut client_writer) = tokio::io::split(client); let (server_reader, server_writer) = tokio::io::split(server_io); - let task = tokio::spawn(run_session( - "127.0.0.1:9".parse().unwrap(), - "http://127.0.0.1:9".into(), - Vec::new(), - "test-fingerprint".into(), - Duration::from_secs(60), - BufReader::new(server_reader), - server_writer, - )); + let task = tokio::spawn(run_session(BufReader::new(server_reader), server_writer)); client_writer .write_all( @@ -322,6 +314,64 @@ async fn heartbeat_keeps_a_compatible_gateway_session_alive() { .unwrap(); } +#[tokio::test] +async fn borrowed_transparent_gateway_is_authenticated_and_monitored() { + let _plugin_guard = crate::test_support::PLUGIN_CONFIG_TEST_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let _bootstrap_home = BootstrapConfigHome::enter(&temp.path().join("xdg")); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bind = listener.local_addr().unwrap(); + let url = format!("http://{bind}"); + let fingerprint = crate::config::transparent_gateway_fingerprint(&url); + let config = crate::config::GatewayConfig { + bind, + ..crate::config::GatewayConfig::default() + }; + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let gateway = tokio::spawn(crate::server::serve_transparent_listener_with_dynamic( + listener, + config, + Vec::new(), + fingerprint.clone(), + Some(shutdown_rx), + )); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let probe_url = url.clone(); + let probe_fingerprint = fingerprint.clone(); + if tokio::task::spawn_blocking(move || { + crate::sidecar::healthz_compatible(&probe_url, &probe_fingerprint) + }) + .await + .unwrap() + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("transparent gateway did not become healthy"); + + let mut lease = + gateway::GatewayLease::borrow_with_interval(url, fingerprint, Duration::from_millis(10)) + .await + .expect("authenticated gateway should be borrowable"); + let _ = shutdown_tx.send(()); + tokio::time::timeout(Duration::from_secs(5), gateway) + .await + .expect("transparent gateway did not stop") + .unwrap() + .unwrap(); + + let error = tokio::time::timeout(Duration::from_secs(5), lease.wait()) + .await + .expect("borrowed gateway heartbeat did not detect shutdown") + .unwrap_err() + .to_string(); + assert!(error.contains("no longer available"), "{error}"); +} + #[tokio::test] async fn heartbeat_performs_one_restart_and_tracks_the_recovered_gateway() { let bind = "127.0.0.1:47632".parse().unwrap(); @@ -508,8 +558,14 @@ async fn heartbeat_attempts_at_most_one_successful_restart() { #[tokio::test] async fn old_mcp_maintenance_loop_exits_when_install_generation_is_replaced() { let dir = tempfile::tempdir().unwrap(); - let generation_path = dir.path().join(GENERATION_FILE_NAME); - write_new_generation(&generation_path).unwrap(); + let plugin_root = dir.path().join("plugin"); + let generation_path = plugin_root.join(GENERATION_FILE_NAME); + let generation_lock = dir.path().join("generation-transaction.lock"); + crate::install_generation::write_new_generation_with_token_at( + &generation_path, + &generation_lock, + ) + .unwrap(); let generation = InstallGeneration::capture(generation_path.clone()).unwrap(); let health_calls = Arc::new(AtomicUsize::new(0)); let restart_calls = Arc::new(AtomicUsize::new(0)); @@ -559,8 +615,15 @@ async fn old_mcp_maintenance_loop_exits_when_install_generation_is_replaced() { .unwrap() .expect("installed generation should be retired"); retirement.invalidate_for_replacement().unwrap(); - std::fs::rename(&generation_path, dir.path().join("retired-generation")).unwrap(); - write_new_generation(&generation_path).unwrap(); + // Force installation swaps the whole plugin tree while retaining its external transaction + // lock until the replacement is committed. + std::fs::rename(&plugin_root, dir.path().join("retired-plugin")).unwrap(); + crate::install_generation::write_staged_generation_with_token( + &generation_path, + &generation_lock, + ) + .unwrap(); + retirement.commit_replacement(); drop(retirement); let error = tokio::time::timeout(Duration::from_secs(5), heartbeat) @@ -584,11 +647,19 @@ fn invalidated_install_generation_can_be_restored_before_rollback_registration() .expect("installed generation should be retired"); retirement.invalidate_for_replacement().unwrap(); - let error = InstallGeneration::capture(generation_path.clone()).unwrap_err(); - assert!(error.contains("has been retired"), "{error}"); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + let verifier = std::thread::spawn(move || result_tx.send(original.verify_current()).unwrap()); + assert!( + result_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "MCP lifecycle verification observed an uncommitted retirement" + ); retirement.restore_after_rollback().unwrap(); - original.verify_current().unwrap(); + result_rx + .recv_timeout(Duration::from_secs(1)) + .unwrap() + .unwrap(); + verifier.join().unwrap(); InstallGeneration::capture(generation_path).unwrap(); } @@ -601,12 +672,14 @@ fn retired_install_generation_remains_retryable_but_not_adoptable() { .unwrap() .expect("installed generation should be retired"); retirement.invalidate_for_replacement().unwrap(); + retirement.commit_replacement(); drop(retirement); let mut resumed = GenerationRetirement::acquire(&generation_path) .unwrap() .expect("a retired generation should support cleanup retry"); resumed.invalidate_for_replacement().unwrap(); + resumed.commit_replacement(); drop(resumed); let error = InstallGeneration::capture(generation_path).unwrap_err(); @@ -617,3 +690,53 @@ fn retired_install_generation_remains_retryable_but_not_adoptable() { fn default_mcp_gateway_uses_plugin_provider_port() { assert_eq!(default_mcp_bind().to_string(), "127.0.0.1:47632"); } + +#[test] +fn persistent_mcp_server_contract_is_host_neutral_and_generation_fenced() { + let server = persistent_server( + std::path::Path::new("/opt/nemo relay/bin/nemo-relay"), + std::path::Path::new("/tmp/plugin/.nemo-relay-generation"), + "generation-token", + ); + + assert_eq!(server["command"], "/opt/nemo relay/bin/nemo-relay"); + assert_eq!(server["args"], json!(["mcp"])); + assert_eq!( + server["env"]["NEMO_RELAY_GATEWAY_BIND"], + crate::sidecar::DEFAULT_BIND + ); + assert_eq!( + server["env"]["NEMO_RELAY_MCP_GENERATION_FILE"], + "/tmp/plugin/.nemo-relay-generation" + ); + assert_eq!( + server["env"]["NEMO_RELAY_MCP_GENERATION"], + "generation-token" + ); +} + +#[test] +fn managed_mcp_server_recognizes_one_current_contract_and_legacy_migrations() { + let relay = |command: &str| command.ends_with("nemo-relay"); + assert!(is_managed_server( + &json!({"command": "/bin/nemo-relay", "args": ["mcp"]}), + relay + )); + for agent in ["claude", "codex", "hermes"] { + assert!(is_managed_server( + &json!({ + "command": "/bin/nemo-relay", + "args": ["mcp", "--agent", agent] + }), + relay + )); + } + for foreign in [ + json!({"command": "/bin/other", "args": ["mcp"]}), + json!({"command": "/bin/nemo-relay", "args": ["mcp", "--agent", "unknown"]}), + json!({"command": "/bin/nemo-relay", "args": ["mcp", "--extra"]}), + json!({"command": "/bin/nemo-relay"}), + ] { + assert!(!is_managed_server(&foreign, relay), "{foreign}"); + } +} diff --git a/crates/cli/tests/coverage/plugin_host_tests.rs b/crates/cli/tests/coverage/plugin_host_tests.rs index 38bca6ccd..33338b671 100644 --- a/crates/cli/tests/coverage/plugin_host_tests.rs +++ b/crates/cli/tests/coverage/plugin_host_tests.rs @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use std::collections::{BTreeSet, VecDeque}; +#[cfg(windows)] +use std::ffi::OsString; use std::fs; use std::io::{Read, Write}; use std::net::TcpListener; @@ -17,6 +19,8 @@ use toml_edit::{DocumentMut, Item, Value as TomlValue}; use super::*; use crate::config::{BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey}; +const TEST_PLUGIN_GENERATION: &str = "test-generation"; + #[derive(Default)] struct FakeCodexHooksClient { hook_lists: VecDeque, String>>, @@ -82,7 +86,47 @@ impl CodexHooksClient for FakeCodexHooksClient { } fn expected_plugin_command() -> String { - expected_plugin_hook_command().unwrap() + let relay = current_exe().unwrap(); + let relay = relay.canonicalize().unwrap_or(relay); + let relay = portable_executable_path(relay); + codex_plugin_hook_command( + &relay, + Path::new("/tmp/nemo-relay-plugin/.nemo-relay-generation"), + TEST_PLUGIN_GENERATION, + ) + .unwrap() +} + +fn write_plugin_generation_for_hooks(path: &Path) { + let plugin_root = path.parent().and_then(Path::parent).unwrap(); + fs::create_dir_all(plugin_root).unwrap(); + fs::write( + plugin_root.join(crate::install_generation::GENERATION_FILE_NAME), + format!("{TEST_PLUGIN_GENERATION}\n"), + ) + .unwrap(); +} + +fn expected_plugin_command_for_hooks(path: &Path) -> String { + fs::read_to_string(path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .and_then(|value| { + value + .get("hooks")? + .as_object()? + .values() + .next()? + .as_array()? + .first()? + .get("hooks")? + .as_array()? + .first()? + .get("command")? + .as_str() + .map(str::to_owned) + }) + .unwrap_or_else(expected_plugin_command) } fn empty_codex_hooks_client() -> FakeCodexHooksClient { @@ -95,11 +139,12 @@ fn empty_codex_hooks_client() -> FakeCodexHooksClient { fn write_plugin_hooks(plugin_root: &Path) -> PathBuf { let path = plugin_root.join("hooks").join("hooks.json"); fs::create_dir_all(path.parent().unwrap()).unwrap(); + write_plugin_generation_for_hooks(&path); fs::write( &path, serde_json::to_vec_pretty(&generated_hooks( CodingAgent::Codex, - &expected_plugin_command(), + &expected_plugin_hook_command(&path).unwrap(), )) .unwrap(), ) @@ -119,13 +164,14 @@ fn codex_hook_metadata( } else { hooks_path.to_path_buf() }; + write_plugin_generation_for_hooks(&hooks_path); if !hooks_path.exists() { fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); fs::write( &hooks_path, serde_json::to_vec_pretty(&generated_hooks( CodingAgent::Codex, - &expected_plugin_command(), + &expected_plugin_command_for_hooks(&hooks_path), )) .unwrap(), ) @@ -135,7 +181,7 @@ fn codex_hook_metadata( key: key.into(), event_name: event_name.into(), handler_type: "command".into(), - command: Some(expected_plugin_command()), + command: Some(expected_plugin_command_for_hooks(&hooks_path)), source_path: hooks_path.display().to_string(), source: "plugin".into(), plugin_id: Some(CODEX_PLUGIN_ID.into()), @@ -892,7 +938,10 @@ fn codex_auto_trust_rejects_modified_loaded_plugin_hook_file() { .unwrap(); let config_path = dir.path().join("config.toml"); fs::write(&config_path, "").unwrap(); - let hooks = required_codex_hook_metadata(&reported_hooks_path, "untrusted", true); + let mut hooks = required_codex_hook_metadata(&reported_hooks_path, "untrusted", true); + for hook in &mut hooks { + hook.command = Some(expected_plugin_command()); + } let mut client = FakeCodexHooksClient { hook_lists: VecDeque::from([Ok(hooks)]), ..FakeCodexHooksClient::default() @@ -1110,6 +1159,438 @@ fn repeated_codex_install_does_not_overwrite_original_backup() { ); } +#[cfg(unix)] +#[test] +fn codex_install_tightens_the_secret_bearing_config_to_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); +} + +#[test] +fn codex_reinstall_refreshes_a_stale_backup_before_overwriting_user_changes() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + + let user_owned = "model_provider = \"local\"\ncustom = \"preserve-me\"\n"; + fs::write(&path, user_owned).unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + assert_eq!(fs::read_to_string(backup_path(&path)).unwrap(), user_owned); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + + assert_eq!(fs::read_to_string(&path).unwrap(), user_owned); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_reinstall_sanitizes_managed_fields_from_a_partial_edit_backup() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + + let installed = fs::read_to_string(&path).unwrap(); + let partially_edited = installed.replacen( + "model_provider = \"nemo-relay-openai\"", + "model_provider = \"local\"", + 1, + ); + assert_ne!(installed, partially_edited); + fs::write(&path, partially_edited).unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + let backup = fs::read_to_string(backup_path(&path)).unwrap(); + assert!(backup.contains("model_provider = \"local\"")); + assert!(!backup.contains("nemo-relay-openai")); + assert!(!backup.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let uninstalled = fs::read_to_string(&path).unwrap(); + assert!(uninstalled.contains("model_provider = \"local\"")); + assert!(!uninstalled.contains("nemo-relay-openai")); + assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_uninstall_migrates_a_contaminated_legacy_backup() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "model_provider = \"openai\"\ncustom = \"preserve-me\"\n", + ) + .unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + + // Older partial-reinstall logic could replace the original backup with the complete + // generated config. Uninstall must recognize its authenticated proof and not restore it. + let contaminated = fs::read(&path).unwrap(); + fs::write(backup_path(&path), contaminated).unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let uninstalled = fs::read_to_string(&path).unwrap(); + assert!(uninstalled.contains("custom = \"preserve-me\"")); + assert!(!uninstalled.contains("nemo-relay-openai")); + assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(!uninstalled.contains("hooks = true")); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_backup_migration_preserves_user_provider_extensions() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + + let extended = fs::read_to_string(&path).unwrap().replace( + "name = \"NeMo Relay\"", + "name = \"NeMo Relay\"\nuser_option = \"keep\"", + ); + fs::write(&path, &extended).unwrap(); + fs::write(backup_path(&path), &extended).unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let uninstalled = fs::read_to_string(&path).unwrap(); + assert!(uninstalled.contains("user_option = \"keep\"")); + assert!(!uninstalled.contains("model_provider = \"nemo-relay-openai\"")); + assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(!uninstalled.contains("hooks = true")); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_reinstall_round_trips_user_provider_fields_and_headers() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + + let mut extended = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + let provider = extended["model_providers"]["nemo-relay-openai"] + .as_table_mut() + .unwrap(); + provider["user_option"] = Item::Value(TomlValue::from("keep")); + provider["http_headers"] + .as_inline_table_mut() + .unwrap() + .insert("x-user-header", TomlValue::from("keep-header")); + fs::write(&path, extended.to_string()).unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + let reinstalled = fs::read_to_string(&path).unwrap(); + assert!(reinstalled.contains("user_option = \"keep\"")); + assert!(reinstalled.contains("x-user-header = \"keep-header\"")); + assert!(reinstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + let backup = fs::read_to_string(backup_path(&path)).unwrap(); + assert!(backup.contains("model_provider = \"openai\"")); + assert!(backup.contains("user_option = \"keep\"")); + assert!(backup.contains("x-user-header = \"keep-header\"")); + assert!(!backup.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(backup.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let uninstalled = fs::read_to_string(&path).unwrap(); + assert!(uninstalled.contains("model_provider = \"openai\"")); + assert!(uninstalled.contains("user_option = \"keep\"")); + assert!(uninstalled.contains("x-user-header = \"keep-header\"")); + assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(!uninstalled.contains("hooks = true")); +} + +#[test] +fn codex_direct_uninstall_preserves_a_complete_extended_provider_inactively() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + let mut extended = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + let provider = extended["model_providers"]["nemo-relay-openai"] + .as_table_mut() + .unwrap(); + provider["user_option"] = Item::Value(TomlValue::from("keep")); + provider["http_headers"] + .as_inline_table_mut() + .unwrap() + .insert("x-user-header", TomlValue::from("keep-header")); + fs::write(&path, extended.to_string()).unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let uninstalled = fs::read_to_string(&path).unwrap(); + assert!(uninstalled.contains("model_provider = \"openai\"")); + assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(uninstalled.contains("name = \"NeMo Relay\"")); + assert!(uninstalled.contains("user_option = \"keep\"")); + assert!(uninstalled.contains("x-user-header = \"keep-header\"")); + assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(!uninstalled.contains("hooks = true")); +} + +#[test] +fn codex_uninstall_sanitizes_an_extended_contaminated_backup_without_the_key() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\ncustom = \"keep\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + let mut installed = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + let provider = installed["model_providers"]["nemo-relay-openai"] + .as_table_mut() + .unwrap(); + provider["user_option"] = Item::Value(TomlValue::from("keep")); + provider["http_headers"] + .as_inline_table_mut() + .unwrap() + .insert("x-user-header", TomlValue::from("keep-header")); + fs::write(&path, installed.to_string()).unwrap(); + fs::write(backup_path(&path), fs::read(&path).unwrap()).unwrap(); + let key_path = crate::config::user_config_dir() + .unwrap() + .join("bootstrap/fingerprint-hmac.key"); + fs::remove_file(key_path).unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let uninstalled = fs::read_to_string(&path).unwrap(); + assert!(uninstalled.contains("custom = \"keep\"")); + assert!(!uninstalled.contains("model_provider = \"nemo-relay-openai\"")); + assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(uninstalled.contains("user_option = \"keep\"")); + assert!(uninstalled.contains("x-user-header = \"keep-header\"")); + assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(!uninstalled.contains("hooks = true")); +} + +#[test] +fn codex_uninstall_sanitizes_an_extended_contaminated_backup_after_key_rotation() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\ncustom = \"keep\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + let mut installed = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + let provider = installed["model_providers"]["nemo-relay-openai"] + .as_table_mut() + .unwrap(); + provider["user_option"] = Item::Value(TomlValue::from("keep")); + provider["http_headers"] + .as_inline_table_mut() + .unwrap() + .insert("x-user-header", TomlValue::from("keep-header")); + fs::write(&path, installed.to_string()).unwrap(); + fs::write(backup_path(&path), fs::read(&path).unwrap()).unwrap(); + let key_path = crate::config::user_config_dir() + .unwrap() + .join("bootstrap/fingerprint-hmac.key"); + fs::write(key_path, [0x5a; 32]).unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let uninstalled = fs::read_to_string(&path).unwrap(); + assert!(uninstalled.contains("custom = \"keep\"")); + assert!(!uninstalled.contains("model_provider = \"nemo-relay-openai\"")); + assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(uninstalled.contains("user_option = \"keep\"")); + assert!(uninstalled.contains("x-user-header = \"keep-header\"")); + assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(!uninstalled.contains("hooks = true")); +} + +#[test] +fn codex_reinstall_repairs_a_rotated_client_proof_and_keeps_custom_headers() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + let mut installed = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + installed["model_providers"]["nemo-relay-openai"]["http_headers"] + .as_inline_table_mut() + .unwrap() + .insert("x-user-header", TomlValue::from("keep-header")); + fs::write(&path, installed.to_string()).unwrap(); + let key_path = crate::config::user_config_dir() + .unwrap() + .join("bootstrap/fingerprint-hmac.key"); + fs::write(key_path, [0x3c; 32]).unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + let reinstalled = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + let token = codex_provider_client_token(&reinstalled).unwrap(); + assert!( + BootstrapChallengeKey::load_existing() + .unwrap() + .unwrap() + .verify_client_token(token) + ); + assert_eq!( + codex_provider_header(&reinstalled, "x-user-header").and_then(TomlValue::as_str), + Some("keep-header") + ); + let backup = fs::read_to_string(backup_path(&path)).unwrap(); + assert!(!backup.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(backup.contains("x-user-header = \"keep-header\"")); +} + +#[cfg(unix)] +#[test] +fn codex_install_rollback_restores_original_private_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + let path = codex_dir.join("config.toml"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + + let error = install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, _command| Err("injected trust failure".into()), + ) + .unwrap_err(); + + assert!(error.contains("injected trust failure"), "{error}"); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "model_provider = \"openai\"\n" + ); + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); +} + +#[cfg(windows)] +#[test] +fn codex_install_rollback_restores_the_original_windows_dacl() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + let path = codex_dir.join("config.toml"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + set_windows_dacl(&path, "D:P(A;;FA;;;SY)(A;;GRGW;;;WD)"); + let original_dacl = crate::file_io::read_windows_dacl(&path).unwrap(); + + let error = install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, _command| Err("injected trust failure".into()), + ) + .unwrap_err(); + + assert!(error.contains("injected trust failure"), "{error}"); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "model_provider = \"openai\"\n" + ); + assert_eq!( + crate::file_io::read_windows_dacl(&path).unwrap(), + original_dacl + ); +} + +#[cfg(windows)] +fn set_windows_dacl(path: &Path, sddl: &str) { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, + SetFileSecurityW, + }; + + let sddl = std::ffi::OsStr::new(sddl) + .encode_wide() + .chain(Some(0)) + .collect::>(); + let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: The SDDL is NUL-terminated and the output pointer is valid. + assert_ne!( + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + std::ptr::null_mut(), + ) + }, + 0, + "{}", + std::io::Error::last_os_error() + ); + let path = path + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect::>(); + // SAFETY: The path and descriptor are valid for the duration of the call. + let result = unsafe { + SetFileSecurityW( + path.as_ptr(), + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + descriptor, + ) + }; + // SAFETY: The descriptor was allocated by ConvertStringSecurityDescriptor... above. + unsafe { LocalFree(descriptor.cast()) }; + assert_ne!(result, 0, "{}", std::io::Error::last_os_error()); +} + #[test] fn codex_upgrade_adds_client_proof_without_replacing_original_backup() { let dir = tempdir().unwrap(); @@ -1269,6 +1750,7 @@ fn codex_hooks_installed_requires_generated_plugin_local_groups() { let plugin_root = dir.path().join("plugin"); let path = plugin_root.join("hooks").join("hooks.json"); fs::create_dir_all(path.parent().unwrap()).unwrap(); + write_plugin_generation_for_hooks(&path); fs::write( &path, serde_json::to_vec_pretty(&json!({ @@ -1295,6 +1777,37 @@ fn codex_hooks_installed_requires_generated_plugin_local_groups() { assert!(codex_hooks_installed(&path).unwrap()); } +#[test] +fn codex_setup_can_validate_hooks_while_installer_holds_the_generation_lock() { + let dir = tempdir().unwrap(); + let plugin_root = dir.path().join("plugin"); + let hooks_path = plugin_root.join("hooks").join("hooks.json"); + let generation_path = plugin_root.join(crate::install_generation::GENERATION_FILE_NAME); + let generation_lock = dir.path().join("generation-transaction.lock"); + let token = crate::install_generation::write_new_generation_with_token_at( + &generation_path, + &generation_lock, + ) + .unwrap(); + let relay = current_exe().unwrap(); + let relay = portable_executable_path(relay.canonicalize().unwrap_or(relay)); + let command = codex_plugin_hook_command(&relay, &generation_path, &token).unwrap(); + fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); + fs::write( + &hooks_path, + serde_json::to_vec_pretty(&generated_hooks(CodingAgent::Codex, &command)).unwrap(), + ) + .unwrap(); + let _transaction = crate::install_generation::GenerationRetirement::acquire(&generation_path) + .unwrap() + .unwrap(); + + assert!( + codex_hooks_installed_with_generation(&hooks_path, Some(&token)).unwrap(), + "installer-owned validation must use its verified token instead of reacquiring its lock" + ); +} + #[cfg(not(windows))] #[test] fn codex_doctor_requires_app_server_reported_trust_but_allows_stopped_sidecar() { @@ -1816,6 +2329,28 @@ supports_websockets = false assert!(!backup_path(&path).exists()); } +#[test] +fn codex_uninstall_removes_proof_from_a_user_modified_provider() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex/config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + let installed = fs::read_to_string(&path).unwrap(); + assert!(installed.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + let modified = installed.replacen(DEFAULT_URL, "http://127.0.0.1:49999", 1); + assert_ne!(installed, modified); + fs::write(&path, modified).unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + + assert!(updated.contains("base_url = \"http://127.0.0.1:49999\"")); + assert!(!updated.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(!backup_path(&path).exists()); +} + #[test] fn codex_uninstall_without_backup_preserves_user_changed_provider_url() { let dir = tempdir().unwrap(); @@ -1991,7 +2526,7 @@ fn claude_enable_rolls_back_backup_when_settings_write_fails() { .unwrap(), ) .unwrap(); - fs::create_dir(settings.with_extension("json.tmp")).unwrap(); + crate::file_io::fail_next_atomic_write(&settings); let error = enable_claude_provider(DEFAULT_URL).unwrap_err(); @@ -2447,6 +2982,66 @@ fn sidecar_reaper_removes_only_the_exited_process_records() { assert!(!pid_path.exists()); } +#[cfg(unix)] +#[test] +fn sidecar_reaper_terminates_descendants_left_by_an_exited_gateway() { + let dir = tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + let owner_path = sidecar_owner_path(dir.path(), url); + let pid_path = sidecar_pid_path(dir.path(), url); + let descendant_pid_path = dir.path().join("descendant.pid"); + let endpoint_lock = lock_sidecar_endpoint(dir.path(), url).unwrap(); + let mut command = std::process::Command::new("sh"); + command + .args(["-c", "sleep 30 & echo $! > \"$1\"; exit 0", "sh"]) + .arg(&descendant_pid_path); + configure_detached_sidecar(&mut command); + let child = command.spawn().unwrap(); + let pid = child.id(); + write_sidecar_owner( + &owner_path, + pid, + url, + "test-shutdown-token", + Some("test-fingerprint"), + ) + .unwrap(); + fs::write(&pid_path, pid.to_string()).unwrap(); + handoff_sidecar_to_reaper( + child, + owner_path.clone(), + pid_path.clone(), + sidecar_lock_path(dir.path(), url), + ) + .unwrap(); + drop(endpoint_lock); + + let deadline = Instant::now() + Duration::from_secs(3); + while !descendant_pid_path.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + let descendant_pid = fs::read_to_string(&descendant_pid_path) + .unwrap() + .trim() + .parse::() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + loop { + // SAFETY: Signal 0 only checks whether the finite test process is still present. + let descendant_gone = unsafe { libc::kill(descendant_pid, 0) } == -1 + && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH); + if descendant_gone && !owner_path.exists() && !pid_path.exists() { + break; + } + if Instant::now() >= deadline { + // SAFETY: Best-effort cleanup of the finite test process on assertion failure. + unsafe { libc::kill(descendant_pid, libc::SIGKILL) }; + panic!("sidecar descendant {descendant_pid} survived direct gateway exit"); + } + thread::sleep(Duration::from_millis(20)); + } +} + #[test] fn sidecar_reaper_does_not_block_other_cleanup_on_a_contended_lock() { let dir = tempdir().unwrap(); @@ -2562,17 +3157,33 @@ fn runtime_dir_prefers_explicit_runtime_base_without_user_segment() { } #[test] -fn codex_hook_command_uses_cmd_quoting_for_windows_paths() { +fn windows_shell_argument_quoting_and_hook_encoding_preserve_paths() { let relay = std::path::PathBuf::from(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"); - let command = codex_hook_command_for_platform(&relay, DEFAULT_URL, true); - + let generation = + std::path::PathBuf::from(r"C:\Program Files\NeMo 100%\plugin\.nemo-relay-generation"); assert_eq!( - command, - r#""C:\Program Files\NeMo 100%%cd:~,%%\bin\nemo-relay.exe" hook-forward codex --gateway-url http://127.0.0.1:47632"# + shell_quote_arg_for_platform(relay.to_str().unwrap(), true), + r#""C:\Program Files\NeMo 100%%cd:~,%\bin\nemo-relay.exe""# ); assert_eq!( - codex_plugin_hook_command_for_platform(&relay, true), - r#""C:\Program Files\NeMo 100%%cd:~,%%\bin\nemo-relay.exe" hook-forward codex --gateway-url http://127.0.0.1:47632"# + crate::installer::decode_windows_hook_command(&codex_plugin_hook_command_for_platform( + &relay, + &generation, + "test-generation", + true, + )) + .unwrap(), + vec![ + relay.display().to_string(), + "hook-forward".into(), + "codex".into(), + "--gateway-url".into(), + DEFAULT_URL.into(), + "--generation-file".into(), + generation.display().to_string(), + "--generation-token".into(), + "test-generation".into(), + ] ); assert_eq!( shell_quote_arg_for_platform("foo&bar", true), @@ -2585,29 +3196,78 @@ fn codex_hook_command_uses_cmd_quoting_for_windows_paths() { #[test] fn generated_windows_hook_command_executes_exact_arguments() { let temp = tempfile::tempdir().unwrap(); - let bin = temp.path().join("Relay & %USERPROFILE% Tools"); + let bin = temp.path().join("Relay & %USERPROFILE% !^ Tools"); std::fs::create_dir(&bin).unwrap(); - let relay = bin.join("nemo-relay.cmd"); + let relay = bin.join("nemo-relay.exe"); + compile_windows_hook_test_relay(&relay); let marker = temp.path().join("hook-ran.txt"); - std::fs::write( - &relay, - "@echo off\r\n\ - @if not \"%~1\"==\"hook-forward\" exit /b 11\r\n\ - @if not \"%~2\"==\"codex\" exit /b 12\r\n\ - @if not \"%~3\"==\"--gateway-url\" exit /b 13\r\n\ - @if not \"%~4\"==\"http://127.0.0.1:47632\" exit /b 14\r\n\ - @echo ok>\"%NEMO_RELAY_HOOK_MARKER%\"\r\n", - ) - .unwrap(); - let command = codex_plugin_hook_command_for_platform(&relay, true); - let status = std::process::Command::new("cmd.exe") - .args(["/d", "/v:off", "/s", "/c", &command]) + let input_marker = temp.path().join("hook-input.txt"); + let generation = temp.path().join("Generation & %USERPROFILE%"); + let command = codex_plugin_hook_command(&relay, &generation, "test-generation").unwrap(); + let mut child = std::process::Command::new("cmd.exe") + .arg("/C") + .arg(&command) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) .env("NEMO_RELAY_HOOK_MARKER", &marker) - .status() + .env("NEMO_RELAY_HOOK_INPUT_MARKER", &input_marker) + .env("NEMO_RELAY_HOOK_GENERATION", &generation) + .env("NEMO_RELAY_HOOK_EMIT_OUTPUT", "1") + .spawn() .unwrap(); + use std::io::Write; + child.stdin.take().unwrap().write_all(b"ping\n").unwrap(); + let output = child.wait_with_output().unwrap(); - assert!(status.success(), "{command}"); + assert!(output.status.success(), "{command}"); assert_eq!(std::fs::read_to_string(marker).unwrap().trim(), "ok"); + assert_eq!(std::fs::read(input_marker).unwrap(), b"ping\n"); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "hook-stdout" + ); + assert_eq!( + String::from_utf8_lossy(&output.stderr).trim(), + "hook-stderr" + ); +} + +#[cfg(windows)] +#[test] +fn generated_windows_hook_command_propagates_the_relay_exit_code() { + let temp = tempfile::tempdir().unwrap(); + let relay = temp.path().join("relay failure.exe"); + compile_windows_hook_test_relay(&relay); + let generation = temp.path().join("generation"); + let command = codex_plugin_hook_command(&relay, &generation, "test-generation").unwrap(); + + let status = std::process::Command::new("cmd.exe") + .arg("/C") + .arg(&command) + .env("NEMO_RELAY_HOOK_GENERATION", &generation) + .env("NEMO_RELAY_HOOK_EXIT_CODE", "23") + .status() + .unwrap(); + + assert_eq!(status.code(), Some(23), "{command}"); +} + +#[cfg(windows)] +fn compile_windows_hook_test_relay(output: &Path) { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/windows_hook_relay.rs"); + let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc")); + let compiled = std::process::Command::new(rustc) + .arg(source) + .args(["--edition", "2024", "-o"]) + .arg(output) + .output() + .unwrap(); + assert!( + compiled.status.success(), + "failed to compile native hook fixture: {}", + String::from_utf8_lossy(&compiled.stderr) + ); } #[test] @@ -2628,17 +3288,17 @@ fn windows_sidecar_flags_only_request_permitted_job_breakaway() { } #[test] -fn codex_hook_command_uses_posix_single_quote_escaping() { +fn posix_shell_argument_quoting_and_hook_encoding_preserve_paths() { let relay = std::path::PathBuf::from("/tmp/NeMo $Relay`test'/bin/nemo-relay"); - let command = codex_hook_command_for_platform(&relay, DEFAULT_URL, false); - + let generation = + std::path::PathBuf::from("/tmp/NeMo $Relay`test'/plugin/.nemo-relay-generation"); assert_eq!( - command, - "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward codex --gateway-url http://127.0.0.1:47632" + shell_quote_arg_for_platform(relay.to_str().unwrap(), false), + "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay'" ); assert_eq!( - codex_plugin_hook_command_for_platform(&relay, false), - "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward codex --gateway-url http://127.0.0.1:47632" + codex_plugin_hook_command_for_platform(&relay, &generation, "test-generation", false), + "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward codex --gateway-url http://127.0.0.1:47632 --generation-file '/tmp/NeMo $Relay`test'\\''/plugin/.nemo-relay-generation' --generation-token test-generation" ); assert_eq!(shell_quote_arg_for_platform("", false), "''"); assert_eq!( @@ -2691,7 +3351,11 @@ fn ensure_sidecar_releases_lock_when_startup_fails_fast() { let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &runtime); let error = loopback_bind("not a loopback url") - .and_then(|bind| GatewaySpec::new(CodingAgent::Codex, bind).ensure()) + .and_then(|bind| { + GatewaySpec::new(bind) + .acquire() + .map(|result| result.endpoint) + }) .unwrap_err(); assert!(error.contains("loopback URL")); @@ -2710,7 +3374,7 @@ fn healthz_rejects_foreign_success_response() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let handle = thread::spawn(move || { - for _ in 0..4 { + for _ in 0..3 { let (mut stream, _) = listener.accept().unwrap(); let _ = read_http_request(&mut stream); stream @@ -2721,7 +3385,10 @@ fn healthz_rejects_foreign_success_response() { } }); - let error = ensure_sidecar_bind(CodingAgent::Codex, address).unwrap_err(); + let error = GatewaySpec::new(address) + .acquire() + .err() + .expect("foreign listener unexpectedly acquired"); assert!( error.contains("not a compatible NeMo Relay gateway"), "{error}" @@ -2743,25 +3410,27 @@ fn startup_reprobes_a_transient_foreign_health_result_after_locking() { .unwrap(); drop(starting); - let (mut ready, _) = listener.accept().unwrap(); - let _ = read_http_request(&mut ready); let body = format!( r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{},"instance_id":"test-instance"}}"#, env!("CARGO_PKG_VERSION"), BOOTSTRAP_PROTOCOL_VERSION ); - ready - .write_all( - format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() + for _ in 0..2 { + let (mut ready, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut ready); + ready + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), ) - .as_bytes(), - ) - .unwrap(); + .unwrap(); + } }); - let endpoint = ensure_sidecar_bind(CodingAgent::Codex, address).unwrap(); + let endpoint = GatewaySpec::new(address).acquire().unwrap().endpoint; assert_eq!(endpoint.address, address); handle.join().unwrap(); @@ -2781,33 +3450,30 @@ fn active_startup_lock_waits_for_relay_identity_instead_of_rejecting_listener() let lock_path = sidecar_lock_path(&state, &url); let owner_lock = lock_sidecar_endpoint(&state, &url).unwrap(); let handle = thread::spawn(move || { - let (mut starting, _) = listener.accept().unwrap(); - let _ = read_http_request(&mut starting); - starting - .write_all(b"HTTP/1.1 503 Starting\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") - .unwrap(); - drop(starting); + thread::sleep(Duration::from_millis(50)); drop(owner_lock); - let (mut ready, _) = listener.accept().unwrap(); - let _ = read_http_request(&mut ready); let body = format!( r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{},"instance_id":"test-instance"}}"#, env!("CARGO_PKG_VERSION"), BOOTSTRAP_PROTOCOL_VERSION ); - ready - .write_all( - format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() + for _ in 0..2 { + let (mut ready, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut ready); + ready + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), ) - .as_bytes(), - ) - .unwrap(); + .unwrap(); + } }); - let endpoint = ensure_sidecar_bind(CodingAgent::Codex, address).unwrap(); + let endpoint = GatewaySpec::new(address).acquire().unwrap().endpoint; assert_eq!(endpoint.address, address); assert_eq!(endpoint.url, format!("http://{address}")); @@ -2829,7 +3495,7 @@ fn start_sidecar_reports_child_exit_before_healthz_ready() { fs::set_permissions(&relay, permissions).unwrap(); let _binary = EnvVarGuard::set_path("NEMO_RELAY_PLUGIN_BINARY", &relay); - let error = start_sidecar(CodingAgent::Codex, "http://127.0.0.1:0", dir.path()).unwrap_err(); + let error = start_sidecar("http://127.0.0.1:0", dir.path()).unwrap_err(); assert!(error.contains("exited before becoming ready"), "{error}"); assert!(!dir.path().join("codex-sidecar.pid").exists()); @@ -2966,8 +3632,15 @@ fn codex_install_hooks_persist_custom_gateway_url() { .as_str() .unwrap(); - assert!(command.contains("hook-forward codex")); - assert!(command.contains("--gateway-url http://127.0.0.1:47633")); + assert!(crate::hook_assertions::command_has_arguments( + command, + &[ + "hook-forward", + "codex", + "--gateway-url", + "http://127.0.0.1:47633", + ] + )); } #[test] @@ -3055,6 +3728,7 @@ fn codex_install_does_not_write_provider_config_when_hooks_are_invalid() { .unwrap(); let plugin_hooks = dir.path().join("plugin").join("hooks").join("hooks.json"); fs::create_dir_all(plugin_hooks.parent().unwrap()).unwrap(); + write_plugin_generation_for_hooks(&plugin_hooks); fs::write(&plugin_hooks, "{ invalid json").unwrap(); let error = install_codex(DEFAULT_URL, &plugin_hooks).unwrap_err(); @@ -3148,7 +3822,7 @@ fn codex_install_config_rolls_back_backup_when_write_fails() { let dir = tempdir().unwrap(); let path = dir.path().join("config.toml"); fs::write(&path, "model_provider = \"openai\"\n").unwrap(); - fs::create_dir(path.with_extension("toml.tmp")).unwrap(); + crate::file_io::fail_next_atomic_write(&path); let error = install_codex_config(&path, DEFAULT_URL).unwrap_err(); @@ -3221,7 +3895,7 @@ fn codex_install_rolls_back_hooks_when_provider_config_write_fails() { "model_provider = \"openai\"\n", ) .unwrap(); - fs::create_dir(codex_dir.join("config.toml.tmp")).unwrap(); + crate::file_io::fail_next_atomic_write(&codex_dir.join("config.toml")); let hooks_path = codex_dir.join("hooks.json"); let original_hooks = serde_json::to_vec_pretty(&json!({ "hooks": { @@ -3597,6 +4271,7 @@ fn plugin_host_entrypoints_reject_unsupported_agents_and_report_json() { let plugin_root = dir.path().join("plugin"); let plugin_hooks = plugin_root.join("hooks").join("hooks.json"); fs::create_dir_all(plugin_hooks.parent().unwrap()).unwrap(); + write_plugin_generation_for_hooks(&plugin_hooks); fs::write( &plugin_hooks, serde_json::to_vec_pretty(&json!({ diff --git a/crates/cli/tests/coverage/plugin_install_setup_tests.rs b/crates/cli/tests/coverage/plugin_install_setup_tests.rs index 14de47921..d63ce31c9 100644 --- a/crates/cli/tests/coverage/plugin_install_setup_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_setup_tests.rs @@ -2,11 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 use serde_json::{Value, json}; +use std::cell::RefCell; use super::*; struct DefaultsOnlyRunner; +#[derive(Default)] +struct GenerationAwareRunner { + calls: RefCell>, +} + impl PluginSetupRunner for DefaultsOnlyRunner { fn setup( &self, @@ -45,6 +51,70 @@ impl PluginSetupRunner for DefaultsOnlyRunner { } } +impl PluginSetupRunner for GenerationAwareRunner { + fn setup( + &self, + _host: IntegrationHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + panic!("generation-aware setup entry point was bypassed") + } + + fn setup_with_generation( + &self, + _host: IntegrationHost, + _gateway_url: &str, + _plugin_root: &Path, + generation_token: Option<&str>, + ) -> Result<(), String> { + self.calls + .borrow_mut() + .push(format!("setup:{}", generation_token.unwrap_or("missing"))); + Ok(()) + } + + fn uninstall( + &self, + _host: IntegrationHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor( + &self, + _host: IntegrationHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + panic!("generation-aware doctor entry point was bypassed") + } + + fn doctor_with_generation( + &self, + _host: IntegrationHost, + _gateway_url: &str, + _plugin_root: &Path, + generation_token: Option<&str>, + ) -> Result<(), String> { + self.calls + .borrow_mut() + .push(format!("doctor:{}", generation_token.unwrap_or("missing"))); + Ok(()) + } + + fn doctor_json( + &self, + _host: IntegrationHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result { + Ok(json!({})) + } +} + #[test] fn setup_runner_defaults_are_explicit_no_ops() { let runner = DefaultsOnlyRunner; @@ -54,6 +124,42 @@ fn setup_runner_defaults_are_explicit_no_ops() { runner.refresh_gateway().unwrap(); } +#[test] +fn setup_and_doctor_receive_the_installer_verified_generation() { + let dir = tempfile::tempdir().unwrap(); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let options = PluginInstallOptions { + install_dir: dir.path().to_owned(), + operation_lock_dir: dir.path().join("locks"), + force: false, + dry_run: false, + skip_doctor: false, + }; + let runner = GenerationAwareRunner::default(); + + run_plugin_setup_with_generation( + IntegrationHost::Codex, + &layout, + &options, + &runner, + Some("generation-a"), + ) + .unwrap(); + run_plugin_doctor_with_generation( + IntegrationHost::Codex, + &layout.plugin_root, + &options, + &runner, + Some("generation-a"), + ) + .unwrap(); + + assert_eq!( + *runner.calls.borrow(), + ["setup:generation-a", "doctor:generation-a"] + ); +} + #[test] fn setup_descriptions_reject_unexpanded_hosts_and_unknown_actions() { assert!( diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index 14be1e3f6..35f87c476 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -28,6 +28,7 @@ const OPERATION_LOCK_HELPER_GLOBAL_DIR_ENV: &str = "NEMO_RELAY_TEST_OPERATION_LO const GENERATION_LOCK_HELPER_PATH_ENV: &str = "NEMO_RELAY_TEST_GENERATION_LOCK_PATH"; const LOCK_HELPER_READY_ENV: &str = "NEMO_RELAY_TEST_LOCK_READY"; const LOCK_HELPER_RELEASE_ENV: &str = "NEMO_RELAY_TEST_LOCK_RELEASE"; +const TEST_GENERATION_TOKEN: &str = "test-generation"; fn force_snapshot_with_backups( backup_marketplace_root: PathBuf, @@ -108,20 +109,23 @@ fn committed_force_snapshot_removes_all_backup_trees_best_effort() { std::fs::create_dir_all(&marketplace).unwrap(); std::fs::create_dir_all(&plugin).unwrap(); - force_snapshot_with_backups(marketplace.clone(), Some(plugin.clone())).commit(); + force_snapshot_with_backups(marketplace.clone(), Some(plugin.clone())) + .commit(&dir.path().join("replacement.lock")); assert!(!marketplace.exists()); assert!(!plugin.exists()); let missing_marketplace = dir.path().join("missing-marketplace"); let missing_plugin = dir.path().join("missing-plugin"); - force_snapshot_with_backups(missing_marketplace, Some(missing_plugin)).commit(); + force_snapshot_with_backups(missing_marketplace, Some(missing_plugin)) + .commit(&dir.path().join("replacement.lock")); let marketplace_file = dir.path().join("marketplace-file"); let plugin_file = dir.path().join("plugin-file"); std::fs::write(&marketplace_file, "file").unwrap(); std::fs::write(&plugin_file, "file").unwrap(); - force_snapshot_with_backups(marketplace_file.clone(), Some(plugin_file.clone())).commit(); + force_snapshot_with_backups(marketplace_file.clone(), Some(plugin_file.clone())) + .commit(&dir.path().join("replacement.lock")); assert!(marketplace_file.exists()); assert!(plugin_file.exists()); } @@ -163,6 +167,7 @@ fn staged_marketplace_promotion_reports_the_source_and_target() { let staged = StagedPluginMarketplace { layout: PluginLayout::new(IntegrationHost::Codex, &staged_parent), parent: staged_parent, + generation_lock_created: false, }; let target = PluginLayout::new(IntegrationHost::Codex, &target_parent); @@ -179,6 +184,35 @@ fn staged_marketplace_promotion_reports_the_source_and_target() { ); } +#[test] +fn replacement_generation_guard_removes_an_owned_lock_after_marker_removal() { + let dir = tempdir().unwrap(); + let marker = dir.path().join("generation-marker"); + let lock = dir.path().join("generation.lock"); + crate::install_generation::write_new_generation_with_token_at(&marker, &lock).unwrap(); + let guard = acquire_replacement_generation_lock(IntegrationHost::Codex, &marker, true).unwrap(); + + std::fs::remove_file(marker).unwrap(); + drop(guard); + + assert!(!lock.exists()); +} + +#[test] +fn replacement_generation_guard_retains_its_lock_when_marker_state_is_uncertain() { + let dir = tempdir().unwrap(); + let marker = dir.path().join("generation-marker"); + let lock = dir.path().join("generation.lock"); + crate::install_generation::write_new_generation_with_token_at(&marker, &lock).unwrap(); + let guard = acquire_replacement_generation_lock(IntegrationHost::Codex, &marker, true).unwrap(); + + std::fs::remove_file(&marker).unwrap(); + std::fs::create_dir(&marker).unwrap(); + drop(guard); + + assert!(lock.exists()); +} + #[test] fn codex_plugin_requires_version_with_complete_hook_support() { let dir = tempdir().unwrap(); @@ -625,6 +659,63 @@ struct MockSetupRunner { failing_call: Option, } +struct BlockingRefreshFailure { + entered: std::sync::mpsc::Sender<()>, + continue_refresh: std::sync::mpsc::Receiver<()>, +} + +impl PluginSetupRunner for BlockingRefreshFailure { + fn snapshot(&self, _host: IntegrationHost) -> Result, String> { + Ok(Some(PluginSetupSnapshot::Mock)) + } + + fn restore_snapshot(&self, _snapshot: &PluginSetupSnapshot) -> Result<(), String> { + Ok(()) + } + + fn refresh_gateway(&self) -> Result<(), String> { + self.entered.send(()).unwrap(); + self.continue_refresh.recv().unwrap(); + Err("refresh gateway failed".into()) + } + + fn setup( + &self, + _host: IntegrationHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn uninstall( + &self, + _host: IntegrationHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor( + &self, + _host: IntegrationHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor_json( + &self, + _host: IntegrationHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result { + Ok(json!({"ok": true, "checks": {}})) + } +} + impl MockSetupRunner { fn calls(&self) -> Vec { self.calls.borrow().clone() @@ -729,6 +820,19 @@ fn write_installed_state(host: IntegrationHost, dir: &Path) { mark_plugin_setup_installed(host, &layout, &options(dir)).unwrap(); } +#[cfg(windows)] +fn replace_generation_with_legacy_marker(layout: &PluginLayout) -> (String, PathBuf) { + let token = { + let generation = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + generation.token().to_owned() + }; + std::fs::remove_file(&layout.generation_lock).unwrap(); + crate::install_generation::write_legacy_generation(&layout.generation_fence, &token).unwrap(); + let mut lock_path = layout.generation_fence.as_os_str().to_os_string(); + lock_path.push(".lock"); + (token, PathBuf::from(lock_path)) +} + fn write_relocated_codex_install(selected_dir: &Path, relocated_dir: &Path) -> PluginLayout { let relocated = PluginLayout::new(IntegrationHost::Codex, relocated_dir); write_plugin_marketplace( @@ -944,7 +1048,7 @@ fn concurrent_install_install_times_out_without_mutating() { &setup_runner, Duration::from_millis(75), ) - .unwrap_err(); + .expect_err("contended install unexpectedly succeeded"); assert!(error.contains("another codex plugin install or uninstall")); assert!(runner.commands().is_empty()); @@ -973,7 +1077,7 @@ fn concurrent_install_uninstall_times_out_without_mutating() { &setup_runner, Duration::from_millis(75), ) - .unwrap_err(); + .expect_err("contended uninstall unexpectedly succeeded"); assert!(error.contains("another codex plugin install or uninstall")); assert!(runner.commands().is_empty()); @@ -1094,16 +1198,18 @@ fn plugin_manifests_and_hooks_use_path_based_relay_command() { IntegrationHost::Codex, Path::new("/bin/nemo-relay"), &generation_fence, + TEST_GENERATION_TOKEN, ) .unwrap(); let server = &mcp["nemo-relay"]; assert_eq!(server["command"], json!("/bin/nemo-relay")); - assert_eq!(server["args"], json!(["mcp", "--agent", "codex"])); + assert_eq!(server["args"], json!(["mcp"])); assert_eq!( server["env"], json!({ "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632", - "NEMO_RELAY_MCP_GENERATION_FILE": &generation_fence + "NEMO_RELAY_MCP_GENERATION_FILE": &generation_fence, + "NEMO_RELAY_MCP_GENERATION": TEST_GENERATION_TOKEN }) ); assert_eq!(server["required"], json!(true)); @@ -1118,30 +1224,55 @@ fn plugin_manifests_and_hooks_use_path_based_relay_command() { IntegrationHost::ClaudeCode, Path::new("/bin/nemo-relay"), &generation_fence, + TEST_GENERATION_TOKEN, ); let claude_server = &claude_mcp.unwrap()["mcpServers"]["nemo-relay"]; assert_eq!(claude_server["command"], json!("/bin/nemo-relay")); - assert_eq!(claude_server["args"], json!(["mcp", "--agent", "claude"])); + assert_eq!(claude_server["args"], json!(["mcp"])); assert_eq!(claude_server["alwaysLoad"], json!(true)); assert_eq!( claude_server["env"]["NEMO_RELAY_MCP_GENERATION_FILE"], - json!(generation_fence) + json!(&generation_fence) + ); + assert_eq!( + claude_server["env"]["NEMO_RELAY_MCP_GENERATION"], + json!(TEST_GENERATION_TOKEN) ); assert_eq!( - plugin_hooks(IntegrationHost::Codex, Path::new("/bin/nemo-relay"))["hooks"]["SessionStart"] - [0]["hooks"][0]["command"], - json!(crate::installer::persistent_hook_forward_command( + plugin_hooks( + IntegrationHost::Codex, Path::new("/bin/nemo-relay"), - CodingAgent::Codex, - )) + &generation_fence, + TEST_GENERATION_TOKEN, + ) + .unwrap()["hooks"]["SessionStart"][0]["hooks"][0]["command"], + json!( + crate::installer::persistent_hook_forward_command( + Path::new("/bin/nemo-relay"), + CodingAgent::Codex, + &generation_fence, + TEST_GENERATION_TOKEN, + ) + .unwrap() + ) ); assert_eq!( - plugin_hooks(IntegrationHost::ClaudeCode, Path::new("/bin/nemo-relay"))["hooks"]["SessionStart"] - [0]["hooks"][0]["command"], - json!(crate::installer::persistent_hook_forward_command( + plugin_hooks( + IntegrationHost::ClaudeCode, Path::new("/bin/nemo-relay"), - CodingAgent::ClaudeCode, - )) + &generation_fence, + TEST_GENERATION_TOKEN, + ) + .unwrap()["hooks"]["SessionStart"][0]["hooks"][0]["command"], + json!( + crate::installer::persistent_hook_forward_command( + Path::new("/bin/nemo-relay"), + CodingAgent::ClaudeCode, + &generation_fence, + TEST_GENERATION_TOKEN, + ) + .unwrap() + ) ); } @@ -1153,20 +1284,35 @@ fn relay_identity_uses_running_executable_when_path_points_elsewhere() { .with_executable("nemo-relay", "/opt/nemo-relay/stale/nemo-relay"); let relay = require_relay(&options(dir.path()), &runner).unwrap(); + let generation = dir + .path() + .join("plugins/nemo-relay-plugin/.nemo-relay-generation"); assert_eq!(relay, PathBuf::from("/opt/nemo-relay/current/nemo-relay")); assert_eq!( - plugin_hooks(IntegrationHost::Codex, &relay)["hooks"]["SessionStart"][0]["hooks"][0]["command"], - json!(crate::installer::persistent_hook_forward_command( + plugin_hooks( + IntegrationHost::Codex, &relay, - CodingAgent::Codex, - )) + &generation, + TEST_GENERATION_TOKEN, + ) + .unwrap()["hooks"]["SessionStart"][0]["hooks"][0]["command"], + json!( + crate::installer::persistent_hook_forward_command( + &relay, + CodingAgent::Codex, + &generation, + TEST_GENERATION_TOKEN, + ) + .unwrap() + ) ); assert_eq!( plugin_mcp_config( IntegrationHost::Codex, &relay, - Path::new("/plugins/nemo-relay-plugin/.nemo-relay-generation"), + &generation, + TEST_GENERATION_TOKEN, ) .unwrap()["nemo-relay"]["command"], json!(relay) @@ -1209,8 +1355,10 @@ fn codex_mcp_env_vars_include_approved_dynamic_and_config_references_only() { "NEMO_RELAY_PLUGIN_BINARY", "NEMO_RELAY_Plugin_Binary", "NEMO_RELAY_GATEWAY_BIND", + "NEMO_RELAY_MCP_GENERATION", "NEMO_RELAY_MCP_GENERATION_FILE", "NEMO_RELAY_FAIL_CLOSED", + "NEMO_RELAY_TRANSPARENT_RUN", "NEMO_RELAY_TEST_CODEX_LOG", "NEMO_RELAY_Test_CodeX_Log", ] @@ -1220,8 +1368,11 @@ fn codex_mcp_env_vars_include_approved_dynamic_and_config_references_only() { assert!(names.is_sorted()); for expected in [ + "ALL_PROXY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "NEMO_RELAY_GATEWAY_URL", + "NEMO_RELAY_TRANSPARENT_RUN", "NEMO_RELAY_CUSTOM_SETTING", "OTEL_CUSTOM_SETTING", "AWS_CUSTOM_SETTING", @@ -1234,11 +1385,18 @@ fn codex_mcp_env_vars_include_approved_dynamic_and_config_references_only() { "missing {expected}" ); } + let all_proxy_names = names + .iter() + .filter(|name| name.eq_ignore_ascii_case("ALL_PROXY")) + .collect::>(); + assert_eq!(all_proxy_names.len(), if cfg!(windows) { 1 } else { 2 }); + assert_eq!(names.iter().any(|name| name == "all_proxy"), !cfg!(windows)); for excluded in [ "NEMO_RELAY_WORKER_TOKEN", "NEMO_RELAY_PLUGIN_BINARY", "NEMO_RELAY_Plugin_Binary", "NEMO_RELAY_GATEWAY_BIND", + "NEMO_RELAY_MCP_GENERATION", "NEMO_RELAY_MCP_GENERATION_FILE", "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", "NEMO_RELAY_FAIL_CLOSED", @@ -1253,6 +1411,27 @@ fn codex_mcp_env_vars_include_approved_dynamic_and_config_references_only() { } } +#[test] +fn checked_in_codex_mcp_env_vars_match_the_generated_base_allowlist() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../integrations/coding-agents/codex/.mcp.json"); + let checked_in: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + let checked_in = checked_in["nemo-relay"]["env_vars"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap().to_string()) + .collect::>(); + + assert_eq!( + checked_in, + crate::mcp_environment::forwarded_names_for_platform(std::iter::empty(), None, false), + "{} drifted from generated MCP environment names", + path.display() + ); +} + #[test] fn codex_mcp_env_vars_match_and_deduplicate_names_using_platform_semantics() { let config = json!({ @@ -1284,6 +1463,16 @@ fn codex_mcp_env_vars_match_and_deduplicate_names_using_platform_semantics() { 1 ); assert!(windows.iter().any(|name| name == "OPENAI_API_KEY")); + for proxy in ["ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"] { + assert_eq!( + windows + .iter() + .filter(|name| name.eq_ignore_ascii_case(proxy)) + .count(), + 1, + "Windows MCP environment contains duplicate {proxy} spellings" + ); + } let unix = crate::mcp_environment::forwarded_names_for_platform(environment, Some(&config), false); @@ -1319,6 +1508,13 @@ fn plugin_setup_delegates_and_dry_run_skips_runner_calls() { &setup_runner, ) .unwrap(); + uninstall_host_locked( + IntegrationHost::Codex, + &dry_run, + &MockRunner::default(), + &setup_runner, + ) + .unwrap(); assert!(setup_runner.calls().is_empty()); let normal = options(dir.path()); @@ -1526,7 +1722,7 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { #[cfg(windows)] assert!(quoted.contains("\"arg with space\"")); #[cfg(windows)] - assert!(quoted.contains("\"quote^\"$\"")); + assert!(quoted.contains("\"quote\"\"$\"")); let runner = MockRunner::default() .with_executable("codex", "/bin/codex") @@ -1895,11 +2091,17 @@ fn install_codex_generates_marketplace_and_runs_setup() { .unwrap(); let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); - InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + let generation = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); assert_eq!( serde_json::from_str::(&std::fs::read_to_string(&layout.hooks_path).unwrap()) .unwrap(), - plugin_hooks(IntegrationHost::Codex, Path::new("/bin/nemo-relay")) + plugin_hooks( + IntegrationHost::Codex, + Path::new("/bin/nemo-relay"), + &layout.generation_fence, + generation.token(), + ) + .unwrap() ); assert_eq!( runner.commands(), @@ -1922,6 +2124,7 @@ fn install_codex_generates_marketplace_and_runs_setup() { IntegrationHost::Codex, Path::new("/bin/nemo-relay"), &layout.generation_fence, + generation.token(), ) .unwrap() ); @@ -2111,7 +2314,7 @@ fn force_install_unregisters_existing_host_before_reinstall() { setup_runner .calls() .iter() - .all(|call| call != &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) ); assert!( setup_runner @@ -2128,7 +2331,12 @@ fn force_install_unregisters_existing_host_before_reinstall() { .iter() .position(|call| call == "snapshot codex") .unwrap(); - assert!(snapshot_index < refresh_index); + let uninstall_index = setup_calls + .iter() + .position(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + .unwrap(); + assert!(snapshot_index < uninstall_index); + assert!(uninstall_index < refresh_index); } #[test] @@ -2146,18 +2354,180 @@ fn force_install_retires_previous_mcp_generation() { write_installed_state(IntegrationHost::Codex, dir.path()); let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + let previous_token = previous.token().to_string(); + let cached_mcp = + serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) + .unwrap(); + let cached_hooks = serde_json::from_str::( + &std::fs::read_to_string(&layout.hooks_path).unwrap(), + ) + .unwrap(); install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap(); let error = previous.verify_current().unwrap_err(); assert!(error.contains("has been retired")); - InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + let current = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + assert_ne!(current.token(), previous_token); let mcp = serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) .unwrap(); assert_eq!( mcp["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION_FILE"], json!(layout.generation_fence) ); + assert_eq!( + mcp["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION"], + json!(current.token()) + ); + assert_eq!( + cached_mcp["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION"], + json!(previous_token) + ); + assert!(crate::hook_assertions::value_has_command_arguments( + &cached_hooks, + &["--generation-token", &previous_token] + )); + let current_hooks = serde_json::from_str::( + &std::fs::read_to_string(&layout.hooks_path).unwrap(), + ) + .unwrap(); + assert!(crate::hook_assertions::value_has_command_arguments( + ¤t_hooks, + &["--generation-token", current.token()] + )); + assert!(layout.generation_lock.exists()); +} + +#[cfg(windows)] +#[test] +fn force_install_reuses_the_same_windows_lock_after_install_dir_canonicalization() { + let root = tempdir().unwrap(); + let requested_install_dir = root.path().join("not-created-yet"); + let first_install_dir = requested_install_dir.clone().canonicalize_or_self(); + assert!(!first_install_dir.exists()); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + + install_host( + IntegrationHost::Codex, + &options(&first_install_dir), + &runner, + &setup_runner, + ) + .unwrap(); + let first_layout = PluginLayout::new(IntegrationHost::Codex, &first_install_dir); + let previous = InstallGeneration::capture(first_layout.generation_fence).unwrap(); + + let canonical_install_dir = requested_install_dir.canonicalize().unwrap(); + assert_ne!(first_install_dir, canonical_install_dir); + install_host( + IntegrationHost::Codex, + &PluginInstallOptions { + force: true, + ..options(&canonical_install_dir) + }, + &runner, + &setup_runner, + ) + .unwrap(); + + assert!(previous.verify_current().unwrap_err().contains("retired")); + let current_layout = PluginLayout::new(IntegrationHost::Codex, &canonical_install_dir); + assert!(current_layout.generation_lock.exists()); + InstallGeneration::capture(current_layout.generation_fence).unwrap(); +} + +#[cfg(windows)] +#[test] +fn force_install_migrates_a_legacy_sibling_lock_before_moving_the_marketplace() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let (previous_token, legacy_lock) = replace_generation_with_legacy_marker(&layout); + + install_host( + IntegrationHost::Codex, + &PluginInstallOptions { + force: true, + ..options(dir.path()) + }, + &runner, + &setup_runner, + ) + .unwrap(); + + let current = InstallGeneration::capture(layout.generation_fence).unwrap(); + assert_ne!(current.token(), previous_token); + assert!(layout.generation_lock.exists()); + assert!(!legacy_lock.exists()); +} + +#[cfg(windows)] +#[test] +fn uninstall_releases_a_legacy_sibling_lock_before_removing_the_marketplace() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let (_, legacy_lock) = replace_generation_with_legacy_marker(&layout); + + uninstall_host( + IntegrationHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!(!legacy_lock.exists()); +} + +#[cfg(windows)] +#[test] +fn legacy_force_install_rollback_restores_the_sibling_lock_without_external_residue() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("doctor codex {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let (previous_token, legacy_lock) = replace_generation_with_legacy_marker(&layout); + + let error = install_host( + IntegrationHost::Codex, + &PluginInstallOptions { + force: true, + skip_doctor: false, + ..options(dir.path()) + }, + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("doctor codex"), "{error}"); + let restored = InstallGeneration::capture(layout.generation_fence).unwrap(); + assert_eq!(restored.token(), previous_token); + assert!(legacy_lock.exists()); + assert!(!layout.generation_lock.exists()); } #[test] @@ -2175,6 +2545,10 @@ fn claude_force_install_retires_and_replaces_its_mcp_generation() { write_installed_state(IntegrationHost::ClaudeCode, dir.path()); let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + let previous_token = previous.token().to_string(); + let cached_mcp = + serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) + .unwrap(); install_host( IntegrationHost::ClaudeCode, @@ -2185,13 +2559,22 @@ fn claude_force_install_retires_and_replaces_its_mcp_generation() { .unwrap(); assert!(previous.verify_current().unwrap_err().contains("retired")); - InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + let current = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + assert_ne!(current.token(), previous_token); let mcp = serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) .unwrap(); assert_eq!( mcp["mcpServers"]["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION_FILE"], json!(layout.generation_fence) ); + assert_eq!( + mcp["mcpServers"]["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION"], + json!(current.token()) + ); + assert_eq!( + cached_mcp["mcpServers"]["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION"], + json!(previous_token) + ); assert!( setup_runner .calls() @@ -2358,7 +2741,9 @@ fn force_install_replaces_a_relocated_fenced_install_without_old_residue() { let current = PluginLayout::new(IntegrationHost::Codex, &selected_dir); assert!(current.marketplace_root.exists()); + assert!(current.generation_lock.exists()); assert!(!relocated.marketplace_root.exists()); + assert!(!relocated.generation_lock.exists()); assert!(!sentinel.exists()); assert!(previous.verify_current().unwrap_err().contains("retired")); let state = read_state(IntegrationHost::Codex, &selected_dir).unwrap(); @@ -2403,7 +2788,9 @@ fn force_install_rollback_restores_a_relocated_fenced_install_and_registration() assert!(error.contains("doctor codex"), "{error}"); let current = PluginLayout::new(IntegrationHost::Codex, &selected_dir); assert!(!current.marketplace_root.exists()); + assert!(!current.generation_lock.exists()); assert!(relocated.marketplace_root.exists()); + assert!(relocated.generation_lock.exists()); assert_eq!( std::fs::read_to_string(&sentinel).unwrap(), "restore-exactly" @@ -2502,7 +2889,7 @@ fn force_install_rejects_corrupt_generation_marker_without_mutating() { for (corruption, cause) in [ ("empty", "is empty"), ("oversized", "exceeds the 128-byte limit"), - ("unreadable", "failed to open"), + ("unreadable", "failed to"), ] { let dir = tempdir().unwrap(); let runner = MockRunner::default() @@ -2648,7 +3035,7 @@ fn force_install_commit_does_not_fail_when_backup_cleanup_errors() { replacement_promoted: true, generation_retirement: None, } - .commit(); + .commit(&dir.path().join("replacement.lock")); assert!(backup.is_file()); } @@ -2689,6 +3076,7 @@ fn force_install_keeps_existing_registration_when_gateway_refresh_fails() { setup_runner.calls(), vec![ "snapshot codex".to_string(), + format!("uninstall codex {DEFAULT_GATEWAY_URL}"), "refresh gateway".to_string(), "restore snapshot".to_string(), ] @@ -2696,39 +3084,139 @@ fn force_install_keeps_existing_registration_when_gateway_refresh_fails() { } #[test] -fn uninstall_restores_mcp_generation_when_gateway_refresh_fails() { +fn failed_force_refresh_hides_transient_generation_retirement_from_mcp() { let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - let setup_runner = MockSetupRunner { - failing_call: Some("refresh gateway".into()), - ..MockSetupRunner::default() - }; - let options = options(dir.path()); write_installed_state(IntegrationHost::Codex, dir.path()); let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + let install_dir = dir.path().to_path_buf(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (continue_tx, continue_rx) = std::sync::mpsc::channel(); - let error = - uninstall_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + let install = std::thread::spawn(move || { + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = BlockingRefreshFailure { + entered: entered_tx, + continue_refresh: continue_rx, + }; + let options = PluginInstallOptions { + force: true, + ..options(&install_dir) + }; + install_host(IntegrationHost::Codex, &options, &runner, &setup_runner) + }); - assert!(error.contains("refresh gateway failed")); - previous.verify_current().unwrap(); - assert!(layout.state_path.exists()); + entered_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + let (verified_tx, verified_rx) = std::sync::mpsc::channel(); + let verifier = std::thread::spawn(move || verified_tx.send(previous.verify_current()).unwrap()); + assert!( + verified_rx + .recv_timeout(Duration::from_millis(100)) + .is_err(), + "MCP observed the force-install retirement before refresh committed" + ); + + continue_tx.send(()).unwrap(); + let error = install.join().unwrap().unwrap_err(); + assert!(error.contains("refresh gateway failed"), "{error}"); + verified_rx + .recv_timeout(Duration::from_secs(1)) + .unwrap() + .unwrap(); + verifier.join().unwrap(); assert!(layout.plugin_root.exists()); - assert_eq!(setup_runner.calls(), vec!["refresh gateway"]); - assert!(runner.commands().is_empty()); } #[test] -fn force_install_restores_previous_install_after_doctor_failure() { +fn replacement_retirement_aggregates_refresh_and_restore_failures_without_rewriting_new_tree() { let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex") - .with_codex_registration(true, true); - let setup_runner = MockSetupRunner { + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let backup = dir.path().join("replacement-backup"); + let install_dir = dir.path().to_path_buf(); + let retirement_layout = layout.clone(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (continue_tx, continue_rx) = std::sync::mpsc::channel(); + + let retirement = std::thread::spawn(move || { + let setup_runner = BlockingRefreshFailure { + entered: entered_tx, + continue_refresh: continue_rx, + }; + retire_replacement_before_rollback( + IntegrationHost::Codex, + &retirement_layout, + &options(&install_dir), + &setup_runner, + None, + ) + }); + + entered_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + std::fs::rename(&layout.plugin_root, &backup).unwrap(); + let replacement_token = crate::install_generation::write_staged_generation_with_token( + &layout.generation_fence, + &layout.generation_lock, + ) + .unwrap(); + let replacement_marker = std::fs::read(&layout.generation_fence).unwrap(); + continue_tx.send(()).unwrap(); + + let error = match retirement.join().unwrap() { + Ok(_) => panic!("replacement retirement unexpectedly succeeded"), + Err(error) => error, + }; + assert!(error.contains("refresh gateway failed"), "{error}"); + assert!(error.contains("lock identity changed"), "{error}"); + assert_eq!( + std::fs::read(&layout.generation_fence).unwrap(), + replacement_marker + ); + assert_eq!( + InstallGeneration::capture(layout.generation_fence) + .unwrap() + .token(), + replacement_token + ); +} + +#[test] +fn uninstall_restores_mcp_generation_when_gateway_refresh_fails() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner { + failing_call: Some("refresh gateway".into()), + ..MockSetupRunner::default() + }; + let options = options(dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + + let error = + uninstall_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("refresh gateway failed")); + previous.verify_current().unwrap(); + assert!(layout.state_path.exists()); + assert!(layout.plugin_root.exists()); + assert_eq!(setup_runner.calls(), vec!["refresh gateway"]); + assert!(runner.commands().is_empty()); +} + +#[test] +fn force_install_restores_previous_install_after_doctor_failure() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner { failing_call: Some(format!("doctor codex {DEFAULT_GATEWAY_URL}")), ..MockSetupRunner::default() }; @@ -2779,7 +3267,7 @@ fn force_install_restores_previous_install_after_doctor_failure() { } #[test] -fn force_install_does_not_uninstall_restored_setup_after_setup_failure() { +fn force_install_cleans_only_the_previous_setup_after_replacement_setup_failure() { let dir = tempdir().unwrap(); let runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") @@ -2810,14 +3298,140 @@ fn force_install_does_not_uninstall_restored_setup_after_setup_failure() { .iter() .any(|call| call == "restore snapshot") ); - assert!( + assert_eq!( setup_runner .calls() .iter() - .all(|call| call != &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + .filter(|call| call.as_str() == format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + .count(), + 1, + "only the previous setup should be removed before replacement registration" + ); +} + +#[test] +fn first_install_removes_a_partially_written_marketplace() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + crate::file_io::fail_next_atomic_write(&layout.plugin_manifest); + + let error = install_host( + IntegrationHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("injected test failure"), "{error}"); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!(!layout.generation_lock.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); +} + +#[test] +fn failed_staging_removes_a_new_external_generation_lock() { + let dir = tempdir().unwrap(); + let target = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let stage_parent = dir.path().join("deterministic-stage"); + let staged = PluginLayout::new(IntegrationHost::Codex, &stage_parent); + crate::file_io::fail_next_atomic_write(&staged.mcp_config); + + let error = match stage_plugin_marketplace_at( + IntegrationHost::Codex, + Path::new("/bin/nemo-relay"), + &target, + true, + &options(dir.path()), + stage_parent.clone(), + ) { + Ok(_) => panic!("staging unexpectedly succeeded"), + Err(error) => error, + }; + + assert!(error.contains("injected test failure"), "{error}"); + assert!(!stage_parent.exists()); + assert!(!target.generation_lock.exists()); +} + +#[test] +fn failed_staging_preserves_a_preexisting_external_generation_lock() { + let dir = tempdir().unwrap(); + let target = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let orphan_marker = dir.path().join("orphan-generation"); + crate::install_generation::write_new_generation_with_token_at( + &orphan_marker, + &target.generation_lock, + ) + .unwrap(); + std::fs::remove_file(orphan_marker).unwrap(); + let original_lock = std::fs::read(&target.generation_lock).unwrap(); + let stage_parent = dir.path().join("deterministic-existing-lock-stage"); + let staged = PluginLayout::new(IntegrationHost::Codex, &stage_parent); + crate::file_io::fail_next_atomic_write(&staged.mcp_config); + + let error = match stage_plugin_marketplace_at( + IntegrationHost::Codex, + Path::new("/bin/nemo-relay"), + &target, + true, + &options(dir.path()), + stage_parent.clone(), + ) { + Ok(_) => panic!("staging unexpectedly succeeded"), + Err(error) => error, + }; + + assert!(error.contains("injected test failure"), "{error}"); + assert!(!stage_parent.exists()); + assert_eq!( + std::fs::read(target.generation_lock).unwrap(), + original_lock ); } +#[cfg(unix)] +#[test] +fn failed_staging_preserves_a_preexisting_dangling_generation_lock_symlink() { + use std::os::unix::fs::symlink; + + let dir = tempdir().unwrap(); + let target = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let symlink_target = dir.path().join("generation-lock-target"); + symlink(&symlink_target, &target.generation_lock).unwrap(); + let stage_parent = dir.path().join("deterministic-symlink-stage"); + let staged = PluginLayout::new(IntegrationHost::Codex, &stage_parent); + crate::file_io::fail_next_atomic_write(&staged.mcp_config); + + let error = match stage_plugin_marketplace_at( + IntegrationHost::Codex, + Path::new("/bin/nemo-relay"), + &target, + true, + &options(dir.path()), + stage_parent.clone(), + ) { + Ok(_) => panic!("staging unexpectedly succeeded"), + Err(error) => error, + }; + + assert!(error.contains("injected test failure"), "{error}"); + assert!(!stage_parent.exists()); + assert!( + std::fs::symlink_metadata(&target.generation_lock) + .unwrap() + .file_type() + .is_symlink() + ); + assert!(symlink_target.exists()); +} + #[test] fn install_claude_enables_provider_routing() { let dir = tempdir().unwrap(); @@ -2972,11 +3586,9 @@ fn setup_failure_rolls_back_generated_files_and_registration() { .unwrap_err(); assert!(error.contains("setup claude-code")); - assert!( - !PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()) - .marketplace_root - .exists() - ); + let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.generation_lock.exists()); assert!( runner .commands() @@ -3015,11 +3627,9 @@ fn doctor_failure_fails_install_and_rolls_back() { .unwrap_err(); assert!(error.contains("doctor claude-code")); - assert!( - !PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()) - .marketplace_root - .exists() - ); + let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.generation_lock.exists()); } #[test] @@ -3092,6 +3702,115 @@ fn plugin_registration_failure_rolls_back_marketplace_without_plugin_removal() { assert!(setup_runner.calls().is_empty()); } +#[test] +fn failed_marketplace_registration_rolls_back_observed_host_side_effects() { + let dir = tempdir().unwrap(); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration_sequence(&[(false, false), (false, true)]); + runner.failing_suffix = Some(layout.marketplace_root.display().to_string()); + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + IntegrationHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("plugin marketplace add"), "{error}"); + assert!( + runner + .commands() + .iter() + .any(|command| command.ends_with("plugin marketplace remove nemo-relay-local")) + ); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!(!layout.generation_lock.exists()); +} + +#[test] +fn failed_plugin_registration_rolls_back_observed_plugin_side_effects() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration_sequence(&[(false, false), (true, true)]); + runner.failing_suffix = Some("plugin add nemo-relay-plugin@nemo-relay-local".into()); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + + let error = install_host( + IntegrationHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("plugin add"), "{error}"); + assert!( + runner + .commands() + .iter() + .any(|command| command.ends_with("plugin remove nemo-relay-plugin@nemo-relay-local")) + ); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!(!layout.generation_lock.exists()); +} + +#[test] +fn unverifiable_registration_failure_preserves_the_install_tree() { + let dir = tempdir().unwrap(); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_suffix = Some(layout.marketplace_root.display().to_string()); + runner.capture_output_sequences.get_mut().insert( + "/bin/codex plugin list".into(), + VecDeque::from([ + CommandOutput::success(String::new()), + CommandOutput { + status: 1, + stdout: String::new(), + stderr: "registration report unavailable".into(), + }, + ]), + ); + runner.capture_output_sequences.get_mut().insert( + "/bin/codex plugin marketplace list".into(), + VecDeque::from([CommandOutput::success(String::new())]), + ); + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + IntegrationHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("refusing destructive rollback"), "{error}"); + assert!(error.contains("registration report unavailable"), "{error}"); + assert!(layout.marketplace_root.exists()); + assert!(layout.state_path.exists()); + assert!(layout.generation_lock.exists()); + InstallGeneration::capture(layout.generation_fence).unwrap(); + assert!( + runner + .commands() + .iter() + .all(|command| !command.contains("plugin marketplace remove")) + ); +} + #[test] fn invalid_existing_state_fails_before_generating_marketplace() { let dir = tempdir().unwrap(); @@ -3311,7 +4030,7 @@ fn uninstall_rejects_each_corrupt_generation_marker_actionably() { for (corruption, cause) in [ ("empty", "is empty"), ("oversized", "exceeds the 128-byte limit"), - ("unreadable", "failed to open"), + ("unreadable", "failed to"), ] { let dir = tempdir().unwrap(); let runner = MockRunner::default() @@ -3665,12 +4384,14 @@ fn readiness_report_rejects_mcp_server_for_different_binary() { let options = options(dir.path()); write_installed_state(IntegrationHost::Codex, dir.path()); let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let generation = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); write_json( &layout.mcp_config, &plugin_mcp_config( IntegrationHost::Codex, Path::new("/tmp/other-relay"), &layout.generation_fence, + generation.token(), ) .unwrap(), ) @@ -3685,6 +4406,120 @@ fn readiness_report_rejects_mcp_server_for_different_binary() { })); } +#[test] +fn readiness_report_rejects_a_stale_mcp_generation_identity() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let mut mcp = + serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) + .unwrap(); + mcp["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION"] = json!("stale-generation"); + write_json(&layout.mcp_config, &mcp).unwrap(); + + let report = + collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); + + assert!(!report.ok()); + let check = report + .checks + .iter() + .find(|check| check.name == "Generated MCP server") + .unwrap(); + assert!(!check.ok); + assert!(check.details.contains("unexpected MCP server manifest")); + assert!(check.details.contains("nemo-relay install codex --force")); +} + +#[test] +fn generated_codex_mcp_check_allows_previously_captured_environment_names() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".mcp.json"); + let expected = json!({ + "nemo-relay": { + "command": "/bin/nemo-relay", + "args": ["mcp"], + "env_vars": ["OPENAI_API_KEY"] + } + }); + let mut installed = expected.clone(); + installed["nemo-relay"]["env_vars"] + .as_array_mut() + .unwrap() + .push(json!("NEMO_RELAY_PREVIOUSLY_DEFINED")); + write_json(&path, &installed).unwrap(); + + let result = generated_mcp_config_check(IntegrationHost::Codex, &path, &expected); + + assert_eq!(result.unwrap(), format!("valid at {}", path.display())); +} + +#[test] +fn generated_codex_mcp_check_accepts_a_windows_allowlist_with_a_historical_name() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".mcp.json"); + let expected_vars = + crate::mcp_environment::forwarded_names_for_platform(std::iter::empty(), None, true); + let expected = json!({ + "nemo-relay": { + "command": "C:\\Program Files\\NeMo Relay\\nemo-relay.exe", + "args": ["mcp"], + "env_vars": expected_vars + } + }); + let mut installed = expected.clone(); + installed["nemo-relay"]["env_vars"] + .as_array_mut() + .unwrap() + .push(json!("NEMO_RELAY_PREVIOUSLY_DEFINED")); + write_json(&path, &installed).unwrap(); + + let result = + generated_mcp_config_check_for_platform(IntegrationHost::Codex, &path, &expected, true); + + assert_eq!(result.unwrap(), format!("valid at {}", path.display())); +} + +#[test] +fn generated_codex_mcp_check_rejects_malformed_or_unapproved_environment_supersets() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".mcp.json"); + let expected = json!({ + "nemo-relay": { + "command": "/bin/nemo-relay", + "args": ["mcp"], + "env_vars": ["OPENAI_API_KEY"] + } + }); + + for invalid in [ + json!({"not": "a name"}), + json!("NEMO_RELAY_WORKER_TOKEN"), + json!("UNRELATED_SECRET"), + json!("OPENAI_API_KEY"), + ] { + let mut installed = expected.clone(); + installed["nemo-relay"]["env_vars"] + .as_array_mut() + .unwrap() + .push(invalid); + write_json(&path, &installed).unwrap(); + + let error = generated_mcp_config_check(IntegrationHost::Codex, &path, &expected) + .expect_err("invalid environment superset passed doctor validation"); + assert!(error.contains("unexpected MCP server manifest"), "{error}"); + assert!( + error.contains("nemo-relay install codex --force"), + "{error}" + ); + } +} + #[test] fn readiness_report_names_newly_required_mcp_env_vars_and_force_remediation() { let dir = tempdir().unwrap(); @@ -4019,6 +4854,51 @@ fn uninstall_cleans_up_plugin_setup_before_host_removal_failure() { ); } +#[test] +fn force_install_recovers_from_a_generation_retired_by_partial_uninstall() { + let dir = tempdir().unwrap(); + let mut failing_runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + failing_runner.failing_suffix = Some("plugin remove nemo-relay-plugin@nemo-relay-local".into()); + let setup_runner = MockSetupRunner::default(); + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + + let error = uninstall_host( + IntegrationHost::Codex, + &options(dir.path()), + &failing_runner, + &setup_runner, + ) + .unwrap_err(); + assert!(error.contains("plugin remove"), "{error}"); + assert!( + std::fs::read_to_string(&layout.generation_fence) + .unwrap() + .starts_with("retired:") + ); + + let retry_runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + install_host( + IntegrationHost::Codex, + &PluginInstallOptions { + force: true, + ..options(dir.path()) + }, + &retry_runner, + &setup_runner, + ) + .unwrap(); + + InstallGeneration::capture(layout.generation_fence).unwrap(); + assert!(layout.generation_lock.exists()); +} + #[test] fn uninstall_does_not_unregister_host_when_plugin_cleanup_fails() { let dir = tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs index eac8e24ed..846aa366c 100644 --- a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs @@ -983,6 +983,550 @@ fn runtime_directory_collection_rejects_before_exceeding_bounded_sort_capacity() ); } +#[test] +fn activation_snapshot_budgets_reject_entry_and_byte_overflow() { + let path = Path::new("fixture"); + let mut entry_budget = SnapshotBudget::default(); + let entry_error = entry_budget + .record_entries(path, MAX_SNAPSHOT_FILES + 1) + .unwrap_err() + .to_string(); + assert!( + entry_error.contains("entry activation snapshot budget"), + "{entry_error}" + ); + + let mut byte_budget = SnapshotBudget::default(); + let byte_error = byte_budget + .record_bytes( + path, + usize::try_from(crate::config::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES).unwrap() + 1, + ) + .unwrap_err() + .to_string(); + assert!( + byte_error.contains("byte activation snapshot budget"), + "{byte_error}" + ); + + let mut closure = RuntimeClosureSources { + entries: MAX_SNAPSHOT_FILES, + ..RuntimeClosureSources::default() + }; + let closure_error = closure.record_entry().unwrap_err().to_string(); + assert!( + closure_error.contains("entry activation snapshot budget"), + "{closure_error}" + ); +} + +#[test] +fn activation_snapshot_rejects_identity_mismatch_missing_files_and_policy_denial() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugin"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest = write_dynamic_manifest(&plugin_dir, "acme.snapshot-contracts"); + + let identity_error = DynamicPluginActivationSnapshot::create( + manifest.to_string_lossy().as_ref(), + "acme.other-plugin", + DynamicPluginKind::Worker, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + assert!( + identity_error.contains("identity changed"), + "{identity_error}" + ); + + let contents = std::fs::read_to_string(&manifest).unwrap(); + std::fs::write( + &manifest, + contents.replace("entrypoint = \"plugin.py\"", "entrypoint = \"other.py\""), + ) + .unwrap(); + std::fs::write(plugin_dir.join("other.py"), b"print('other')\n").unwrap(); + let entrypoint_error = DynamicPluginActivationSnapshot::create( + manifest.to_string_lossy().as_ref(), + "acme.snapshot-contracts", + DynamicPluginKind::Worker, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + assert!( + entrypoint_error.contains("integrity-checked source.artifact"), + "{entrypoint_error}" + ); + let closure_error = + dynamic_plugin_runtime_closure_digest(manifest.to_string_lossy().as_ref(), None) + .unwrap_err() + .to_string(); + assert!( + closure_error.contains("integrity-checked source.artifact"), + "{closure_error}" + ); + + std::fs::write(&manifest, contents).unwrap(); + let blocked = crate::plugins::policy::DynamicPluginHostPolicy { + defaults: crate::plugins::policy::DynamicPluginHostPolicyEffect { + allowed: Some(false), + ..crate::plugins::policy::DynamicPluginHostPolicyEffect::default() + }, + ..crate::plugins::policy::DynamicPluginHostPolicy::default() + }; + let policy_error = DynamicPluginActivationSnapshot::create( + manifest.to_string_lossy().as_ref(), + "acme.snapshot-contracts", + DynamicPluginKind::Worker, + None, + &blocked, + ) + .unwrap_err() + .to_string(); + assert!( + policy_error.contains("violates host policy"), + "{policy_error}" + ); + + std::fs::remove_file(plugin_dir.join("plugin.py")).unwrap(); + let missing_error = DynamicPluginActivationSnapshot::create( + manifest.to_string_lossy().as_ref(), + "acme.snapshot-contracts", + DynamicPluginKind::Worker, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + assert!( + missing_error.contains("failed to normalize"), + "{missing_error}" + ); +} + +#[test] +fn activation_snapshot_copies_declared_signature_into_stable_identity() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugin"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest = write_dynamic_manifest_with_options( + &plugin_dir, + "acme.signed-snapshot", + &["plugin_worker"], + Some("plugin.py.sig"), + ); + std::fs::write(plugin_dir.join("plugin.py.sig"), b"fixture signature\n").unwrap(); + + let snapshot = DynamicPluginActivationSnapshot::create( + manifest.to_string_lossy().as_ref(), + "acme.signed-snapshot", + DynamicPluginKind::Worker, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap(); + + let signature_logical = snapshot + .identity_files + .keys() + .find(|path| path.ends_with("plugin.py.sig")) + .cloned() + .expect("signature is part of the stable snapshot identity"); + assert!(snapshot.identity_file(&signature_logical).is_some()); + assert_eq!( + snapshot.closure_digest(), + dynamic_plugin_runtime_closure_digest(manifest.to_string_lossy().as_ref(), None).unwrap() + ); +} + +#[test] +fn python_snapshot_contract_requires_environment_and_trusted_source_digest() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugin"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest = write_python_dynamic_manifest(&plugin_dir, "acme.python-contract"); + + let snapshot_error = DynamicPluginActivationSnapshot::create( + manifest.to_string_lossy().as_ref(), + "acme.python-contract", + DynamicPluginKind::Worker, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + assert!( + snapshot_error.contains("no managed environment"), + "{snapshot_error}" + ); + let closure_error = + dynamic_plugin_runtime_closure_digest(manifest.to_string_lossy().as_ref(), None) + .unwrap_err() + .to_string(); + assert!( + closure_error.contains("no managed environment"), + "{closure_error}" + ); + + let contents = std::fs::read_to_string(&manifest).unwrap(); + let integrity = contents + .find("[integrity]") + .expect("fixture has integrity section"); + let load = contents.find("[load]").expect("fixture has load section"); + std::fs::write( + &manifest, + format!("{}{}", &contents[..integrity], &contents[load..]), + ) + .unwrap(); + let digest_error = dynamic_plugin_runtime_closure_digest( + manifest.to_string_lossy().as_ref(), + Some(temp.path().join("environment").to_string_lossy().as_ref()), + ) + .unwrap_err() + .to_string(); + assert!( + digest_error.contains("requires integrity.sha256"), + "{digest_error}" + ); +} + +#[cfg(unix)] +#[test] +fn snapshot_directory_copy_preserves_python_launcher_and_rejects_special_entries() { + use std::ffi::CString; + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("environment"); + let bin = source.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let interpreter = temp.path().join("managed-python"); + std::fs::write(&interpreter, b"python").unwrap(); + symlink(&interpreter, bin.join("python3.11")).unwrap(); + let destination = temp.path().join("snapshot"); + let mut copied = HashMap::new(); + let mut budget = SnapshotBudget::default(); + + copy_snapshot_directory( + &source, + &destination, + &mut copied, + &mut budget, + false, + &mut Vec::new(), + ) + .unwrap(); + + assert!( + std::fs::symlink_metadata(destination.join("bin/python3.11")) + .unwrap() + .file_type() + .is_symlink() + ); + assert!(!is_python_venv_launcher(Path::new("/"))); + + let fifo_source = temp.path().join("fifo-source"); + std::fs::create_dir(&fifo_source).unwrap(); + let fifo = fifo_source.join("worker.pipe"); + let fifo_c = CString::new(fifo.as_os_str().as_encoded_bytes()).unwrap(); + // SAFETY: `fifo_c` is a valid NUL-terminated path and the mode contains only permission bits. + assert_eq!(unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) }, 0); + let special_error = copy_snapshot_directory( + &fifo_source, + &temp.path().join("fifo-snapshot"), + &mut HashMap::new(), + &mut SnapshotBudget::default(), + false, + &mut Vec::new(), + ) + .unwrap_err() + .to_string(); + assert!( + special_error.contains("regular file or directory"), + "{special_error}" + ); + + let regular = temp.path().join("regular"); + std::fs::write(®ular, b"regular").unwrap(); + let destination_directory = temp.path().join("destination-directory"); + std::fs::create_dir(&destination_directory).unwrap(); + let write_error = copy_snapshot_regular_file( + ®ular, + &destination_directory, + &mut HashMap::new(), + &mut SnapshotBudget::default(), + "fixture", + ) + .unwrap_err() + .to_string(); + assert!(write_error.contains("failed to write dynamic plugin snapshot file")); +} + +#[cfg(unix)] +#[test] +fn snapshot_directory_walk_rejects_missing_cycles_dangling_links_and_depth() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing"); + let normalization_error = copy_snapshot_directory( + &missing, + &temp.path().join("destination"), + &mut HashMap::new(), + &mut SnapshotBudget::default(), + false, + &mut Vec::new(), + ) + .unwrap_err() + .to_string(); + assert!( + normalization_error.contains("failed to normalize"), + "{normalization_error}" + ); + + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + let canonical = source.canonicalize().unwrap(); + let cycle_error = copy_snapshot_directory_contents( + &source, + &temp.path().join("cycle-destination"), + &mut HashMap::new(), + &mut SnapshotBudget::default(), + false, + &mut vec![canonical.clone()], + ) + .unwrap_err() + .to_string(); + assert!(cycle_error.contains("symlink cycle"), "{cycle_error}"); + + let destination_file = temp.path().join("destination-file"); + std::fs::write(&destination_file, b"file").unwrap(); + let destination_error = copy_snapshot_directory_contents( + &source, + &destination_file, + &mut HashMap::new(), + &mut SnapshotBudget::default(), + false, + &mut Vec::new(), + ) + .unwrap_err() + .to_string(); + assert!( + destination_error.contains("failed to create"), + "{destination_error}" + ); + + symlink(temp.path().join("absent-target"), source.join("dangling")).unwrap(); + let dangling_error = copy_snapshot_directory( + &source, + &temp.path().join("dangling-destination"), + &mut HashMap::new(), + &mut SnapshotBudget::default(), + false, + &mut Vec::new(), + ) + .unwrap_err() + .to_string(); + assert!( + dangling_error.contains("failed to resolve"), + "{dangling_error}" + ); + + let closure_cycle = collect_runtime_closure_directory_contents( + &source, + Path::new("runtime"), + false, + &mut vec![canonical], + &mut RuntimeClosureSources::default(), + ) + .unwrap_err() + .to_string(); + assert!(closure_cycle.contains("symlink cycle"), "{closure_cycle}"); + + let depth_error = collect_snapshot_files( + &source, + &source, + &mut Vec::new(), + Some(MAX_SNAPSHOT_DEPTH), + &mut 0, + ) + .unwrap_err() + .to_string(); + assert!(depth_error.contains("traversal depth"), "{depth_error}"); +} + +#[cfg(unix)] +#[test] +fn snapshot_file_and_closure_helpers_cover_external_and_invalid_sources() { + use std::ffi::CString; + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugin"); + std::fs::create_dir(&plugin_dir).unwrap(); + let manifest = plugin_dir.join("relay-plugin.toml"); + std::fs::write(&manifest, b"fixture").unwrap(); + let root = temp.path().join("snapshot"); + std::fs::create_dir(&root).unwrap(); + + let missing_error = copy_snapshot_file( + &root, + &manifest, + "missing.bin", + "artifact", + &mut HashMap::new(), + &mut SnapshotBudget::default(), + ) + .unwrap_err() + .to_string(); + assert!( + missing_error.contains("failed to normalize"), + "{missing_error}" + ); + + let root_error = copy_snapshot_file( + &root, + &manifest, + "/", + "library", + &mut HashMap::new(), + &mut SnapshotBudget::default(), + ) + .unwrap_err() + .to_string(); + assert!( + root_error.contains("has no parent directory"), + "{root_error}" + ); + + let external = temp.path().join("external-artifact.bin"); + std::fs::write(&external, b"external artifact").unwrap(); + let (logical, canonical, copied) = copy_snapshot_file( + &root, + &manifest, + external.to_string_lossy().as_ref(), + "artifact", + &mut HashMap::new(), + &mut SnapshotBudget::default(), + ) + .unwrap(); + assert_eq!(logical, external); + assert_eq!(canonical, external.canonicalize().unwrap()); + assert_eq!(std::fs::read(copied).unwrap(), b"external artifact"); + + let mut closure = RuntimeClosureSources::default(); + let closure_missing = + collect_declared_runtime_closure_file(&manifest, "missing.bin", "artifact", &mut closure) + .unwrap_err() + .to_string(); + assert!( + closure_missing.contains("failed to normalize"), + "{closure_missing}" + ); + let closure_root = + collect_declared_runtime_closure_file(&manifest, "/", "library", &mut closure) + .unwrap_err() + .to_string(); + assert!( + closure_root.contains("has no parent directory"), + "{closure_root}" + ); + collect_declared_runtime_closure_file( + &manifest, + external.to_string_lossy().as_ref(), + "artifact", + &mut closure, + ) + .unwrap(); + assert!( + closure + .files + .keys() + .any(|path| path.ends_with("external-artifact.bin")) + ); + + let missing_directory_error = collect_runtime_closure_directory_contents( + &temp.path().join("missing-directory"), + Path::new("runtime"), + false, + &mut Vec::new(), + &mut RuntimeClosureSources::default(), + ) + .unwrap_err() + .to_string(); + assert!( + missing_directory_error.contains("failed to normalize"), + "{missing_directory_error}" + ); + + let walk = temp.path().join("walk"); + std::fs::create_dir(&walk).unwrap(); + std::fs::create_dir(walk.join("__pycache__")).unwrap(); + std::fs::write(walk.join("cached.pyc"), b"cache").unwrap(); + std::fs::write(walk.join("module.py"), b"module").unwrap(); + let mut skipped = RuntimeClosureSources::default(); + collect_runtime_closure_directory( + &walk, + Path::new("runtime"), + true, + &mut Vec::new(), + &mut skipped, + ) + .unwrap(); + assert_eq!(skipped.files.len(), 1); + assert!(skipped.files.contains_key(Path::new("runtime/module.py"))); + + symlink(temp.path().join("absent"), walk.join("dangling")).unwrap(); + let dangling_error = collect_runtime_closure_directory( + &walk, + Path::new("runtime"), + false, + &mut Vec::new(), + &mut RuntimeClosureSources::default(), + ) + .unwrap_err() + .to_string(); + assert!( + dangling_error.contains("failed to resolve"), + "{dangling_error}" + ); + std::fs::remove_file(walk.join("dangling")).unwrap(); + + let fifo = walk.join("worker.pipe"); + let fifo_c = CString::new(fifo.as_os_str().as_encoded_bytes()).unwrap(); + // SAFETY: `fifo_c` is a valid NUL-terminated path and the mode contains only permission bits. + assert_eq!(unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) }, 0); + let fifo_error = collect_runtime_closure_directory( + &walk, + Path::new("runtime"), + false, + &mut Vec::new(), + &mut RuntimeClosureSources::default(), + ) + .unwrap_err() + .to_string(); + assert!( + fifo_error.contains("regular file or directory"), + "{fifo_error}" + ); + + let one_file = temp.path().join("one-file"); + std::fs::create_dir(&one_file).unwrap(); + std::fs::write(one_file.join("entry"), b"entry").unwrap(); + let mut entries = MAX_SNAPSHOT_FILES; + let entry_error = + collect_snapshot_files(&one_file, &one_file, &mut Vec::new(), None, &mut entries) + .unwrap_err() + .to_string(); + assert!(entry_error.contains("verification budget"), "{entry_error}"); + + make_snapshot_removable(&temp.path().join("already-removed")); +} + #[test] fn python_environment_entry_budget_counts_skipped_cache_entries() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index 5604b07b1..46a2d7d7f 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -392,6 +392,7 @@ async fn healthz_rejects_a_different_persistent_gateway_fingerprint() { test_config(), Some("expected-fingerprint".into()), Some(BootstrapChallengeKey::from_bytes(b"test challenge key")), + false, None, )); let response = app @@ -423,6 +424,7 @@ async fn managed_sidecar_requires_private_client_proof_for_forwarded_credentials test_config(), Some("expected-fingerprint".into()), Some(key.clone()), + true, None, ); let mut headers = HeaderMap::new(); @@ -440,6 +442,15 @@ async fn managed_sidecar_requires_private_client_proof_for_forwarded_credentials let foreground = AppState::new(test_config()); assert!(foreground.allows_environment_provider_auth(&HeaderMap::new())); + + let transparent = AppState::new_with_bootstrap( + test_config(), + Some("transparent-fingerprint".into()), + Some(BootstrapChallengeKey::from_bytes(b"test challenge key")), + false, + None, + ); + assert!(transparent.allows_environment_provider_auth(&HeaderMap::new())); } #[tokio::test] @@ -449,6 +460,7 @@ async fn healthz_only_refreshes_idle_activity_for_an_authenticated_heartbeat() { test_config(), Some("expected-fingerprint".into()), Some(challenge_key.clone()), + true, None, ); let activity = state.last_activity.clone(); @@ -512,6 +524,7 @@ async fn bootstrap_shutdown_requires_the_private_owner_token() { test_config(), None, None, + false, Some(BootstrapShutdown { token: "private-token".into(), sender: Arc::new(std::sync::Mutex::new(Some(sender))), diff --git a/crates/cli/tests/coverage/sidecar_state_tests.rs b/crates/cli/tests/coverage/sidecar_state_tests.rs index 5b7a2f15a..dd5f7d2d7 100644 --- a/crates/cli/tests/coverage/sidecar_state_tests.rs +++ b/crates/cli/tests/coverage/sidecar_state_tests.rs @@ -17,14 +17,19 @@ fn endpoint_leases_reset_recovery_only_after_the_last_client_closes() { let runtime = tempfile::tempdir().unwrap(); let url = "http://127.0.0.1:47632"; let first = EndpointLease::acquire(runtime.path(), url).unwrap(); + let cohort = first.cohort_id().to_string(); assert!(first.fresh_epoch()); - let mut epoch = RecoveryEpoch::new(url, "gateway-1"); + let mut epoch = RecoveryEpoch::new(url, &cohort, "gateway-1"); epoch.restarts = 1; write_recovery_epoch(runtime.path(), &epoch).unwrap(); let second = EndpointLease::acquire(runtime.path(), url).unwrap(); assert!(!second.fresh_epoch()); - assert!(read_recovery_epoch(runtime.path(), url).unwrap().is_some()); + assert!( + read_recovery_epoch(runtime.path(), url, &cohort) + .unwrap() + .is_some() + ); drop(first); let third = EndpointLease::acquire(runtime.path(), url).unwrap(); @@ -34,7 +39,186 @@ fn endpoint_leases_reset_recovery_only_after_the_last_client_closes() { let next_epoch = EndpointLease::acquire(runtime.path(), url).unwrap(); assert!(next_epoch.fresh_epoch()); - assert!(read_recovery_epoch(runtime.path(), url).unwrap().is_none()); + assert!( + read_recovery_epoch(runtime.path(), url, next_epoch.cohort_id()) + .unwrap() + .is_none() + ); +} + +#[test] +fn endpoint_lease_release_is_serialized_with_registry_handoffs() { + let runtime = tempfile::tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + let lease = EndpointLease::acquire(runtime.path(), url).unwrap(); + let lease_path = lease.path.clone(); + let registry = lock_file_for( + &lease_registry_path(runtime.path(), url), + Duration::from_secs(1), + ) + .unwrap(); + let (dropping, started) = std::sync::mpsc::channel(); + let release = std::thread::spawn(move || { + dropping.send(()).unwrap(); + drop(lease); + }); + started.recv().unwrap(); + std::thread::sleep(Duration::from_millis(50)); + + let still_locked = OpenOptions::new() + .read(true) + .write(true) + .open(&lease_path) + .unwrap(); + assert_eq!( + try_lock_exclusive(&still_locked).unwrap(), + LockAttempt::Contended + ); + + drop(registry); + release.join().unwrap(); + assert!(!lease_path.exists()); +} + +#[test] +fn intentional_plugin_stop_starts_a_fresh_cohort_despite_old_live_leases() { + let dir = tempdir().unwrap(); + let environment = Environment::isolated(); + environment.set("XDG_CONFIG_HOME", dir.path()); + environment.set("HOME", dir.path()); + environment.remove("USERPROFILE"); + let runtime = state_dir().unwrap(); + let url = crate::sidecar::DEFAULT_URL; + let old = EndpointLease::acquire(&runtime, url).unwrap(); + let old_cohort = old.cohort_id().to_string(); + let mut exhausted = RecoveryEpoch::new(url, &old_cohort, "gateway-old"); + exhausted.restarts = 1; + write_recovery_epoch(&runtime, &exhausted).unwrap(); + + stop_owned_and_reset(url).unwrap(); + + let new = EndpointLease::acquire(&runtime, url).unwrap(); + assert!(new.fresh_epoch()); + assert_ne!(new.cohort_id(), old_cohort); + assert!( + read_recovery_epoch(&runtime, url, new.cohort_id()) + .unwrap() + .is_none() + ); +} + +#[test] +fn failed_plugin_stop_restores_the_previous_cohort_and_recovery_budget() { + let dir = tempdir().unwrap(); + let environment = Environment::isolated(); + environment.set("XDG_CONFIG_HOME", dir.path()); + environment.set("HOME", dir.path()); + environment.remove("USERPROFILE"); + let runtime = state_dir().unwrap(); + let url = "http://127.0.0.1:9"; + let old = EndpointLease::acquire(&runtime, url).unwrap(); + let old_cohort = old.cohort_id().to_string(); + let mut exhausted = RecoveryEpoch::new(url, &old_cohort, "gateway-old"); + exhausted.restarts = 1; + write_recovery_epoch(&runtime, &exhausted).unwrap(); + write_owner(&owner_path(&runtime, url), 42, url, "", Some("fingerprint")).unwrap(); + + let error = stop_owned_and_reset(url).unwrap_err(); + assert!(error.contains("has no shutdown token"), "{error}"); + + let overlapping = EndpointLease::acquire(&runtime, url).unwrap(); + assert!(!overlapping.fresh_epoch()); + assert_eq!(overlapping.cohort_id(), old_cohort); + assert_eq!( + read_recovery_epoch(&runtime, url, &old_cohort) + .unwrap() + .unwrap() + .restarts, + 1 + ); +} + +#[test] +fn failed_plugin_stop_hides_transient_retirement_from_heartbeat_validation() { + let dir = tempdir().unwrap(); + let environment = Environment::isolated(); + environment.set("XDG_CONFIG_HOME", dir.path()); + environment.set("HOME", dir.path()); + environment.remove("USERPROFILE"); + let runtime = state_dir().unwrap(); + let url = "http://127.0.0.1:9"; + let old = EndpointLease::acquire(&runtime, url).unwrap(); + let old_cohort = old.cohort_id().to_string(); + write_owner(&owner_path(&runtime, url), 42, url, "", Some("fingerprint")).unwrap(); + let (rotated_tx, rotated_rx) = std::sync::mpsc::channel(); + let (continue_tx, continue_rx) = std::sync::mpsc::channel(); + + let stop = std::thread::spawn(move || { + stop_owned_and_reset_after_rotation(url, || { + rotated_tx.send(()).unwrap(); + continue_rx.recv().unwrap(); + }) + }); + rotated_rx.recv().unwrap(); + let (validated_tx, validated_rx) = std::sync::mpsc::channel(); + let validate_cohort = old_cohort.clone(); + let validation = std::thread::spawn(move || { + validated_tx + .send(crate::sidecar::validate_gateway_cohort( + url, + &validate_cohort, + )) + .unwrap(); + }); + + assert!( + validated_rx + .recv_timeout(std::time::Duration::from_millis(100)) + .is_err(), + "heartbeat validation observed the uncommitted cohort rotation" + ); + continue_tx.send(()).unwrap(); + let error = stop.join().unwrap().unwrap_err(); + assert!(error.contains("has no shutdown token"), "{error}"); + validated_rx.recv().unwrap().unwrap(); + validation.join().unwrap(); +} + +#[test] +fn endpoint_cohort_lock_pins_hook_delivery_across_cross_host_replacement() { + let runtime = tempfile::tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + let lease = EndpointLease::acquire(runtime.path(), url).unwrap(); + let cohort = lease.cohort_id().to_string(); + let delivery_guard = lock_endpoint_shared(runtime.path(), url).unwrap(); + let concurrent_delivery_guard = lock_endpoint_shared(runtime.path(), url).unwrap(); + validate_recovery_cohort(runtime.path(), url, &cohort).unwrap(); + + let runtime_path = runtime.path().to_path_buf(); + let (replaced_tx, replaced_rx) = std::sync::mpsc::channel(); + let replacement = std::thread::spawn(move || { + let _lock = lock_endpoint(&runtime_path, url).unwrap(); + write_recovery_cohort(&runtime_path, &RecoveryCohort::new(url)).unwrap(); + replaced_tx.send(()).unwrap(); + }); + assert!( + replaced_rx + .recv_timeout(Duration::from_millis(100)) + .is_err(), + "a different host replaced the gateway while hook delivery was pinned" + ); + + drop(delivery_guard); + assert!( + replaced_rx + .recv_timeout(Duration::from_millis(100)) + .is_err(), + "replacement ignored a second concurrent hook delivery" + ); + drop(concurrent_delivery_guard); + replaced_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + replacement.join().unwrap(); + assert!(validate_recovery_cohort(runtime.path(), url, &cohort).is_err()); } #[test] @@ -48,6 +232,7 @@ fn recovery_epoch_rejects_identity_and_restart_count_drift() { "service": "foreign", "bootstrap_protocol": BOOTSTRAP_PROTOCOL_VERSION, "url": url, + "cohort_id": "cohort-1", "instance_id": "gateway-1", "restarts": 0, "pending": false, @@ -56,16 +241,16 @@ fn recovery_epoch_rejects_identity_and_restart_count_drift() { ) .unwrap(); assert!( - read_recovery_epoch(runtime.path(), url) + read_recovery_epoch(runtime.path(), url, "cohort-1") .unwrap_err() .contains("incompatible") ); - let mut epoch = RecoveryEpoch::new(url, "gateway-1"); + let mut epoch = RecoveryEpoch::new(url, "cohort-1", "gateway-1"); epoch.restarts = 2; write_recovery_epoch(runtime.path(), &epoch).unwrap(); assert!( - read_recovery_epoch(runtime.path(), url) + read_recovery_epoch(runtime.path(), url, "cohort-1") .unwrap_err() .contains("incompatible") ); diff --git a/crates/cli/tests/coverage/sidecar_tests.rs b/crates/cli/tests/coverage/sidecar_tests.rs index a89d5869b..2e6197ce7 100644 --- a/crates/cli/tests/coverage/sidecar_tests.rs +++ b/crates/cli/tests/coverage/sidecar_tests.rs @@ -39,19 +39,19 @@ impl Drop for EnvVarRestore { #[test] fn gateway_spec_is_the_complete_compatibility_contract() { let bind = "127.0.0.1:47632".parse().unwrap(); - let first = GatewaySpec::new(CodingAgent::Codex, bind) + let first = GatewaySpec::new(bind) .with_launch_args(vec![ OsString::from("--openai-base-url"), OsString::from("mock"), ]) .with_fingerprint("fingerprint-a"); - let same = GatewaySpec::new(CodingAgent::Codex, bind) + let same = GatewaySpec::new(bind) .with_launch_args(vec![ OsString::from("--openai-base-url"), OsString::from("mock"), ]) .with_fingerprint("fingerprint-a"); - let different = GatewaySpec::new(CodingAgent::Codex, bind) + let different = GatewaySpec::new(bind) .with_launch_args(vec![ OsString::from("--openai-base-url"), OsString::from("other"), @@ -67,29 +67,66 @@ fn gateway_spec_is_the_complete_compatibility_contract() { fn endpoint_recovery_epoch_rejects_a_staggered_second_replacement() { let state = tempfile::tempdir().unwrap(); let url = "http://127.0.0.1:47632"; - write_recovery_epoch(state.path(), &RecoveryEpoch::new(url, "gateway-1")).unwrap(); + let cohort = "cohort-1"; + write_recovery_epoch(state.path(), &RecoveryEpoch::new(url, cohort, "gateway-1")).unwrap(); - reconcile_gateway_epoch(state.path(), url, false, "gateway-2").unwrap(); - let recovered = read_recovery_epoch(state.path(), url).unwrap().unwrap(); + reconcile_gateway_epoch(state.path(), url, cohort, false, "gateway-2").unwrap(); + let recovered = read_recovery_epoch(state.path(), url, cohort) + .unwrap() + .unwrap(); assert_eq!(recovered.instance_id, "gateway-2"); assert_eq!(recovered.restarts, 1); - let error = reconcile_gateway_epoch(state.path(), url, false, "gateway-3").unwrap_err(); + let error = reconcile_gateway_epoch(state.path(), url, cohort, false, "gateway-3").unwrap_err(); assert!(error.contains("replaced again"), "{error}"); - let unchanged = read_recovery_epoch(state.path(), url).unwrap().unwrap(); + let unchanged = read_recovery_epoch(state.path(), url, cohort) + .unwrap() + .unwrap(); assert_eq!(unchanged.instance_id, "gateway-2"); assert_eq!(unchanged.restarts, 1); } #[test] fn gateway_spec_rejects_non_loopback_bind_before_launch() { - let error = GatewaySpec::new(CodingAgent::Codex, "0.0.0.0:47632".parse().unwrap()) - .ensure() - .unwrap_err(); + let error = GatewaySpec::new("0.0.0.0:47632".parse().unwrap()) + .acquire() + .err() + .expect("non-loopback gateway unexpectedly acquired"); assert!(error.contains("require a loopback bind address"), "{error}"); } +#[test] +fn fixed_acquisition_revalidates_a_captured_cohort_before_launch() { + let _environment = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let state = tempfile::tempdir().unwrap(); + let runtime = state.path().join("runtime"); + let _state = EnvVarRestore::set(BOOTSTRAP_STATE_DIR_ENV, state.path().to_str().unwrap()); + let _runtime = EnvVarRestore::set("XDG_RUNTIME_DIR", runtime.to_str().unwrap()); + let missing_binary = state.path().join("must-not-be-launched"); + let _binary = EnvVarRestore::set("NEMO_RELAY_PLUGIN_BINARY", missing_binary.to_str().unwrap()); + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = probe.local_addr().unwrap(); + drop(probe); + let url = format!("http://{address}"); + let stale = EndpointLease::acquire(state.path(), &url).unwrap(); + + stop_owned_sidecar_and_reset(&url).unwrap(); + let error = GatewaySpec::new(address) + .acquire_with_lease(stale) + .err() + .expect("a retired cohort unexpectedly acquired the endpoint"); + + assert!( + error.contains("retired by an integration update"), + "{error}" + ); + assert!(!error.contains("NEMO_RELAY_PLUGIN_BINARY"), "{error}"); + TcpListener::bind(address).expect("retired acquisition unexpectedly started a listener"); +} + #[test] fn typed_owner_record_round_trips_and_rejects_identity_drift() { let dir = tempfile::tempdir().unwrap(); @@ -219,13 +256,8 @@ fn direct_sidecar_start_classifies_existing_listeners_before_spawning() { BOOTSTRAP_PROTOCOL_VERSION ); let (address, server) = one_health_response(compatible_body); - let endpoint = start_sidecar_bind( - &GatewaySpec::new(CodingAgent::Codex, address), - dir.path(), - dir.path(), - None, - ) - .unwrap(); + let endpoint = + start_sidecar_bind(&GatewaySpec::new(address), dir.path(), dir.path(), None).unwrap(); assert_eq!(endpoint.address, address); server.join().unwrap(); @@ -234,24 +266,14 @@ fn direct_sidecar_start_classifies_existing_listeners_before_spawning() { BOOTSTRAP_PROTOCOL_VERSION ); let (address, server) = one_health_response(incompatible_body); - let error = start_sidecar_bind( - &GatewaySpec::new(CodingAgent::Codex, address), - dir.path(), - dir.path(), - None, - ) - .unwrap_err(); + let error = + start_sidecar_bind(&GatewaySpec::new(address), dir.path(), dir.path(), None).unwrap_err(); assert!(error.contains("different version"), "{error}"); server.join().unwrap(); let (address, server) = one_health_response("{}".into()); - let error = start_sidecar_bind( - &GatewaySpec::new(CodingAgent::Codex, address), - dir.path(), - dir.path(), - None, - ) - .unwrap_err(); + let error = + start_sidecar_bind(&GatewaySpec::new(address), dir.path(), dir.path(), None).unwrap_err(); assert!(error.contains("not a compatible NeMo Relay"), "{error}"); server.join().unwrap(); } @@ -373,9 +395,10 @@ fn gateway_start_reports_an_uncreatable_runtime_directory() { let _config = EnvVarRestore::set("XDG_CONFIG_HOME", dir.path().to_str().unwrap()); let bind = unused_loopback_address(); - let error = GatewaySpec::new(CodingAgent::Codex, bind) - .ensure() - .unwrap_err(); + let error = GatewaySpec::new(bind) + .acquire() + .err() + .expect("gateway unexpectedly acquired with an invalid runtime directory"); assert!(error.contains("failed to create"), "{error}"); assert!(error.contains(&runtime.display().to_string()), "{error}"); @@ -397,9 +420,10 @@ fn gateway_start_reports_an_uncreatable_state_directory() { std::fs::write(&state, "file").unwrap(); let bind = unused_loopback_address(); - let error = GatewaySpec::new(CodingAgent::Codex, bind) - .ensure() - .unwrap_err(); + let error = GatewaySpec::new(bind) + .acquire() + .err() + .expect("gateway unexpectedly acquired with an invalid state directory"); assert!(error.contains("failed to create"), "{error}"); assert!(error.contains(&state.display().to_string()), "{error}"); @@ -421,9 +445,10 @@ fn gateway_start_reports_an_unopenable_endpoint_lock() { let endpoint_lock = sidecar_lock_path(&state, &url); std::fs::create_dir_all(&endpoint_lock).unwrap(); - let error = GatewaySpec::new(CodingAgent::Codex, bind) - .ensure() - .unwrap_err(); + let error = GatewaySpec::new(bind) + .acquire() + .err() + .expect("gateway unexpectedly acquired with an invalid endpoint lock"); assert!(error.contains("failed to open sidecar lock"), "{error}"); assert!( @@ -431,3 +456,32 @@ fn gateway_start_reports_an_unopenable_endpoint_lock() { "{error}" ); } + +#[cfg(windows)] +#[test] +fn windows_handle_inheritance_suppression_is_scoped() { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::{ + GetHandleInformation, HANDLE_FLAG_INHERIT, SetHandleInformation, + }; + + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::File::create(dir.path().join("captured-output.log")).unwrap(); + let handle = file.as_raw_handle().cast(); + // SAFETY: `handle` is a live file handle uniquely owned by this test. + assert_ne!( + unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) }, + 0 + ); + { + let _guard = super::process::HandleInheritanceGuard::suppress([handle]).unwrap(); + let mut flags = 0; + // SAFETY: `handle` remains live and `flags` is writable storage. + assert_ne!(unsafe { GetHandleInformation(handle, &mut flags) }, 0); + assert_eq!(flags & HANDLE_FLAG_INHERIT, 0); + } + let mut flags = 0; + // SAFETY: Dropping the guard restored the live handle before this query. + assert_ne!(unsafe { GetHandleInformation(handle, &mut flags) }, 0); + assert_ne!(flags & HANDLE_FLAG_INHERIT, 0); +} diff --git a/crates/cli/tests/fixtures/windows_hook_relay.rs b/crates/cli/tests/fixtures/windows_hook_relay.rs new file mode 100644 index 000000000..e8a03b5d0 --- /dev/null +++ b/crates/cli/tests/fixtures/windows_hook_relay.rs @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::OsString; +use std::io::Read; + +fn main() { + let generation = std::env::var_os("NEMO_RELAY_HOOK_GENERATION") + .expect("NEMO_RELAY_HOOK_GENERATION is required"); + let expected = vec![ + OsString::from("hook-forward"), + OsString::from("codex"), + OsString::from("--gateway-url"), + OsString::from("http://127.0.0.1:47632"), + OsString::from("--generation-file"), + generation, + OsString::from("--generation-token"), + OsString::from("test-generation"), + ]; + let actual = std::env::args_os().skip(1).collect::>(); + if actual != expected { + eprintln!("unexpected hook arguments: {actual:?}"); + std::process::exit(19); + } + + if let Some(path) = std::env::var_os("NEMO_RELAY_HOOK_INPUT_MARKER") { + let mut input = Vec::new(); + std::io::stdin().read_to_end(&mut input).unwrap(); + std::fs::write(path, input).unwrap(); + } + if let Some(path) = std::env::var_os("NEMO_RELAY_HOOK_MARKER") { + std::fs::write(path, "ok\n").unwrap(); + } + if std::env::var_os("NEMO_RELAY_HOOK_EMIT_OUTPUT").is_some() { + println!("hook-stdout"); + eprintln!("hook-stderr"); + } + if let Ok(code) = std::env::var("NEMO_RELAY_HOOK_EXIT_CODE") { + std::process::exit(code.parse().unwrap()); + } +} diff --git a/docs/nemo-relay-cli/about.mdx b/docs/nemo-relay-cli/about.mdx index 70440e403..5481c5330 100644 --- a/docs/nemo-relay-cli/about.mdx +++ b/docs/nemo-relay-cli/about.mdx @@ -20,6 +20,13 @@ model traffic through the gateway, and diagnose local configuration. The `nemo-relay-cli` Cargo package remains available for source-based installation and unsupported platforms. +Persistent integrations use one lifecycle path: + +`Codex / Claude Code / Hermes Agent` → `nemo-relay mcp` → `shared Relay gateway` + +The host-specific layer installs and emits canonical lifecycle hooks. The MCP +process is host-neutral and keeps the shared user-level gateway available. + ## Start Here When Use these guides when you need to: diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index bb6279c37..b894cc412 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -54,7 +54,12 @@ under the active session scope. Use the agent shortcuts for no-install local observability. The wrapper starts a gateway on a dynamic `127.0.0.1` port, injects the resolved hook and gateway configuration into the launched coding agent, and stops the gateway when the -agent exits. +agent exits. Relay owns the launched wrapper and its descendants as one process +tree, so a gateway failure cannot leave an agent running without its private +gateway and hook configuration. Interactive Unix launches transfer foreground +terminal ownership and preserve Ctrl-C and Ctrl-Z job control. Non-interactive +Unix launches forward termination signals to the complete agent process tree +before Relay removes the injected configuration. ```bash nemo-relay codex @@ -69,6 +74,14 @@ instead of the built-in shortcut: nemo-relay run -- codex ``` +For Claude Code and Codex, transparent mode leaves the caller's source settings, +selected profile, and installed plugin state unchanged. A process marker makes +any installed Relay MCP borrow the wrapper-owned dynamic gateway. Claude's +persistent hooks exit without forwarding; Codex disables the known Relay plugin +hook identities in its process-local CLI state. Only the injected wrapper hooks +deliver events. Hermes gets the same isolation through its process-private +configuration overlay. + If a launcher or wrapper hides the real agent name, set that wrapper as the configured command and pass `--agent`. The same pattern applies to Claude Code, Codex, and Hermes: @@ -330,12 +343,14 @@ map to NeMo Relay LLM start/end events when present. Hermes `pre_llm_call` and Transparent Claude Code and Codex hooks invoke `nemo-relay hook-forward ` with the canonical payload on standard input. -The wrapper injects `NEMO_RELAY_GATEWAY_URL` for its ephemeral gateway. +The wrapper-owned hook command embeds its ephemeral gateway URL and is marked +as transparent so it cannot recover the fixed gateway. Persistent Claude Code, Codex, and Hermes hooks also use `nemo-relay hook-forward `. A persistent generated command identifies -the fixed gateway explicitly, preflights its Relay identity, and performs one -coordinated start or recovery before forwarding the unchanged payload. Hermes +the fixed gateway and private install-generation fence explicitly, preflights +its Relay identity, and joins the shared endpoint recovery cohort before +forwarding the unchanged payload. Hermes setup stores the canonical absolute command and trusts its exact event pairs; it does not enable global hook auto-acceptance. diff --git a/docs/nemo-relay-cli/claude-code.mdx b/docs/nemo-relay-cli/claude-code.mdx index 5802af847..c21a548d1 100644 --- a/docs/nemo-relay-cli/claude-code.mdx +++ b/docs/nemo-relay-cli/claude-code.mdx @@ -28,8 +28,18 @@ nemo-relay claude -- "summarize this repository" This shortcut is equivalent to `nemo-relay run -- claude`. The wrapper starts a gateway on a dynamic `127.0.0.1` port, creates a temporary Claude plugin -directory with NeMo Relay hooks, passes that plugin with `--plugin-dir`, and -sets `ANTHROPIC_BASE_URL` to the gateway URL for the launched process. +directory with NeMo Relay hooks, and passes that plugin with `--plugin-dir`. +Because Claude Code gives its first `--settings` source precedence over the +process environment, Relay also creates a private settings overlay that +preserves that source and overrides only `ANTHROPIC_BASE_URL` for the launched +process. The source settings and installed plugin enablement remain unchanged. +If a Relay plugin is already enabled, its MCP process authenticates and borrows +the dynamic gateway instead of launching the fixed sidecar, then monitors that +exact gateway while MCP stdio remains open. Its persistent hooks exit without +forwarding. Only the wrapper-owned temporary hooks deliver lifecycle payloads, +and those hooks authenticate the wrapper gateway before sending a payload, so +installed and source-marketplace plugin IDs cannot duplicate the captured +stream. Inspect what would be launched without starting Claude Code: @@ -64,8 +74,10 @@ exits after the final client's idle timeout. The MCP server advertises no tools. The generated entry sets `alwaysLoad: true`, so Claude Code 2.1.121 or newer waits for the MCP connection before session startup. The installed command -hook retains the same-gateway retry path for an unexpected outage after -startup. MCP owns the normal session-long lifecycle. +hook carries the same install-generation fence, joins the shared endpoint +recovery cohort while it delivers a payload, and retains the same-gateway retry +path for an unexpected outage after startup. MCP owns the normal session-long +lifecycle. Persistent plugin mode loads system and user Relay configuration only and uses the user configuration directory as its working directory. Use the transparent @@ -140,11 +152,11 @@ the launched process. ## Captured Events Generated Claude Code hooks include `SessionStart`, `SessionEnd`, -`SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, -`PostToolUseFailure`, `Notification`, and `PreCompact` for scope, tool, and -mark events. `UserPromptSubmit`, `AfterAgentResponse`, `AfterAgentThought`, and -`Stop` are retained as private LLM correlation hints and are not emitted as -standalone NeMo Relay events. +`UserPromptSubmit`, `UserPromptExpansion`, `PreToolUse`, `PostToolUse`, +`PostToolUseFailure`, `PermissionRequest`, `SubagentStart`, `SubagentStop`, +`Notification`, `Stop`, `PreCompact`, and `PostCompact`. Relay normalizes them +as scope, prompt, tool, mark, notification, compaction, subagent, or private LLM +correlation events according to the hook payload. Tool hooks preserve canonical fields such as `tool_use_id`, `tool_name`, `tool_input`, `error`, `duration_ms`, and `is_interrupt`. Subagent hooks use @@ -227,7 +239,7 @@ Relay plugin or the ephemeral plugin generated by `nemo-relay claude` or The standalone gateway can still observe Anthropic LLM traffic, but it cannot invent missing tool, prompt, compaction, notification, or subagent hooks. -`UserPromptSubmit`, `AfterAgentResponse`, `AfterAgentThought`, and `Stop` are -used as private correlation and turn-boundary hints. They are not exported as -standalone user-visible mark events unless they also produce a scoped turn, -tool, LLM, or lifecycle observation. +`UserPromptSubmit` contributes prompt and LLM correlation. `Stop` is a private +correlation and turn-boundary hint rather than a standalone user-visible mark. +The other hooks normalize to their corresponding scope, tool, mark, +notification, compaction, or subagent semantics. diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index e90de421b..cd26cc081 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -48,9 +48,15 @@ nemo-relay codex -- exec "Summarize this repository." This shortcut is equivalent to `nemo-relay run -- codex`. The wrapper starts a gateway on a dynamic `127.0.0.1` port, enables Codex hooks with CLI config -overrides, injects hook commands that use `NEMO_RELAY_GATEWAY_URL`, and points -Codex at a temporary `nemo-relay-openai` provider alias that uses the gateway -URL while preserving Codex's OpenAI auth path. +overrides, injects hook commands that embed that gateway URL, and points Codex +at a temporary `nemo-relay-openai` provider alias while preserving Codex's +OpenAI auth path. Exact trust for the generated hooks and process-local disable +entries for the known local and source Relay plugin hook identities are +supplied in the same CLI layer. Relay does not replace or rewrite the selected +Codex profile. If a Relay plugin is already enabled, its MCP process +authenticates, borrows, and monitors that exact dynamic gateway; only the +wrapper-owned hooks remain enabled for that process. Those hooks authenticate +the wrapper gateway before sending lifecycle payloads. Inspect what would be launched without starting Codex: @@ -130,9 +136,17 @@ referenced by user observability configuration. Rerun `nemo-relay install codex --force` when doctor reports a missing forwarded variable. -Plugin-owned hook commands pin `http://127.0.0.1:47632` explicitly, so an -ambient `NEMO_RELAY_GATEWAY_URL` cannot split hook traffic from the -required MCP-managed gateway. +The installer derives a per-user client proof for managed provider requests and +writes the secret-bearing Codex configuration with an owner-only mode on Unix +and a protected owner/System DACL on Windows. Relay consumes that proof before +middleware, observability, or upstream +forwarding. + +Installer-owned hook commands pin `http://127.0.0.1:47632` and the exact +install-generation fence explicitly. Each delivery temporarily joins the same +endpoint recovery cohort as overlapping MCP clients, so a hook cannot create an +unaccounted second replacement. An ambient `NEMO_RELAY_GATEWAY_URL` cannot +split hook traffic from the required MCP-managed gateway. If user configuration, forwarded credentials, or the Relay version changes while an old sidecar is still running, the new MCP client reports an actionable @@ -286,11 +300,13 @@ for them. Relay maps delivered events to agent, turn, subagent, tool, and mark lifecycle events; prompt and stop payloads also provide LLM correlation context. The transparent wrapper passes hook entries as Codex CLI config overrides and -sets `features.hooks=true` for that launched process. Persistent install writes -`.codex/config.toml` with `features.hooks = true`, but the installed plugin's -`hooks/hooks.json` is the sole persistent Relay hook source. During upgrades, -the installer removes legacy Relay groups from `.codex/hooks.json` while -preserving unrelated hooks and backups. +sets `features.hooks=true` for that launched process. It also disables the known +installed Relay hook identities in the same process-local state, without +changing persistent trust or enablement. Persistent install writes +`.codex/config.toml` with `features.hooks = true`, and the installed plugin's +`hooks/hooks.json` remains the sole persistent Relay hook source. During +upgrades, the installer removes legacy Relay groups from `.codex/hooks.json` +while preserving unrelated hooks and backups. ## Smoke Test diff --git a/docs/nemo-relay-cli/hermes.mdx b/docs/nemo-relay-cli/hermes.mdx index c4a792ae8..2406a3798 100644 --- a/docs/nemo-relay-cli/hermes.mdx +++ b/docs/nemo-relay-cli/hermes.mdx @@ -45,7 +45,7 @@ rewriting the config. Rename or remove the conflicting entry, then retry. Installation performs the following changes as one verified transaction: - Adds `mcp_servers.nemo-relay` with the canonical absolute `nemo-relay` - executable and `mcp --agent hermes` arguments. + executable and the shared `mcp` argument. - Sets the shared gateway bind to `127.0.0.1:47632` and adds a private MCP generation fence. - Installs one canonical Relay handler for every supported Hermes lifecycle @@ -67,15 +67,17 @@ and forwarded environment names: nemo-relay doctor --plugin hermes ``` -If you add a Relay, OpenTelemetry, AWS, proxy, certificate, or -config-referenced credential variable later, run -`nemo-relay install hermes --force` to add its name to the Hermes MCP -environment. Generated config stores `${VARIABLE_NAME}` references, never -secret values. +Generated config always includes the standard Relay, OpenTelemetry, AWS, +proxy, certificate, and provider credential names. It stores +`${VARIABLE_NAME}` references, never secret values; Relay treats an unresolved +self-reference as unset before parsing its command line. If you later add a +custom `NEMO_RELAY_`, `OTEL_`, or `AWS_` name or change a config-referenced +credential name, run `nemo-relay install hermes --force` to add it to the +Hermes MCP environment. ## Gateway Lifecycle -Hermes launches `nemo-relay mcp --agent hermes` as a long-lived stdio MCP +Hermes launches `nemo-relay mcp` as a long-lived stdio MCP server. The MCP process does not expose tools. It starts or acquires the gateway immediately when the process launches and completes initialization only after Relay identity, version, bootstrap protocol, and effective persistent @@ -84,16 +86,18 @@ configuration are verified. Hermes starts MCP discovery in the background, so an early lifecycle hook can arrive before the MCP process finishes acquiring the gateway. The installed hook command handles that race: it starts or reuses the same gateway under the -same startup lock before delivery. If delivery then fails before connecting, -the command performs one coordinated recovery and retries the original payload -once. The payload is not reconstructed or replaced. +same startup lock before delivery, guarded by the same install generation as +the MCP entry. Its temporary endpoint lease makes any replacement spend the +same recovery allowance as overlapping MCP clients. If delivery then fails +before connecting, the command retries the original payload once. The payload +is not reconstructed or replaced. Concurrent Hermes, Claude Code, and Codex MCP clients can share one compatible gateway. Every open MCP client heartbeats the gateway. An endpoint-scoped -recovery epoch permits one coordinated restart across all overlapping clients, -even when one client misses the replacement between heartbeat ticks. A second -failure exits those MCP clients with an error. After the last client closes, -the gateway exits after 300 idle seconds by default. Set +recovery cohort permits one coordinated restart across all overlapping clients +and hooks, even when one client misses the replacement between heartbeat ticks. +A second failure exits those MCP clients with an error. After the last client +closes, the gateway exits after 300 idle seconds by default. Set `NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS` before setup and launch to choose another positive timeout. diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index 66c758595..827fcc590 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -128,6 +128,15 @@ hooks, exact hook trust, and environment references into unset. It preserves unrelated Hermes configuration. Refer to the [Hermes Agent guide](/nemo-relay-cli/hermes). +On Windows, the generated hook string uses the built-in Windows PowerShell +encoded-command envelope. This keeps the outer command free of quoting and +metacharacters across the different Codex, Claude Code, and Hermes command +runners. The encoded payload contains only the canonical `nemo-relay.exe` path +and `hook-forward` arguments. PowerShell launches that Rust binary directly +and preserves its standard input, standard output, standard error, and exit +code; the MCP client and gateway remain Rust-native. Install and doctor verify +the exact generated command and its lifecycle-event ownership. + Start a new Codex CLI process after installation. Restart the Codex desktop app if it was already running so it reloads the provider and hook configuration. @@ -142,19 +151,43 @@ open, the client performs one coordinated restart and fails if recovery does not succeed. A foreign process on the configured port is rejected with an actionable conflict instead of being accepted because it returned HTTP 200. Health and readiness include a per-process instance ID. Process-held endpoint -leases and a private recovery epoch make the one-restart allowance global to -all overlapping MCP clients, including a client whose heartbeat misses the -first replacement. A new recovery epoch begins only after the last MCP client -closes. +leases and a private recovery cohort make the one-restart allowance global to +all overlapping MCP clients and in-flight persistent hooks, including a client +whose heartbeat misses the first replacement. Every heartbeat verifies that +its cohort is still current, so an integration replacement retires old clients +deterministically instead of allowing them to adopt the replacement gateway. +After the last participant closes, the next acquisition starts a fresh recovery +epoch. A serialized integration replacement explicitly retires the current +cohort and creates a fresh one even while old leases drain. Codex marks the MCP server as required, so the captured turn waits for verified gateway readiness. Claude Code 2.1.121 or newer uses `alwaysLoad`, which blocks session startup until the MCP connection is ready. Hermes starts MCP discovery -asynchronously, so its first lifecycle hook can still run first. The installed -`hook-forward` command closes that race by starting or reusing the same gateway -under the same startup lock and retrying the original canonical payload once. +asynchronously, so its first lifecycle hook can still run first. Every +installed MCP entry and `hook-forward` command carry both the generation-file +path and the immutable identity expected at that path. A host process that +cached an older plugin configuration therefore cannot adopt a replacement +installation after `--force`. Each hook temporarily joins the same endpoint +recovery cohort and closes that race by starting or reusing the same gateway +under the same startup lock. A hook that +cannot establish the authenticated delivery connection may recover the gateway +and retry once. Relay does not retry after payload transmission begins. The MCP server advertises no tools in any host. +A fixed-endpoint `hook-forward` command without a valid installer-owned +generation fence fails according to the configured hook failure policy before +it contacts or launches the gateway. This prevents a legacy hook retained by a +host process from reviving a retired installation. Run `nemo-relay install + --force` to replace an unfenced persistent hook. Transparent hook +commands embed their process-private dynamic gateway URL and do not recover a +persistent gateway, so they do not require this fence. Source plugins and custom +automations can opt into `--forward-only` to contact an already-running gateway +without a fence. That mode waits for an authenticated, configuration-compatible +Relay gateway and rejects foreign or incompatible listeners before sending the +payload. Authentication and delivery use the same TCP connection, so a process +that replaces the listener after a health probe cannot receive lifecycle data. +The mode never launches or recovers Relay. + Hook recovery retries only connection failures that are known to precede an HTTP response. Relay does not claim crash-consistent exactly-once delivery across a gateway failure after request acceptance: there is no durable hook @@ -173,9 +206,10 @@ guarantees. The compatibility fingerprint covers resolved provider, exporter, dynamic plugin, and relevant environment settings without exposing their values in the health response. For Claude Code and Codex, -`nemo-relay install --force` generation-fences the replacement MCP -process and uses a private ownership token to stop the shared fixed-endpoint -sidecar when necessary. Ownership is keyed by endpoint, not host, so an upgrade +`nemo-relay install --force` rotates the immutable MCP and hook generation +identity, then rotates the endpoint recovery cohort and uses a private ownership +token to stop the shared fixed-endpoint sidecar as one serialized operation. +Ownership is keyed by endpoint, not host, so an upgrade from any installed host can retire a gateway originally started by another. Custom managed endpoints are left untouched. Relay never sends its shutdown token to an unrelated listener. Hermes installation transactionally rotates @@ -189,15 +223,19 @@ values—for provider credentials, Relay runtime, OpenTelemetry, AWS, proxies, certificates, approved prefixes, and credential variables referenced by user observability configuration. Claude Code supplies its normal environment to plugin MCP processes. Hermes config stores `${VARIABLE_NAME}` references for -the provider credentials, currently defined approved variables, and explicit -credential names referenced by user observability configuration. Rerun -`nemo-relay install hermes --force` after adding one of those variables. +the complete standard allowlist, currently defined custom approved variables, +and explicit credential names referenced by user observability configuration. +Relay treats unresolved self-references as unset before parsing its command +line. Rerun `nemo-relay install hermes --force` after adding a custom approved +name or changing a config-referenced credential name. The long-lived managed sidecar substitutes a forwarded provider credential only when a request carries provider authorization or Relay's private per-user client proof. The Codex installer writes a domain-separated HMAC proof into the -managed provider's `http_headers`; Relay validates and removes that header -before middleware, observability, or upstream forwarding. The underlying HMAC -key remains in Relay's owner-only bootstrap state. Claude Code and Hermes send +managed provider's `http_headers`; Relay stores the resulting Codex config with +an owner-only mode on Unix or protected owner/System DACL on Windows before the +temporary file becomes visible. Relay validates and removes that header before +middleware, observability, or upstream forwarding. The underlying HMAC key +remains in Relay's owner-only bootstrap state. Claude Code and Hermes send their normal provider authorization. A foreign loopback caller cannot spend the sidecar's forwarded credentials. Foreground `nemo-relay --bind` use retains environment-key injection for explicit local proxy workflows. @@ -293,6 +331,13 @@ canonical `--version` output. Interactive `nemo-relay config hermes` now owns only transparent-wrapper configuration; use `nemo-relay install hermes` and `nemo-relay uninstall hermes` for persistent Hermes state. +The MCP bootstrap is now host-neutral. Generated Codex, Claude Code, and Hermes +configuration invokes exactly `nemo-relay mcp`; the removed `--agent` option no +longer parses. A forced install recognizes and replaces the former +`mcp --agent claude|codex|hermes` entries. Reinstall each persistent integration +with `nemo-relay install --force` before relying on generated config from +this release. + This release removes the internal `nemo-relay plugin-shim` command. Existing generated installations should be refreshed with `nemo-relay install --force`. Use the following public replacements for @@ -300,8 +345,8 @@ custom automation: | Removed internal command | Public replacement | | --- | --- | -| `plugin-shim hook ` | `hook-forward ` with the canonical lifecycle payload on standard input | -| `plugin-shim serve` | `mcp --agent ` for host-managed lifecycle, or `--bind
` to run a gateway directly | +| `plugin-shim hook ` | `hook-forward --forward-only` with the canonical lifecycle payload on standard input and an existing gateway | +| `plugin-shim serve` | `mcp` for host-managed lifecycle, or `--bind
` to run a gateway directly | | `plugin-shim install ` | `install ` | | `plugin-shim uninstall ` | `uninstall ` | | `plugin-shim provider claude enable` | `install claude-code` | @@ -310,7 +355,10 @@ custom automation: | `plugin-shim doctor ` | `doctor --plugin ` | Persistent generated hook commands include the fixed gateway URL. Transparent -wrappers provide `NEMO_RELAY_GATEWAY_URL` for their dynamic gateway. When a +wrapper hooks embed their dynamic gateway URL, while the process environment +lets an installed plugin MCP authenticate, borrow, and monitor that exact +gateway. Transparent hook delivery authenticates the wrapper gateway before +writing its lifecycle payload. When a transparent run uses a recognizable wrapper command, such as `npx codex`, Relay checks the host version through that wrapper. Opaque custom wrappers remain supported when invoking the configured command with `--version` emits the diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 2c5e09a73..8dd1f74e2 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -52,9 +52,10 @@ nemo-relay run -- codex nemo-relay run -- hermes ``` -Use `--agent claude|codex|hermes` when a wrapper hides the agent -command name. Use `--dry-run --print` to inspect generated config without -launching. +When a wrapper hides the agent command name, configure that wrapper under +`[agents.].command` and select it with +`--agent claude|codex|hermes`. Use `--dry-run --print` to inspect generated +config without launching. Use `nemo-relay doctor` to inspect environment, config, agent commands, hook readiness, observability outputs, and shell completions. Scope the report to one @@ -91,13 +92,23 @@ returns its initialization response only after Relay identity, version, and bootstrap-protocol readiness are verified. Concurrent Codex, Claude Code, and Hermes processes share the gateway and heartbeat it while their MCP stdio connections remain open; the gateway exits after the final client's idle -timeout. A process-held endpoint epoch permits only one coordinated restart -across all overlapping clients, including staggered heartbeats. Codex requires +timeout. Process-held MCP and hook leases share one endpoint recovery cohort, +which permits only one coordinated restart across all overlapping participants, +including staggered heartbeats. Codex requires MCP initialization before the captured turn. Claude Code marks Relay MCP as `alwaysLoad`, so it also waits for the connection before -session startup. Hermes starts MCP discovery asynchronously, so its command -hook retains the same-gateway recovery path for an early hook. The MCP client -advertises no tools. +session startup. Hermes starts MCP discovery asynchronously, so its +generation-fenced command hook temporarily joins the same recovery cohort for +an early hook. Installed MCP entries and hook commands carry both their +generation-file path and the immutable identity expected there, so cached host +configuration cannot adopt a replacement installation at the same path. The +MCP client advertises no tools. + +MCP bootstrap is deliberately host-neutral: all three generated integrations +use the exact `nemo-relay mcp` command. Agent identity remains only in lifecycle +hook commands, where Relay needs it to translate each host's canonical payload. +Legacy generated `mcp --agent ` entries are recognized during forced +upgrade and replaced with the single current contract. Persistent mode loads system and user Relay configuration only and starts the sidecar from the user configuration directory. Relative exporter paths are @@ -108,8 +119,9 @@ Claude Code supplies its normal MCP process environment. Use transparent `nemo-relay run` for project-specific configuration. The managed sidecar injects a forwarded provider key only for a request with provider authorization or Relay's private per-user client proof. Codex receives that derived proof in -its managed provider headers; Relay consumes it before middleware, telemetry, -or upstream forwarding. Claude Code and Hermes send their normal provider +its managed provider headers; the installer writes that config privately and +Relay consumes the proof before middleware, telemetry, or upstream forwarding. +Claude Code and Hermes send their normal provider authorization, so an unrelated loopback caller cannot spend forwarded keys. Install the persistent integrations with: @@ -137,8 +149,13 @@ codex plugin marketplace add NVIDIA/NeMo-Relay codex plugin add nemo-relay-plugin@nemo-relay ``` -That path relies on `nemo-relay` being available on `PATH`; source plugin hooks -invoke `nemo-relay hook-forward codex` directly. +That path relies on `nemo-relay` being available on `PATH`. Source plugin hooks +use `nemo-relay hook-forward codex --forward-only`: they post to the gateway +started by the required MCP entry but cannot launch or recover Relay without an +installer-owned generation fence. Before posting, they authenticate the Relay +identity and verify that its user-level configuration matches. The proof and +payload use one TCP connection, preventing a replacement listener from +receiving the payload after verification. Use the source marketplace path for discovery or manifest validation. Use `nemo-relay install codex` for complete provider routing, environment @@ -153,8 +170,11 @@ claude plugin install nemo-relay-plugin@nemo-relay --scope user ``` That path reads `.claude-plugin/marketplace.json` from the repository. Source -plugin hooks invoke `nemo-relay hook-forward claude` directly. Use -`nemo-relay install claude-code` for the complete provider-routing setup. +plugin hooks use `nemo-relay hook-forward claude --forward-only`: they post to +the gateway started by the `alwaysLoad` MCP entry but cannot launch or recover +Relay without an installer-owned generation fence. They authenticate that +gateway on the same connection used to send lifecycle data. Use `nemo-relay install +claude-code` for the complete provider-routing setup. Hermes persistent installation is user-level: @@ -215,8 +235,8 @@ destination records, logs, or reports the failure. Transparent Claude Code and Codex hooks call `nemo-relay hook-forward ` with the canonical hook payload on standard -input. The wrapper injects `NEMO_RELAY_GATEWAY_URL` so the command reaches the -ephemeral per-run gateway. +input. The wrapper-owned command embeds the ephemeral per-run gateway URL and +is marked as transparent so it never starts or recovers the fixed gateway. Persistent Claude Code and Codex hooks, and Hermes hooks in both modes, call `nemo-relay hook-forward `. During a transparent Hermes run, the same diff --git a/integrations/coding-agents/claude-code/.mcp.json b/integrations/coding-agents/claude-code/.mcp.json index 36c8e4ef4..6abfbb56f 100644 --- a/integrations/coding-agents/claude-code/.mcp.json +++ b/integrations/coding-agents/claude-code/.mcp.json @@ -3,9 +3,7 @@ "nemo-relay": { "command": "nemo-relay", "args": [ - "mcp", - "--agent", - "claude" + "mcp" ], "env": { "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632" diff --git a/integrations/coding-agents/claude-code/README.md b/integrations/coding-agents/claude-code/README.md index e0f86fd80..9ba64ad42 100644 --- a/integrations/coding-agents/claude-code/README.md +++ b/integrations/coding-agents/claude-code/README.md @@ -18,7 +18,7 @@ same local hook and gateway controls as Claude Code. - `.claude-plugin/plugin.json` describes the Claude Code hook package. - `.mcp.json` starts the native `nemo-relay mcp` lifecycle client. - `hooks/hooks.json` contains hook entries that run - `nemo-relay hook-forward claude`. + `nemo-relay hook-forward claude --forward-only`. ## Captured Events @@ -53,9 +53,15 @@ nemo-relay run -- claude ``` The wrapper starts a per-invocation gateway on a dynamic localhost port, -creates a temporary Claude plugin directory, passes it with `--plugin-dir`, sets -`ANTHROPIC_BASE_URL` for the launched process, and removes the temporary plugin -when Claude exits. +creates a temporary Claude plugin directory, and passes it with `--plugin-dir`. +It also creates a private settings overlay that preserves the caller's first +explicit `--settings` object and overrides only `ANTHROPIC_BASE_URL` for the +launched process. Relay removes both temporary artifacts when Claude exits and +does not rewrite the source settings or user-level plugin state. An enabled +Relay plugin MCP authenticates, borrows, and monitors the wrapper-owned dynamic +gateway, and its persistent hooks become no-ops for this process; only the +temporary hook source delivers lifecycle data after authenticating that +gateway. Inspect the launch without starting Claude Code: @@ -211,9 +217,11 @@ claude plugin install nemo-relay-plugin@nemo-relay --scope user That path reads `.claude-plugin/marketplace.json` from the repository and installs this Claude Code plugin from `integrations/coding-agents/claude-code`. -The source plugin starts `nemo-relay mcp` and its hooks invoke -`nemo-relay hook-forward claude` directly. Use `nemo-relay install -claude-code` for complete provider routing and generation-fenced upgrades. +The source plugin starts `nemo-relay mcp`; its hooks use the explicit +`--forward-only` mode to post to that existing gateway without an +installer-owned generation fence. They cannot launch or recover Relay. Use +`nemo-relay install claude-code` for complete provider routing and +generation-fenced upgrades. Create a local Claude Code marketplace and copy the plugin under that marketplace root: @@ -264,8 +272,9 @@ claude --plugin-dir "$PLUGIN_ROOT" ``` Hook commands in the source `hooks/hooks.json` template use -`nemo-relay hook-forward claude`, so source marketplace installs rely on -the same `nemo-relay` executable available on `PATH`. +`nemo-relay hook-forward claude --forward-only`, so source marketplace installs +rely on the same `nemo-relay` executable available on `PATH` and on their +`alwaysLoad` MCP entry for gateway startup. If you set up the marketplace manually for development, use the top-level installer commands for provider routing and rollback: diff --git a/integrations/coding-agents/claude-code/hooks/hooks.json b/integrations/coding-agents/claude-code/hooks/hooks.json index 9eb5f6ef7..73cb06ef4 100644 --- a/integrations/coding-agents/claude-code/hooks/hooks.json +++ b/integrations/coding-agents/claude-code/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -27,7 +27,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -39,7 +39,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -51,7 +51,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -63,7 +63,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -75,7 +75,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -86,7 +86,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -97,7 +97,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -108,7 +108,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -119,7 +119,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -130,7 +130,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -141,7 +141,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -152,7 +152,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] diff --git a/integrations/coding-agents/codex/.mcp.json b/integrations/coding-agents/codex/.mcp.json index 4562f04bb..01d6245cc 100644 --- a/integrations/coding-agents/codex/.mcp.json +++ b/integrations/coding-agents/codex/.mcp.json @@ -2,36 +2,48 @@ "nemo-relay": { "command": "nemo-relay", "args": [ - "mcp", - "--agent", - "codex" + "mcp" ], "env": { "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632" }, "env_vars": [ + "ALL_PROXY", "ANTHROPIC_API_KEY", "APPDATA", "AWS_ACCESS_KEY_ID", "AWS_ALLOW_HTTP", + "AWS_CA_BUNDLE", "AWS_CONFIG_FILE", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_DEFAULT_REGION", + "AWS_EC2_METADATA_DISABLED", "AWS_ENDPOINT_URL", "AWS_PROFILE", "AWS_REGION", + "AWS_ROLE_ARN", + "AWS_ROLE_SESSION_NAME", + "AWS_SDK_LOAD_CONFIG", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_SHARED_CREDENTIALS_FILE", + "AWS_STS_REGIONAL_ENDPOINTS", + "AWS_WEB_IDENTITY_TOKEN_FILE", "HOME", "HTTPS_PROXY", "HTTP_PROXY", "LOCALAPPDATA", "NEMO_RELAY_ANTHROPIC_BASE_URL", + "NEMO_RELAY_GATEWAY_URL", "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES", "NEMO_RELAY_OPENAI_BASE_URL", "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "NEMO_RELAY_PYTHON", + "NEMO_RELAY_TRANSPARENT_RUN", "NO_PROXY", "OPENAI_API_KEY", "OTEL_EXPORTER_OTLP_COMPRESSION", @@ -54,6 +66,7 @@ "USERPROFILE", "XDG_CONFIG_HOME", "XDG_RUNTIME_DIR", + "all_proxy", "http_proxy", "https_proxy", "no_proxy" diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index b46be16bc..902f5d711 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -23,7 +23,7 @@ used by the installer. - `.mcp.json` starts the native `nemo-relay mcp` lifecycle client and requires successful gateway initialization. - `hooks/hooks.json` contains Codex hook entries that run - `nemo-relay hook-forward codex`. + `nemo-relay hook-forward codex --forward-only`. - `nemo-relay install codex` creates the local marketplace, installs the plugin, and persists Codex provider and exact plugin-hook trust using `nemo-relay` from `PATH`. @@ -75,7 +75,8 @@ or reduce the managed Python environment before retrying. Concurrent Codex, Claude Code, and configured Hermes processes can share the gateway and heartbeat it every 30 seconds. The sidecar remains available for 300 idle seconds after the final client closes. If it dies -while MCP remains open, one coordinated restart is attempted. The MCP server +while MCP remains open, one endpoint-coordinated restart is attempted across +all overlapping MCP clients and persistent hook deliveries. The MCP server advertises no tools. On Windows, Relay requests Job Object breakaway only when the host job permits @@ -93,15 +94,19 @@ provider, Relay, OpenTelemetry, AWS, proxy, certificate, and config-referenced credential variable names without storing values. The installer also derives a per-user HMAC proof from Relay's owner-only -bootstrap key and places the proof in the managed provider headers. The shared +bootstrap key and places the proof in the managed provider headers. It writes +the secret-bearing Codex config with an owner-only mode on Unix or a protected +owner/System DACL on Windows. The shared sidecar requires that proof before it injects a forwarded provider credential, then removes the proof before middleware, observability, and upstream forwarding. This prevents an unrelated loopback caller from spending the sidecar's credentials. -Plugin-owned hook commands pin `http://127.0.0.1:47632` explicitly, so an -ambient `NEMO_RELAY_GATEWAY_URL` cannot split hook traffic from the -required MCP-managed gateway. +Installer-owned hook commands pin `http://127.0.0.1:47632` and their private +install-generation file explicitly. Each delivery temporarily joins the same +recovery cohort as the MCP clients, so it cannot create an unaccounted second +replacement. An ambient `NEMO_RELAY_GATEWAY_URL` cannot split hook traffic from +the required MCP-managed gateway. If the Relay version, user configuration, or forwarded credentials change, an MCP client refuses to reuse the incompatible sidecar. `nemo-relay install codex @@ -136,10 +141,15 @@ nemo-relay run -- codex ``` The wrapper starts a per-invocation gateway on a dynamic localhost port, -enables Codex hooks with CLI config overrides, injects hook commands that use -`NEMO_RELAY_GATEWAY_URL`, and points Codex at a temporary `nemo-relay-openai` -provider alias that uses the gateway URL while preserving Codex's OpenAI auth -path. +enables Codex hooks with CLI config overrides, injects hook commands that embed +the gateway URL, and points Codex at a temporary `nemo-relay-openai` provider +alias while preserving Codex's OpenAI auth path. It trusts only the exact +generated session-hook commands and disables the known local and source Relay +plugin hook identities in Codex's process-local CLI layer. It does not replace +or rewrite the selected profile. An enabled Relay plugin MCP authenticates, +borrows, and monitors that exact dynamic gateway, while only the wrapper hooks +remain enabled for that process. Those hooks authenticate the wrapper gateway +before sending lifecycle payloads. Inspect the launch without starting Codex: @@ -300,7 +310,9 @@ codex plugin add nemo-relay-plugin@nemo-relay That path reads `.agents/plugins/marketplace.json` from the repository and installs this Codex plugin from `integrations/coding-agents/codex`. Source hooks -invoke `nemo-relay hook-forward codex` directly. +use `nemo-relay hook-forward codex --forward-only` to post to the gateway +started by required MCP without an installer-owned generation fence. They +cannot launch or recover Relay. Treat the source marketplace path as discovery or manifest validation. Use `nemo-relay install codex` for the complete provider, environment-forwarding, diff --git a/integrations/coding-agents/codex/hooks/hooks.json b/integrations/coding-agents/codex/hooks/hooks.json index 61a5ea9b3..550a462bc 100644 --- a/integrations/coding-agents/codex/hooks/hooks.json +++ b/integrations/coding-agents/codex/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -29,7 +29,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -41,7 +41,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -53,7 +53,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -64,7 +64,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -75,7 +75,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -86,7 +86,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -97,7 +97,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -108,7 +108,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] diff --git a/scripts/test-claude-plugin-e2e.sh b/scripts/test-claude-plugin-e2e.sh index 107467ef3..161759662 100755 --- a/scripts/test-claude-plugin-e2e.sh +++ b/scripts/test-claude-plugin-e2e.sh @@ -30,7 +30,11 @@ cleanup() { kill "$provider_pid" 2>/dev/null || true wait "$provider_pid" 2>/dev/null || true fi - rm -rf "$work" + if [[ "${RELAY_E2E_KEEP_WORK:-0}" == "1" ]]; then + echo "Claude Code E2E workspace retained at $work" >&2 + else + rm -rf "$work" + fi } trap cleanup EXIT @@ -115,11 +119,13 @@ plugins = json.loads(subprocess.check_output(["claude", "plugin", "list", "--jso relay = [item for item in plugins if item.get("id") == "nemo-relay-plugin@nemo-relay-local"] assert len(relay) == 1, relay server = relay[0]["mcpServers"]["nemo-relay"] -assert server["args"] == ["mcp", "--agent", "claude"], server +assert server["args"] == ["mcp"], server assert server["env"]["NEMO_RELAY_GATEWAY_BIND"] == "127.0.0.1:47632", server generation = Path(server["env"]["NEMO_RELAY_MCP_GENERATION_FILE"]) assert generation == plugin_root / ".nemo-relay-generation", generation assert generation.is_file(), generation +generation_token = server["env"]["NEMO_RELAY_MCP_GENERATION"] +assert generation_token == generation.read_text().splitlines()[0].strip(), server assert server["alwaysLoad"] is True, server PY @@ -172,7 +178,90 @@ assert '"hasTools":false' in log, log PY } +run_transparent_claude() { + output="$work/claude-transparent.json" + stderr="$work/claude-transparent.stderr" + debug="$work/claude-transparent.debug.log" + ( + cd "$work/workspace" + nemo-relay run \ + --config "$XDG_CONFIG_HOME/nemo-relay/config.toml" \ + -- \ + claude \ + --settings "$work/claude-user-settings.json" \ + -p "ping" \ + --output-format json \ + --no-session-persistence \ + --tools "" \ + --debug-file "$debug" + ) >"$output" 2>"$stderr" + python3 - "$output" "$stderr" "$debug" <<'PY' +import json +import sys +from pathlib import Path + +output, stderr, debug = map(Path, sys.argv[1:]) +result = json.loads(output.read_text()) +assert result["subtype"] == "success", (result, stderr.read_text()) +assert result["result"] == "pong", result +log = debug.read_text() +assert 1 <= log.count("Hook SessionStart:startup") <= 2, log +assert 1 <= log.count("Hook UserPromptSubmit") <= 2, log +assert 1 <= log.count('Hook Stop (Stop) success') <= 2, log +assert 1 <= log.count("SessionEnd:other") <= 2, log +assert log.count('MCP server "plugin:nemo-relay-plugin:nemo-relay": Successfully connected') == 1, log +PY +} + +# The transparent wrapper preserves the explicit Claude settings source. The installed Relay MCP +# borrows the dynamic gateway, while its persistent hooks exit without duplicating ATOF delivery. +cat >"$work/claude-user-settings.json" <<'EOF' +{ + "model": "claude-haiku-4-5", + "enabledPlugins": { + "nemo-relay-plugin@nemo-relay-local": true + } +} +EOF +cp "$HOME/.claude/settings.json" "$work/claude-settings-before-transparent.json" +cp "$work/claude-user-settings.json" "$work/claude-user-settings-before-transparent.json" +: >"$provider_log" +events="$work/atof/events.jsonl" +rm -f "$events" +wait_for_relay_port_release +run_transparent_claude +wait_for_relay_port_release +cmp "$HOME/.claude/settings.json" "$work/claude-settings-before-transparent.json" +cmp "$work/claude-user-settings.json" "$work/claude-user-settings-before-transparent.json" +python3 - "$provider_log" "$events" <<'PY' +import json +import sys +from urllib.parse import urlparse + +requests = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8") if line.strip()] +messages = [row for row in requests if urlparse(row["path"]).path.endswith("/messages")] +assert len(messages) == 1, requests +assert messages[0]["model"] == "claude-haiku-4-5", messages +events = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8") if line.strip()] +turn_starts = [ + event for event in events + if event.get("kind") == "scope" + and event.get("name") == "claude-code-turn" + and event.get("scope_category") == "start" +] +turn_ends = [ + event for event in events + if event.get("kind") == "scope" + and event.get("name") == "claude-code-turn" + and event.get("scope_category") == "end" +] +assert len(turn_starts) == len(turn_ends) == 1, (turn_starts, turn_ends) +PY +nemo-relay doctor --plugin claude-code --install-dir "$work/install" + wait_for_relay_port_release +: >"$provider_log" +rm -f "$events" for run_id in $(seq 1 10); do run_claude "$run_id" wait_for_relay_port_release @@ -252,7 +341,10 @@ assert len(llm_starts) == len(llm_ends) == 12, (len(llm_starts), len(llm_ends)) session_ids = {event["metadata"]["session_id"] for event in turn_starts} assert len(session_ids) == 12, session_ids -debug_logs = list(work.glob("claude-*.debug.log")) +debug_logs = [ + path for path in work.glob("claude-*.debug.log") + if path.name != "claude-transparent.debug.log" +] assert len(debug_logs) == 12, debug_logs PY diff --git a/scripts/test-codex-plugin-e2e.sh b/scripts/test-codex-plugin-e2e.sh index fb7368450..df541e404 100755 --- a/scripts/test-codex-plugin-e2e.sh +++ b/scripts/test-codex-plugin-e2e.sh @@ -71,7 +71,11 @@ cleanup() { kill -KILL "$sidecar_pid" 2>/dev/null || true fi fi - rm -rf "$work" + if [[ "${RELAY_E2E_KEEP_WORK:-0}" == "1" ]]; then + echo "Codex E2E workspace retained at $work" >&2 + else + rm -rf "$work" + fi } trap cleanup EXIT @@ -378,6 +382,117 @@ PY fi } +run_transparent_codex_ping() { + stdout="$work/codex-transparent.stdout" + stderr="$work/codex-transparent.stderr" + if ! python3 - "$stdout" "$stderr" "$XDG_CONFIG_HOME/nemo-relay/config.toml" "$transparent_project" <<'PY' +import subprocess +import sys + +stdout_path, stderr_path, relay_config, project = sys.argv[1:] +with open(stdout_path, "wb") as stdout, open(stderr_path, "wb") as stderr: + process = subprocess.run( + [ + "nemo-relay", + "run", + "--config", + relay_config, + "--", + "codex", + "--profile", + "relay-user-profile", + "exec", + "--skip-git-repo-check", + "ping", + ], + stdout=stdout, + stderr=stderr, + cwd=project, + timeout=60, + check=False, + ) +raise SystemExit(process.returncode) +PY + then + echo "transparent Codex run with persistent plugin installed failed" >&2 + cat "$stdout" >&2 + cat "$stderr" >&2 + return 1 + fi + grep -qi "pong" "$stdout" + python3 - "$stderr" <<'PY' +import re +import sys +from collections import Counter + +lines = open(sys.argv[1], encoding="utf-8", errors="replace").read().splitlines() +ansi = re.compile(r"\x1b\[[0-9;]*m") +started = Counter() +completed = Counter() +for raw_line in lines: + line = ansi.sub("", raw_line).strip() + match = re.search(r"(?:^|\s)hook: (SessionStart|UserPromptSubmit|Stop)( Completed)?$", line) + if not match: + continue + (completed if match.group(2) else started)[match.group(1)] += 1 +expected = Counter({"SessionStart": 1, "UserPromptSubmit": 1, "Stop": 1}) +# The installed plugin remains enabled and its process-local hook exits without forwarding. Codex +# can therefore report both that hook and the wrapper-owned hook, while the ATOF assertions below +# still require exactly one delivered lifecycle stream. +assert all(count <= 2 for count in started.values()), (started, lines) +assert all(count <= 2 for count in completed.values()), (completed, lines) +if started or completed: + assert set(started) == set(expected) and set(completed) == set(expected), (started, completed, lines) + assert started == completed, (started, completed, lines) +PY +} + +# Transparent mode preserves the selected profile. The installed plugin remains configured, but its +# MCP borrows the wrapper-owned dynamic gateway and its persistent hooks become process-local no-ops. +cat >"$CODEX_HOME/relay-user-profile.config.toml" <<'EOF' +model = "gpt-5.1-codex" +model_reasoning_effort = "low" +EOF +cp "$CODEX_HOME/config.toml" "$work/codex-config-before-transparent.toml" +cp "$CODEX_HOME/relay-user-profile.config.toml" "$work/codex-profile-before-transparent.toml" +: >"$provider_log" +transparent_project="$work/transparent-project" +mkdir -p "$transparent_project" +events="$transparent_project/atof/events.jsonl" +rm -f "$events" +wait_for_relay_port_release +run_transparent_codex_ping +wait_for_relay_port_release +cmp "$CODEX_HOME/config.toml" "$work/codex-config-before-transparent.toml" +cmp "$CODEX_HOME/relay-user-profile.config.toml" "$work/codex-profile-before-transparent.toml" +[[ -z "$(find_sidecar_file 'sidecar-*.owner.json')" ]] +python3 - "$provider_log" "$events" <<'PY' +import json +import sys + +requests = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8") if line.strip()] +responses = [row for row in requests if row["method"] == "POST" and row["path"].endswith("/responses")] +assert len(responses) == 1, requests +assert responses[0]["model"] == "gpt-5.1-codex", responses +events = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8") if line.strip()] +turn_starts = [ + event for event in events + if event.get("kind") == "scope" + and event.get("name") == "codex-turn" + and event.get("scope_category") == "start" +] +turn_ends = [ + event for event in events + if event.get("kind") == "scope" + and event.get("name") == "codex-turn" + and event.get("scope_category") == "end" +] +assert len(turn_starts) == len(turn_ends) == 1, (turn_starts, turn_ends) +assert turn_starts[0].get("data", {}).get("hook_event_name", "").lower() == "userpromptsubmit", turn_starts +assert turn_ends[0].get("metadata", {}).get("hook_event_name", "").lower() == "stop", turn_ends +PY +nemo-relay doctor --plugin codex --install-dir "$install_dir" + # Exercise incompatible configuration handling before collecting acceptance events. export NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS=300 holder_fifo="$work/mcp-holder.stdin" diff --git a/scripts/test-hermes-mcp-e2e.sh b/scripts/test-hermes-mcp-e2e.sh index 1dd742763..696d0a290 100755 --- a/scripts/test-hermes-mcp-e2e.sh +++ b/scripts/test-hermes-mcp-e2e.sh @@ -143,13 +143,15 @@ home, doctor_path, relay = map(Path, sys.argv[1:]) config = (home / "config.yaml").read_text() assert "mcp_servers:" in config and "nemo-relay:" in config, config assert str(relay.resolve()) in config, config -assert "- mcp" in config and "- --agent" in config and "- hermes" in config, config +assert "- mcp" in config and "- --agent" not in config, config assert "NEMO_RELAY_GATEWAY_BIND: 127.0.0.1:47632" in config, config assert "OPENAI_API_KEY: ${OPENAI_API_KEY}" in config, config generation = home / ".nemo-relay-generation" assert f"NEMO_RELAY_MCP_GENERATION_FILE: {generation}" in config, config assert generation == home / ".nemo-relay-generation", generation assert generation.is_file(), generation +generation_token = generation.read_text().splitlines()[0].strip() +assert f"NEMO_RELAY_MCP_GENERATION: {generation_token}" in config, config allowlist = json.loads((home / "shell-hooks-allowlist.json").read_text()) commands = { @@ -159,6 +161,7 @@ commands = { } assert len(commands) == 1, commands command = commands.pop() +assert f"--generation-token {generation_token}" in command, command approvals = [entry for entry in allowlist["approvals"] if entry.get("command") == command] assert len(approvals) == 13, approvals assert len({entry["event"] for entry in approvals}) == 13, approvals From debbbe6213af6c2cdd6fe9aa2b1ae57a961b9aba Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 04:29:06 -0400 Subject: [PATCH 011/102] test(cli): close shared bootstrap coverage gaps Exercise transactional plugin installation, Hermes configuration and trust, launcher overlays, and environment propagation across their failure paths. Remove redundant verification and unreachable refresh work, and wait for complete PID publication in sidecar process-tree tests. Signed-off-by: Will Killian --- crates/cli/src/hermes.rs | 15 - crates/cli/src/plugin_install/mod.rs | 3 - .../cli/tests/coverage/agent_process_tests.rs | 5 + crates/cli/tests/coverage/config_tests.rs | 11 + crates/cli/tests/coverage/hermes_tests.rs | 373 ++++++++++++++ crates/cli/tests/coverage/installer_tests.rs | 41 ++ crates/cli/tests/coverage/launcher_tests.rs | 132 +++++ .../cli/tests/coverage/plugin_host_tests.rs | 38 +- .../tests/coverage/plugin_install_tests.rs | 459 +++++++++++++++++- 9 files changed, 1040 insertions(+), 37 deletions(-) diff --git a/crates/cli/src/hermes.rs b/crates/cli/src/hermes.rs index 0167f9d14..754c3a9cd 100644 --- a/crates/cli/src/hermes.rs +++ b/crates/cli/src/hermes.rs @@ -478,21 +478,6 @@ fn verify_install( verify_hook_definitions(&config, command)?; verify_trust(&paths.allowlist, command)?; - let raw = fs::read_to_string(&paths.allowlist) - .map_err(|error| format!("failed to verify {}: {error}", paths.allowlist.display()))?; - let allowlist = - parse_json_object(Some(&raw), "Hermes shell-hook allowlist").map_err(|e| e.to_string())?; - let approvals = allowlist["approvals"] - .as_array() - .expect("trust verification checked approvals"); - if approvals - .iter() - .filter_map(|entry| entry.get("command").and_then(Value::as_str)) - .any(|candidate| is_managed_hook_command(candidate) && candidate != command) - { - return Err("stale Hermes Relay hook approvals remain".into()); - } - let actual_token = match generation_transaction { Some(transaction) => transaction.active_visible_token()?, None => InstallGeneration::capture(paths.generation.clone())? diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/plugin_install/mod.rs index e5416dcd9..5d6874bf3 100644 --- a/crates/cli/src/plugin_install/mod.rs +++ b/crates/cli/src/plugin_install/mod.rs @@ -705,9 +705,6 @@ fn install_host_locked( None }; if options.force && staged.is_none() { - if !options.dry_run { - setup_runner.refresh_gateway()?; - } force_cleanup_existing_install(host, &layout, options, runner, setup_runner)?; } if let Some(staged) = staged.as_ref() { diff --git a/crates/cli/tests/coverage/agent_process_tests.rs b/crates/cli/tests/coverage/agent_process_tests.rs index 3a5065e6b..dfaba9b74 100644 --- a/crates/cli/tests/coverage/agent_process_tests.rs +++ b/crates/cli/tests/coverage/agent_process_tests.rs @@ -45,6 +45,10 @@ fn wrapper_probe_uses_last_host_token_and_validates_opaque_wrappers() { ), ["custom-codex-wrapper", "--profile", "dev", "--version"] ); + assert_eq!( + version_probe_argv(CodingAgent::Codex, &[]), + ["codex", "--version"] + ); } #[test] @@ -71,6 +75,7 @@ fn platform_resolution_supports_explicit_paths_and_windows_pathext() { ), Some(shim) ); + assert_eq!(resolve_executable_for_platform("", None, None, false), None); } #[cfg(unix)] diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index f3a2f760c..4b35b486c 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -1581,6 +1581,17 @@ fn ordinary_server_ignores_managed_bootstrap_fingerprint_environment() { !xdg.join("nemo-relay/bootstrap/fingerprint-hmac.key") .exists() ); + + scope.set_bootstrap_fingerprint(""); + let managed_args = ServerArgs { + ready_file: Some(temp.path().join("managed.ready.json")), + ..args + }; + assert!( + managed_bootstrap_identity(&managed_args, &resolved, &[]) + .unwrap() + .is_none() + ); } #[test] diff --git a/crates/cli/tests/coverage/hermes_tests.rs b/crates/cli/tests/coverage/hermes_tests.rs index 8796bb929..c3d8b3bca 100644 --- a/crates/cli/tests/coverage/hermes_tests.rs +++ b/crates/cli/tests/coverage/hermes_tests.rs @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 use std::cell::Cell; +use std::ffi::OsString; use std::path::Path; +use std::sync::MutexGuard; use std::time::{Duration, UNIX_EPOCH}; use serde_json::{Value, json}; @@ -36,6 +38,38 @@ fn json_file(path: &Path) -> Value { serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() } +struct XdgConfigHomeScope { + _guard: MutexGuard<'static, ()>, + previous: Option, +} + +impl XdgConfigHomeScope { + fn enter(path: &Path) -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let previous = std::env::var_os("XDG_CONFIG_HOME"); + // SAFETY: This scope holds the process-wide environment mutex. + unsafe { std::env::set_var("XDG_CONFIG_HOME", path) }; + Self { + _guard: guard, + previous, + } + } +} + +impl Drop for XdgConfigHomeScope { + fn drop(&mut self) { + // SAFETY: This restores the process environment while the mutex is still held. + unsafe { + match self.previous.take() { + Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } + } +} + #[test] fn user_config_path_uses_hermes_home_or_platform_home() { let default_home = Path::new("/users/relay"); @@ -1027,3 +1061,342 @@ fn malformed_user_files_fail_before_any_state_is_replaced() { assert_eq!(std::fs::read(path).unwrap(), before[index]); } } + +#[test] +fn hermes_entrypoints_reject_missing_or_foreign_relay_binaries() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("hermes/config.yaml"); + let missing_relay = temp.path().join("missing/nemo-relay"); + + let error = install_persistent(&config_path, &missing_relay) + .unwrap_err() + .to_string(); + assert!(error.contains("missing or not executable"), "{error}"); + + std::fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + std::fs::write( + &config_path, + format!( + "mcp_servers:\n {MCP_SERVER_NAME}:\n command: {}\n args: [mcp]\n", + missing_relay.display() + ), + ) + .unwrap(); + let error = configured_relay_executable(&config_path).unwrap_err(); + assert!(error.contains("missing or not executable"), "{error}"); + + let foreign = json!({ + "mcp_servers": { + MCP_SERVER_NAME: { + "command": "foreign-mcp", + "args": ["serve"] + } + } + }); + let error = relay_executable_from_config(&foreign).unwrap_err(); + assert!(error.contains("not a managed Relay MCP client"), "{error}"); +} + +#[test] +fn hermes_diagnosis_validates_binary_bind_generation_and_environment() { + let temp = tempfile::tempdir().unwrap(); + let _config_home = XdgConfigHomeScope::enter(&temp.path().join("xdg")); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + + let original = yaml(&paths.config); + std::fs::remove_file(&relay).unwrap(); + let error = diagnose_persistent(&paths.config).unwrap_err(); + assert!(error.contains("missing or not executable"), "{error}"); + + let relay = relay_binary(temp.path()); + let mut wrong_bind = original.clone(); + wrong_bind["mcp_servers"][MCP_SERVER_NAME]["env"]["NEMO_RELAY_GATEWAY_BIND"] = + json!("127.0.0.1:1"); + std::fs::write(&paths.config, serde_yaml::to_string(&wrong_bind).unwrap()).unwrap(); + let error = diagnose_persistent(&paths.config).unwrap_err(); + assert!(error.contains("shared gateway bind"), "{error}"); + + let mut wrong_generation = original.clone(); + wrong_generation["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_FILE_ENV] = + json!(temp.path().join("wrong-generation").display().to_string()); + std::fs::write( + &paths.config, + serde_yaml::to_string(&wrong_generation).unwrap(), + ) + .unwrap(); + let error = diagnose_persistent(&paths.config).unwrap_err(); + assert!(error.contains("points at the wrong file"), "{error}"); + + let mut missing_environment = original; + assert!( + missing_environment["mcp_servers"][MCP_SERVER_NAME]["env"] + .as_object_mut() + .unwrap() + .remove("OPENAI_API_KEY") + .is_some() + ); + std::fs::write( + &paths.config, + serde_yaml::to_string(&missing_environment).unwrap(), + ) + .unwrap(); + let error = diagnose_persistent(&paths.config).unwrap_err(); + assert!(error.contains("missing environment names"), "{error}"); + assert!(error.contains("OPENAI_API_KEY"), "{error}"); + assert!(error.contains("install hermes --force"), "{error}"); + + assert!(relay.exists()); +} + +#[test] +fn hermes_generation_finish_preserves_primary_errors_and_reports_restore_failures() { + let primary = CliError::Install("primary failure".into()); + let error = finish_generation_mutation::<()>(Err(primary), None, "install") + .unwrap_err() + .to_string(); + assert!(error.contains("primary failure"), "{error}"); + + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join(GENERATION_FILE_NAME); + crate::install_generation::write_new_generation(&generation).unwrap(); + let mut retirement = GenerationRetirement::acquire(&generation).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + std::fs::write(&generation, "foreign-generation\n").unwrap(); + + let error = finish_generation_mutation::<()>( + Err(CliError::Install("mutation failed".into())), + Some(&mut retirement), + "install", + ) + .unwrap_err() + .to_string(); + assert!(error.contains("mutation failed"), "{error}"); + assert!(error.contains("additionally failed to restore"), "{error}"); +} + +#[test] +fn hermes_uninstall_and_verification_reject_malformed_or_residual_state() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let hermes_paths = paths(&temp.path().join("hermes")); + install_persistent_with( + hermes_paths.clone(), + &relay, + &[], + None, + UNIX_EPOCH, + atomic_write, + ) + .unwrap(); + + let config = yaml(&hermes_paths.config); + let command = config["hooks"]["on_session_start"][0]["command"] + .as_str() + .unwrap() + .to_string(); + let token = InstallGeneration::capture(hermes_paths.generation.clone()) + .unwrap() + .token() + .to_owned(); + let expected_environment = forwarded_environment_names(&[], None); + + let mut duplicate_hook = config.clone(); + duplicate_hook["hooks"]["on_session_start"] + .as_array_mut() + .unwrap() + .push(json!({"command": command})); + let error = verify_hook_definitions(&duplicate_hook, &command).unwrap_err(); + assert!( + error.contains("exactly one trusted Relay handler"), + "{error}" + ); + + let mut harmless_missing_command = config.clone(); + harmless_missing_command["hooks"] + .as_object_mut() + .unwrap() + .insert("custom".into(), json!([{"timeout": 1}])); + verify_hook_definitions(&harmless_missing_command, &command).unwrap(); + + verify_install( + &hermes_paths, + &relay, + &command, + &expected_environment, + &token, + None, + ) + .unwrap(); + + install_persistent_with( + hermes_paths.clone(), + &relay, + &expected_environment, + None, + UNIX_EPOCH, + atomic_write, + ) + .unwrap(); + let config = yaml(&hermes_paths.config); + let command = config["hooks"]["on_session_start"][0]["command"] + .as_str() + .unwrap() + .to_string(); + let expected_token = InstallGeneration::capture(hermes_paths.generation.clone()) + .unwrap() + .token() + .to_owned(); + crate::install_generation::write_new_generation(&hermes_paths.generation).unwrap(); + let error = verify_install( + &hermes_paths, + &relay, + &command, + &expected_environment, + &expected_token, + None, + ) + .unwrap_err(); + assert!( + error.contains("generation did not persist exactly"), + "{error}" + ); + + let malformed_paths = paths(&temp.path().join("malformed")); + std::fs::create_dir_all(malformed_paths.config.parent().unwrap()).unwrap(); + std::fs::write(&malformed_paths.allowlist, r#"{"approvals":{}}"#).unwrap(); + let error = uninstall_persistent_with(malformed_paths, atomic_write) + .unwrap_err() + .to_string(); + assert!(error.contains("approvals must be an array"), "{error}"); +} + +#[test] +fn hermes_uninstall_verifier_identifies_each_residual_owned_surface() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + + let error = verify_uninstall(&paths).unwrap_err(); + assert!(error.contains("generation fence still exists"), "{error}"); + + std::fs::remove_file(&paths.generation).unwrap(); + let error = verify_uninstall(&paths).unwrap_err(); + assert!( + error.contains("managed Hermes Relay config still exists"), + "{error}" + ); + + std::fs::remove_file(&paths.config).unwrap(); + let error = verify_uninstall(&paths).unwrap_err(); + assert!( + error.contains("managed Hermes Relay trust approval still exists"), + "{error}" + ); +} + +#[test] +fn hermes_file_helpers_report_path_lock_read_remove_and_restore_failures() { + let temp = tempfile::tempdir().unwrap(); + + let error = PersistentPaths::for_config(PathBuf::from("/")) + .unwrap_err() + .to_string(); + assert!(error.contains("has no parent directory"), "{error}"); + let error = acquire_install_lock(Path::new("/"), Duration::ZERO).unwrap_err(); + assert!(error.contains("has no parent directory"), "{error}"); + + let parent_file = temp.path().join("parent-file"); + std::fs::write(&parent_file, "file").unwrap(); + let error = acquire_allowlist_lock(&parent_file.join("allowlist"), Duration::ZERO).unwrap_err(); + assert!(error.contains("failed to create"), "{error}"); + let error = + acquire_allowlist_lock(&parent_file.join("nested/allowlist"), Duration::ZERO).unwrap_err(); + assert!(error.contains("failed to create"), "{error}"); + + let allowlist = temp.path().join("allowlist.json"); + let lock_dir = temp.path().join("allowlist.json.lock"); + std::fs::create_dir(&lock_dir).unwrap(); + let error = acquire_allowlist_lock(&allowlist, Duration::ZERO).unwrap_err(); + assert!( + error.contains("failed to open Hermes install lock"), + "{error}" + ); + + let held_config = temp.path().join("held/config.yaml"); + let _held = acquire_install_lock(&held_config, Duration::ZERO).unwrap(); + let error = acquire_install_lock(&held_config, Duration::from_millis(30)).unwrap_err(); + assert!(error.contains("timed out waiting"), "{error}"); + + let directory = temp.path().join("directory"); + std::fs::create_dir(&directory).unwrap(); + let error = read_optional_utf8(&directory).unwrap_err().to_string(); + assert!(error.contains("failed to read"), "{error}"); + let error = match FileSnapshot::capture(&directory) { + Ok(_) => panic!("directory snapshot unexpectedly succeeded"), + Err(error) => error.to_string(), + }; + assert!(error.contains("failed to snapshot"), "{error}"); + let error = remove_optional_file(&directory).unwrap_err(); + assert!(error.contains("failed to remove"), "{error}"); + remove_optional_file(&temp.path().join("missing")).unwrap(); + + let restored = temp.path().join("restored"); + std::fs::write(&restored, "original").unwrap(); + let snapshot = FileSnapshot::capture(&restored).unwrap(); + std::fs::remove_file(&restored).unwrap(); + let error = snapshot.restore(&mut |_path, _bytes| Ok(())).unwrap_err(); + assert!(error.contains("failed to restore permissions"), "{error}"); + + let absent = temp.path().join("absent"); + let snapshot = FileSnapshot::capture(&absent).unwrap(); + std::fs::write(&absent, "transient").unwrap(); + snapshot.restore(&mut atomic_write).unwrap(); + assert!(!absent.exists()); +} + +#[test] +fn hermes_rollback_reports_both_primary_and_snapshot_restore_errors() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state"); + std::fs::write(&path, "original").unwrap(); + let snapshot = FileSnapshot::capture(&path).unwrap(); + let error = rollback_error::<(), _>( + "install", + "primary failure".into(), + &[snapshot], + &mut |_path, _bytes| Err("restore failure".into()), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("primary failure"), "{error}"); + assert!( + error.contains("rollback also failed: restore failure"), + "{error}" + ); +} + +#[test] +fn hermes_uninstall_removes_an_allowlist_containing_only_managed_approvals() { + let temp = tempfile::tempdir().unwrap(); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.allowlist.parent().unwrap()).unwrap(); + std::fs::write( + &paths.allowlist, + serde_json::to_vec(&json!({ + "approvals": [{ + "event": "on_session_start", + "command": "nemo-relay hook-forward hermes" + }] + })) + .unwrap(), + ) + .unwrap(); + + let affected = uninstall_persistent_with(paths.clone(), atomic_write).unwrap(); + + assert_eq!(affected, vec![paths.allowlist.clone()]); + assert!(!paths.allowlist.exists()); +} diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs index c1d626546..9a138b918 100644 --- a/crates/cli/tests/coverage/installer_tests.rs +++ b/crates/cli/tests/coverage/installer_tests.rs @@ -281,6 +281,47 @@ fn explicit_persistent_destinations_ignore_ambient_urls() { let destination = resolve_hook_destination(Some("http://embedded".into()), None, false, true); assert_eq!(destination.gateway_url, "http://embedded"); assert_eq!(destination.lifecycle, HookGatewayLifecycle::Transparent); + + let destination = resolve_hook_destination(None, None, false, false); + assert_eq!(destination.gateway_url, crate::sidecar::DEFAULT_URL); + assert_eq!(destination.lifecycle, HookGatewayLifecycle::Recover); +} + +#[test] +fn verified_hook_response_rejects_invalid_status_and_fail_open_http_errors() { + let error = handle_verified_hook_forward_response( + Ok(crate::sidecar::VerifiedHttpResponse { + status: 0, + body: Vec::new(), + }), + true, + ) + .unwrap_err() + .to_string(); + assert!(error.contains("invalid status"), "{error}"); + + handle_hook_forward_status(reqwest::StatusCode::BAD_GATEWAY, String::new(), false).unwrap(); +} + +#[test] +fn windows_hook_decoder_rejects_unsafe_odd_and_trailing_argument_envelopes() { + const SEPARATOR: &str = " -NoLogo -NoProfile -NonInteractive -EncodedCommand "; + #[cfg(windows)] + let launcher = windows_powershell_path().unwrap(); + #[cfg(not(windows))] + let launcher = "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe".to_string(); + + assert!(decode_windows_hook_command(&format!("powershell.exe{SEPARATOR}QQ==")).is_none()); + assert!(decode_windows_hook_command(&format!("{launcher}{SEPARATOR}QQ==")).is_none()); + + let script = "$ErrorActionPreference='Stop'; & 'relay' ; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE"; + let encoded = base64::engine::general_purpose::STANDARD.encode( + script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(), + ); + assert!(decode_windows_hook_command(&format!("{launcher}{SEPARATOR}{encoded}")).is_none()); } #[test] diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index cd7653d90..bc694e4e4 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -1238,6 +1238,138 @@ fn claude_settings_overlay_handles_inline_json_and_rejects_malformed_sources() { .to_string() .contains("field `env` must be a JSON object") ); + + let temp = tempfile::tempdir().unwrap(); + let non_object_path = temp.path().join("array-settings.json"); + std::fs::write(&non_object_path, "[]").unwrap(); + let non_object = vec![ + "claude".into(), + format!("--settings={}", non_object_path.display()), + ]; + assert!( + claude_settings_overlay(&non_object, 0, "http://127.0.0.1:4321") + .unwrap_err() + .to_string() + .contains("must contain a JSON object") + ); + + let empty_inline = vec!["claude".into(), "--settings=".into()]; + assert!( + claude_settings_overlay(&empty_inline, 0, "http://127.0.0.1:4321") + .unwrap_err() + .to_string() + .contains("missing its value") + ); + + let missing_file = vec![ + "claude".into(), + "--verbose".into(), + "--settings".into(), + temp.path() + .join("missing-settings.json") + .display() + .to_string(), + ]; + assert!( + claude_settings_overlay(&missing_file, 0, "http://127.0.0.1:4321") + .unwrap_err() + .to_string() + .contains("failed to read Claude Code settings") + ); + + let malformed_json = vec!["claude".into(), "--settings={not-json".into()]; + assert!( + claude_settings_overlay(&malformed_json, 0, "http://127.0.0.1:4321") + .unwrap_err() + .to_string() + .contains("failed to parse Claude Code --settings JSON") + ); +} + +#[test] +fn codex_session_hook_state_rejects_every_malformed_generated_shape() { + let malformed = [ + ( + json!({"hooks": {"SessionStart": {}}}), + "hook groups were malformed", + ), + ( + json!({"hooks": {"SessionStart": [true]}}), + "hook group was malformed", + ), + ( + json!({"hooks": {"SessionStart": [{}]}}), + "hook handlers were malformed", + ), + ( + json!({"hooks": {"SessionStart": [{"hooks": [true]}]}}), + "command hook was malformed", + ), + ( + json!({"hooks": {"SessionStart": [{"hooks": [{"type": "prompt", "command": "relay"}]}]}}), + "hook was not a command", + ), + ( + json!({"hooks": {"SessionStart": [{"hooks": [{"type": "command"}]}]}}), + "hook command was missing", + ), + ]; + for (generated, expected) in malformed { + let error = codex_session_hook_state_override(&generated) + .unwrap_err() + .to_string(); + assert!(error.contains(expected), "{error}"); + } + + let generated = json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "*", + "hooks": [{ + "type": "command", + "command": "/opt/nemo relay/bin/nemo-relay hook-forward codex", + "timeout": 0, + "statusMessage": "Forwarding to Relay" + }] + }] + } + }); + let group = generated["hooks"]["PreToolUse"][0].as_object().unwrap(); + let handler = &group["hooks"].as_array().unwrap()[0]; + let hash = codex_command_hook_hash("pre_tool_use", group, handler).unwrap(); + + let normalized_handler = json!({ + "type": "command", + "command": "/opt/nemo relay/bin/nemo-relay hook-forward codex", + "timeout": 1, + "statusMessage": "Forwarding to Relay" + }); + assert_eq!( + hash, + codex_command_hook_hash("pre_tool_use", group, &normalized_handler).unwrap() + ); + + let mut without_matcher = group.clone(); + without_matcher.remove("matcher"); + assert_ne!( + hash, + codex_command_hook_hash("pre_tool_use", &without_matcher, handler).unwrap() + ); + + let mut without_status = normalized_handler; + without_status + .as_object_mut() + .unwrap() + .remove("statusMessage"); + assert_ne!( + hash, + codex_command_hook_hash("pre_tool_use", group, &without_status).unwrap() + ); + + let state = codex_session_hook_state_override(&generated).unwrap(); + assert!(state.contains("pre_tool_use")); + assert!(state.contains("trusted_hash")); + assert!(state.contains("enabled=false")); } #[test] diff --git a/crates/cli/tests/coverage/plugin_host_tests.rs b/crates/cli/tests/coverage/plugin_host_tests.rs index 33338b671..df99bb815 100644 --- a/crates/cli/tests/coverage/plugin_host_tests.rs +++ b/crates/cli/tests/coverage/plugin_host_tests.rs @@ -21,6 +21,24 @@ use crate::config::{BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey}; const TEST_PLUGIN_GENERATION: &str = "test-generation"; +#[cfg(unix)] +fn wait_for_published_pid(path: &Path, process: &str) -> i32 { + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if let Some(pid) = fs::read_to_string(path) + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + { + return pid; + } + assert!( + Instant::now() < deadline, + "{process} did not publish a complete PID" + ); + thread::sleep(Duration::from_millis(10)); + } +} + #[derive(Default)] struct FakeCodexHooksClient { hook_lists: VecDeque, String>>, @@ -2881,15 +2899,7 @@ fn failed_sidecar_startup_terminates_the_detached_process_group() { .arg(&grandchild_pid_path); configure_detached_sidecar(&mut command); let mut child = command.spawn().unwrap(); - let deadline = Instant::now() + Duration::from_secs(3); - while !grandchild_pid_path.exists() && Instant::now() < deadline { - thread::sleep(Duration::from_millis(10)); - } - let grandchild_pid = fs::read_to_string(&grandchild_pid_path) - .unwrap() - .trim() - .parse::() - .unwrap(); + let grandchild_pid = wait_for_published_pid(&grandchild_pid_path, "sidecar grandchild"); assert!(!child.wait().unwrap().success()); terminate_sidecar_process_tree(&mut child); @@ -3016,15 +3026,7 @@ fn sidecar_reaper_terminates_descendants_left_by_an_exited_gateway() { .unwrap(); drop(endpoint_lock); - let deadline = Instant::now() + Duration::from_secs(3); - while !descendant_pid_path.exists() && Instant::now() < deadline { - thread::sleep(Duration::from_millis(10)); - } - let descendant_pid = fs::read_to_string(&descendant_pid_path) - .unwrap() - .trim() - .parse::() - .unwrap(); + let descendant_pid = wait_for_published_pid(&descendant_pid_path, "sidecar descendant"); let deadline = Instant::now() + Duration::from_secs(3); loop { // SAFETY: Signal 0 only checks whether the finite test process is still present. diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index 35f87c476..f74d43626 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::{HashMap, VecDeque}; use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -99,6 +99,15 @@ fn readiness_worker_returns_a_report_and_handles_channel_disconnects() { .details .contains("collector stopped unexpectedly") ); + + let hermes = failed_host_plugin_readiness( + IntegrationHost::Hermes, + dir.path().join("config.yaml"), + "fixture failure", + ); + assert!(hermes.marketplace.is_none()); + assert!(hermes.plugin.is_none()); + assert!(!hermes.ok()); } #[test] @@ -332,6 +341,30 @@ impl<'a> HomeScope<'a> { prev_hermes_home, } } + + fn without_home() -> Self { + let guard = plugin_install_env_lock() + .lock() + .unwrap_or_else(|error| error.into_inner()); + let prev_home = std::env::var_os("HOME"); + let prev_userprofile = std::env::var_os("USERPROFILE"); + let prev_codex_home = std::env::var_os("CODEX_HOME"); + let prev_hermes_home = std::env::var_os("HERMES_HOME"); + // SAFETY: This test holds a process-wide mutex for the lifetime of the env override. + unsafe { + std::env::remove_var("HOME"); + std::env::remove_var("USERPROFILE"); + std::env::remove_var("CODEX_HOME"); + std::env::remove_var("HERMES_HOME"); + } + Self { + _guard: guard, + prev_home, + prev_userprofile, + prev_codex_home, + prev_hermes_home, + } + } } impl Drop for HomeScope<'_> { @@ -358,6 +391,15 @@ impl Drop for HomeScope<'_> { } } +#[test] +fn plugin_operation_lock_directory_requires_a_user_home() { + let _home = HomeScope::without_home(); + + let error = default_operation_lock_dir().unwrap_err(); + + assert!(error.contains("set HOME or USERPROFILE"), "{error}"); +} + struct PathScope<'a> { _guard: std::sync::MutexGuard<'a, ()>, previous: Option, @@ -664,6 +706,64 @@ struct BlockingRefreshFailure { continue_refresh: std::sync::mpsc::Receiver<()>, } +struct FailStateWriteAfterRefresh { + state_path: PathBuf, + injected: Cell, +} + +impl PluginSetupRunner for FailStateWriteAfterRefresh { + fn snapshot(&self, _host: IntegrationHost) -> Result, String> { + Ok(Some(PluginSetupSnapshot::Mock)) + } + + fn restore_snapshot(&self, _snapshot: &PluginSetupSnapshot) -> Result<(), String> { + Ok(()) + } + + fn refresh_gateway(&self) -> Result<(), String> { + if !self.injected.replace(true) { + crate::file_io::fail_next_atomic_write(&self.state_path); + } + Ok(()) + } + + fn setup( + &self, + _host: IntegrationHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn uninstall( + &self, + _host: IntegrationHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor( + &self, + _host: IntegrationHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor_json( + &self, + _host: IntegrationHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result { + Ok(json!({"ok": true, "checks": {}})) + } +} + impl PluginSetupRunner for BlockingRefreshFailure { fn snapshot(&self, _host: IntegrationHost) -> Result, String> { Ok(Some(PluginSetupSnapshot::Mock)) @@ -2072,6 +2172,62 @@ fn hermes_doctor_probes_the_configured_relay_and_top_level_doctor_discovers_it() assert_eq!(hermes.state_path, config); assert!(hermes.marketplace.is_none()); assert!(hermes.plugin.is_none()); + + doctor_hermes_host(&options(dir.path()), &runner).unwrap(); + + std::fs::remove_file(&configured_relay).unwrap(); + let error = doctor_hermes_host(&options(dir.path()), &runner).unwrap_err(); + assert!(error.contains("doctor checks failed"), "{error}"); + let report = doctor_hermes_json_value(&options(dir.path()), &runner).unwrap(); + let failed = report["readiness_checks"].as_array().unwrap(); + for expected in [ + "Configured Relay binary", + "Relay hook support", + "Relay MCP support", + ] { + assert!( + failed + .iter() + .any(|check| check["name"] == expected && check["ok"] == json!(false)), + "missing failed {expected} check: {failed:?}" + ); + } +} + +#[test] +fn hermes_install_and_uninstall_dry_runs_preserve_persistent_state() { + let dir = tempdir().unwrap(); + let home = dir.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let _home = HomeScope::enter(&home); + let mut dry_run = options(dir.path()); + dry_run.dry_run = true; + dry_run.skip_doctor = false; + let runner = MockRunner::default(); + + install_hermes_host(&dry_run, &runner).unwrap(); + let config = hermes_config_path().unwrap(); + assert!(!config.exists()); + + let hermes_home = config.parent().unwrap(); + std::fs::create_dir_all(hermes_home).unwrap(); + let allowlist = hermes_home.join("shell-hooks-allowlist.json"); + let generation = hermes_home.join(GENERATION_FILE_NAME); + let sentinels = [ + (&config, b"sentinel config\n".as_slice()), + (&allowlist, b"sentinel allowlist\n".as_slice()), + (&generation, b"sentinel generation\n".as_slice()), + ]; + for (path, contents) in sentinels { + std::fs::write(path, contents).unwrap(); + } + + uninstall_hermes_host(&dry_run).unwrap(); + + for (path, contents) in sentinels { + assert_eq!(std::fs::read(path).unwrap(), contents); + } + assert!(runner.quiet_commands().is_empty()); } #[test] @@ -3266,6 +3422,233 @@ fn force_install_restores_previous_install_after_doctor_failure() { })); } +#[test] +fn force_install_restores_previous_install_after_state_write_failure() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let install_options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let sentinel = layout.plugin_root.join("previous-install"); + std::fs::write(&sentinel, "preserve").unwrap(); + let original_state = std::fs::read(&layout.state_path).unwrap(); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + let setup_runner = FailStateWriteAfterRefresh { + state_path: layout.state_path.clone(), + injected: Cell::new(false), + }; + + let error = install_host( + IntegrationHost::Codex, + &install_options, + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("injected test failure"), "{error}"); + assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "preserve"); + assert_eq!(std::fs::read(&layout.state_path).unwrap(), original_state); + previous.verify_current().unwrap(); + assert_no_force_replacement_residue(dir.path()); +} + +#[test] +fn first_install_cleans_generated_marketplace_after_state_write_failure() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + crate::file_io::fail_next_atomic_write(&layout.state_path); + + let error = install_host( + IntegrationHost::Codex, + &options(dir.path()), + &runner, + &MockSetupRunner::default(), + ) + .unwrap_err(); + + assert!(error.contains("injected test failure"), "{error}"); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!(!layout.generation_lock.exists()); +} + +#[test] +fn force_replacement_restoration_aggregates_independent_cleanup_failures() { + let dir = tempdir().unwrap(); + let install_file = dir.path().join("install-file"); + std::fs::write(&install_file, "not a directory").unwrap(); + let layout = PluginLayout::new(IntegrationHost::Codex, &install_file); + let original_marketplace_root = dir.path().join("original-marketplace"); + let original_plugin_root = dir.path().join("original-plugin"); + let mut snapshot = ForceInstallSnapshot { + state_bytes: Some(b"original state".to_vec()), + setup_snapshot: Some(PluginSetupSnapshot::Mock), + original_marketplace_root: original_marketplace_root.clone(), + original_plugin_root: original_plugin_root.clone(), + original_generation_fence: original_plugin_root.join(GENERATION_FILE_NAME), + plugin_registered: false, + marketplace_registered: false, + backup_marketplace_root: dir.path().join("missing-marketplace-backup"), + backup_plugin_root: Some(dir.path().join("missing-plugin-backup")), + marketplace_moved: true, + plugin_moved: true, + replacement_promoted: true, + generation_retirement: None, + }; + let mut runner = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + runner.failing_suffixes = vec![ + "plugin remove nemo-relay-plugin@nemo-relay-local".into(), + "plugin marketplace remove nemo-relay-local".into(), + ]; + let setup_runner = MockSetupRunner { + failing_call: Some("restore snapshot".into()), + ..MockSetupRunner::default() + }; + + let error = restore_force_replacement_after_error::<()>( + IntegrationHost::Codex, + &layout, + &mut snapshot, + &options(&install_file), + &runner, + &setup_runner, + "replacement failed".into(), + ) + .unwrap_err(); + + assert!(error.contains("replacement failed"), "{error}"); + assert!( + error.contains("failed to restore previous install"), + "{error}" + ); + assert!(error.contains("plugin remove"), "{error}"); + assert!(error.contains("plugin marketplace remove"), "{error}"); + assert!(error.contains("failed to restore marketplace"), "{error}"); + assert!(error.contains("failed to restore plugin root"), "{error}"); + assert!(error.contains("restore snapshot failed"), "{error}"); + assert!(error.contains("failed to restore"), "{error}"); +} + +#[test] +fn force_replacement_restoration_reports_failed_host_reregistration() { + let dir = tempdir().unwrap(); + let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let original_marketplace_root = dir.path().join("original-marketplace"); + let mut snapshot = ForceInstallSnapshot { + state_bytes: None, + setup_snapshot: None, + original_marketplace_root: original_marketplace_root.clone(), + original_plugin_root: original_marketplace_root.join("plugins/nemo-relay-plugin"), + original_generation_fence: original_marketplace_root.join(GENERATION_FILE_NAME), + plugin_registered: true, + marketplace_registered: true, + backup_marketplace_root: dir.path().join("unused-marketplace-backup"), + backup_plugin_root: None, + marketplace_moved: false, + plugin_moved: false, + replacement_promoted: false, + generation_retirement: None, + }; + let mut runner = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + runner.failing_suffixes = vec![ + format!( + "plugin marketplace add {}", + original_marketplace_root.display() + ), + "plugin add nemo-relay-plugin@nemo-relay-local".into(), + ]; + + let error = restore_force_replacement( + IntegrationHost::Codex, + &layout, + &mut snapshot, + &options(dir.path()), + &runner, + &MockSetupRunner::default(), + ) + .unwrap_err(); + + assert!(error.contains("plugin marketplace add"), "{error}"); + assert!(error.contains("plugin add"), "{error}"); +} + +#[test] +fn force_replacement_moves_and_restores_a_separate_plugin_tree() { + let dir = tempdir().unwrap(); + let previous_marketplace_root = dir.path().join("previous-marketplace"); + let previous_plugin_root = dir.path().join("relocated-plugin"); + std::fs::create_dir_all(&previous_marketplace_root).unwrap(); + std::fs::create_dir_all(&previous_plugin_root).unwrap(); + std::fs::write( + previous_marketplace_root.join("marketplace.json"), + "marketplace", + ) + .unwrap(); + std::fs::write(previous_plugin_root.join("plugin.json"), "plugin").unwrap(); + let target = PluginLayout::new(IntegrationHost::Codex, &dir.path().join("target")); + let preflight = PluginInstallPreflight { + persisted: None, + state_bytes: None, + previous_marketplace_root: previous_marketplace_root.clone(), + previous_plugin_root: previous_plugin_root.clone(), + previous_generation_fence: previous_plugin_root.join(GENERATION_FILE_NAME), + plugin_registered: false, + marketplace_registered: false, + previous_setup_installed: false, + previous_install_exists: true, + generation_retirement: None, + }; + let setup_runner = MockSetupRunner::default(); + let runner = MockRunner::default().with_executable("codex", "/bin/codex"); + let mut snapshot = begin_force_replacement( + IntegrationHost::Codex, + &target, + preflight, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!(snapshot.marketplace_moved); + assert!(snapshot.plugin_moved); + assert!(!previous_marketplace_root.exists()); + assert!(!previous_plugin_root.exists()); + + restore_force_replacement( + IntegrationHost::Codex, + &target, + &mut snapshot, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert_eq!( + std::fs::read_to_string(previous_marketplace_root.join("marketplace.json")).unwrap(), + "marketplace" + ); + assert_eq!( + std::fs::read_to_string(previous_plugin_root.join("plugin.json")).unwrap(), + "plugin" + ); +} + #[test] fn force_install_cleans_only_the_previous_setup_after_replacement_setup_failure() { let dir = tempdir().unwrap(); @@ -4520,6 +4903,80 @@ fn generated_codex_mcp_check_rejects_malformed_or_unapproved_environment_superse } } +#[test] +fn generated_mcp_check_rejects_host_shape_and_non_environment_drift() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".mcp.json"); + let expected = json!({ + "nemo-relay": { + "command": "/bin/nemo-relay", + "args": ["mcp"], + "env_vars": ["OPENAI_API_KEY"] + } + }); + + let mut wrong_command = expected.clone(); + wrong_command["nemo-relay"]["command"] = json!("/bin/foreign-relay"); + write_json(&path, &wrong_command).unwrap(); + let error = + generated_mcp_config_check(IntegrationHost::ClaudeCode, &path, &expected).unwrap_err(); + assert!(error.contains("install claude-code --force"), "{error}"); + + let expected_without_vars = json!({ + "nemo-relay": { + "command": "/bin/nemo-relay", + "args": ["mcp"] + } + }); + write_json(&path, &wrong_command).unwrap(); + let error = generated_mcp_config_check(IntegrationHost::Codex, &path, &expected_without_vars) + .unwrap_err(); + assert!(error.contains("unexpected MCP server manifest"), "{error}"); + + let mut actual_without_vars = expected.clone(); + actual_without_vars["nemo-relay"] + .as_object_mut() + .unwrap() + .remove("env_vars"); + write_json(&path, &actual_without_vars).unwrap(); + let error = generated_mcp_config_check(IntegrationHost::Codex, &path, &expected).unwrap_err(); + assert!(error.contains("unexpected MCP server manifest"), "{error}"); + + write_json(&path, &wrong_command).unwrap(); + let error = generated_mcp_config_check(IntegrationHost::Codex, &path, &expected).unwrap_err(); + assert!(error.contains("unexpected MCP server manifest"), "{error}"); + assert!(error.contains("install codex --force"), "{error}"); +} + +#[test] +fn legacy_claude_manifest_inspection_distinguishes_absent_unreadable_and_malformed_files() { + let dir = tempdir().unwrap(); + let plugin_root = dir.path().join("plugin"); + std::fs::create_dir_all(&plugin_root).unwrap(); + assert!(!legacy_plugin_without_mcp(IntegrationHost::ClaudeCode, &plugin_root).unwrap()); + + let manifest = plugin_manifest_path(IntegrationHost::ClaudeCode, &plugin_root); + std::fs::create_dir_all(&manifest).unwrap(); + let error = legacy_plugin_without_mcp(IntegrationHost::ClaudeCode, &plugin_root).unwrap_err(); + assert!( + error.contains("failed to inspect legacy plugin manifest"), + "{error}" + ); + + std::fs::remove_dir(&manifest).unwrap(); + std::fs::write(&manifest, "{not-json").unwrap(); + let error = legacy_plugin_without_mcp(IntegrationHost::ClaudeCode, &plugin_root).unwrap_err(); + assert!( + error.contains("failed to inspect legacy plugin manifest"), + "{error}" + ); + + std::fs::write(&manifest, r#"{"name":"legacy"}"#).unwrap(); + assert!(legacy_plugin_without_mcp(IntegrationHost::ClaudeCode, &plugin_root).unwrap()); + std::fs::write(&manifest, r#"{"mcpServers":{}}"#).unwrap(); + assert!(!legacy_plugin_without_mcp(IntegrationHost::ClaudeCode, &plugin_root).unwrap()); +} + #[test] fn readiness_report_names_newly_required_mcp_env_vars_and_force_remediation() { let dir = tempdir().unwrap(); From b1d15f0bde048f35d167902b984eee13a90112c6 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 05:19:29 -0400 Subject: [PATCH 012/102] test(cli): verify full bootstrap patch coverage Signed-off-by: Will Killian --- crates/cli/tests/coverage/doctor_tests.rs | 56 +++++++++++++ crates/cli/tests/coverage/hermes_tests.rs | 53 ++++++++++++ crates/cli/tests/coverage/mcp_tests.rs | 58 +++++++++++++ .../tests/coverage/plugins_lifecycle_tests.rs | 81 +++++++++++++++++++ 4 files changed, 248 insertions(+) diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index 2d2468f74..c41b903ae 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -747,6 +747,62 @@ async fn collect_agents_preserves_wrapper_argv_for_version_validation() { assert_eq!(agents[0].version.as_deref(), Some("codex-cli 0.143.0")); } +#[cfg(unix)] +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn collect_agents_distinguishes_required_and_optional_version_failures() { + let temp = tempfile::tempdir().unwrap(); + let codex = temp.path().join("codex"); + std::fs::write(&codex, "#!/bin/sh\nprintf 'codex-cli 0.1.0\\n'\n").unwrap(); + make_executable(&codex); + + let mut configured = ResolvedConfig::default(); + configured.agents.codex.command = Some(codex.display().to_string()); + let required = collect_agents(Some(CodingAgent::Codex), &configured).await; + assert_eq!(required[0].status, Status::Fail); + assert!(required[0].annotation.contains("is unsupported")); + + let _environment = EnvScope::set(&[("PATH", Some(temp.path().as_os_str()))]); + let discovered = collect_agents(None, &ResolvedConfig::default()).await; + let optional = discovered + .iter() + .find(|agent| agent.name == "codex") + .unwrap(); + assert_eq!(optional.status, Status::Warn); + assert!(optional.annotation.contains("is unsupported")); + + std::fs::write(&codex, "#!/bin/sh\nexit 0\n").unwrap(); + make_executable(&codex); + let required = collect_agents(Some(CodingAgent::Codex), &configured).await; + assert_eq!(required[0].status, Status::Fail); + assert!( + required[0] + .annotation + .contains("could not determine version") + ); + + let discovered = collect_agents(None, &ResolvedConfig::default()).await; + let optional = discovered + .iter() + .find(|agent| agent.name == "codex") + .unwrap(); + assert_eq!(optional.status, Status::Warn); + assert!(optional.annotation.contains("could not determine version")); +} + +#[test] +fn hermes_hook_status_reports_actionable_persistent_diagnosis_failures() { + let temp = tempfile::tempdir().unwrap(); + let mut agents = AgentConfigs::default(); + agents.hermes.hooks_path = Some(temp.path().join("missing-config.yaml")); + + let (status, details) = hook_status(CodingAgent::Hermes, &agents); + + assert_eq!(status, Status::Fail); + assert!(details.contains("persistent MCP/hooks"), "{details}"); + assert!(details.contains("install hermes --force"), "{details}"); +} + #[cfg(unix)] #[tokio::test] async fn probe_version_returns_none_for_empty_output_and_spawn_failures() { diff --git a/crates/cli/tests/coverage/hermes_tests.rs b/crates/cli/tests/coverage/hermes_tests.rs index c3d8b3bca..abe2cdf97 100644 --- a/crates/cli/tests/coverage/hermes_tests.rs +++ b/crates/cli/tests/coverage/hermes_tests.rs @@ -451,6 +451,59 @@ fn verification_rejects_relay_handlers_and_approvals_on_unexpected_events() { assert!(error.contains("missing its event")); } +#[test] +fn hermes_structure_and_trust_validation_cover_exact_failure_shapes() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let generation = temp.path().join(GENERATION_FILE_NAME); + let command = persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); + + let error = trusted_hooks(Some(r#"{"approvals": {}}"#), &command, &relay, UNIX_EPOCH) + .unwrap_err() + .to_string(); + assert!(error.contains("approvals must be an array"), "{error}"); + + let error = parse_json_object(Some("[]"), "test allowlist") + .unwrap_err() + .to_string(); + assert!(error.contains("must contain a JSON object"), "{error}"); + + let mut malformed_hooks = json!({"hooks": {"on_session_start": {}}}); + let error = strip_managed_hooks(&mut malformed_hooks) + .unwrap_err() + .to_string(); + assert!( + error.contains("on_session_start hooks must be an array"), + "{error}" + ); + + let error = parse_yaml_object(Some("[]"), "test config") + .unwrap_err() + .to_string(); + assert!(error.contains("must contain an object"), "{error}"); + assert!(is_managed_hook_command( + "nemo-relay hook-forward hermes --fail-closed" + )); + + let path = temp.path().join("shell-hooks-allowlist.json"); + let mut missing = trusted_hooks(None, &command, &relay, UNIX_EPOCH).unwrap(); + missing["approvals"].as_array_mut().unwrap().remove(0); + std::fs::write(&path, serde_json::to_vec(&missing).unwrap()).unwrap(); + let error = verify_trust(&path, &command).unwrap_err(); + assert!( + error.contains("expected exactly one trust approval"), + "{error}" + ); + + let mut with_opaque_entry = trusted_hooks(None, &command, &relay, UNIX_EPOCH).unwrap(); + with_opaque_entry["approvals"] + .as_array_mut() + .unwrap() + .push(json!({"metadata": "unrelated"})); + std::fs::write(&path, serde_json::to_vec(&with_opaque_entry).unwrap()).unwrap(); + verify_trust(&path, &command).unwrap(); +} + #[test] fn install_is_verified_idempotent_and_rotates_the_generation() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/mcp_tests.rs b/crates/cli/tests/coverage/mcp_tests.rs index 24e663e23..49194a075 100644 --- a/crates/cli/tests/coverage/mcp_tests.rs +++ b/crates/cli/tests/coverage/mcp_tests.rs @@ -43,6 +43,64 @@ impl Drop for BootstrapConfigHome { } } +struct TransparentRunEnvironment { + _guard: std::sync::MutexGuard<'static, ()>, + previous_run: Option, + previous_gateway: Option, +} + +impl TransparentRunEnvironment { + fn without_gateway() -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous_run = std::env::var_os(crate::config::TRANSPARENT_RUN_ENV); + let previous_gateway = std::env::var_os(crate::config::GATEWAY_URL_ENV); + // SAFETY: This scope holds the process-wide environment mutex. + unsafe { + std::env::set_var(crate::config::TRANSPARENT_RUN_ENV, "1"); + std::env::remove_var(crate::config::GATEWAY_URL_ENV); + } + Self { + _guard: guard, + previous_run, + previous_gateway, + } + } +} + +impl Drop for TransparentRunEnvironment { + fn drop(&mut self) { + // SAFETY: This restores the process environment while the mutex remains held. + unsafe { + match self.previous_run.take() { + Some(value) => std::env::set_var(crate::config::TRANSPARENT_RUN_ENV, value), + None => std::env::remove_var(crate::config::TRANSPARENT_RUN_ENV), + } + match self.previous_gateway.take() { + Some(value) => std::env::set_var(crate::config::GATEWAY_URL_ENV, value), + None => std::env::remove_var(crate::config::GATEWAY_URL_ENV), + } + } + } +} + +#[tokio::test] +async fn transparent_mcp_requires_the_wrapper_gateway_url() { + let _environment = TransparentRunEnvironment::without_gateway(); + + let error = run(&crate::config::ServerArgs::default()) + .await + .unwrap_err() + .to_string(); + + assert!(error.contains(crate::config::GATEWAY_URL_ENV), "{error}"); + assert!( + error.contains(crate::config::TRANSPARENT_RUN_ENV), + "{error}" + ); +} + #[test] fn bounded_mcp_reader_accepts_the_limit_and_preserves_following_frames() { let mut input = vec![b'a'; MAX_MCP_FRAME_BYTES - 1]; diff --git a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs index 846aa366c..282392ba2 100644 --- a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs @@ -1661,6 +1661,87 @@ fn python_activation_snapshot_is_attested_copied_and_tamper_evident() { assert!(error.contains("failed authentication"), "{error}"); } +#[test] +fn python_entrypoint_validation_reports_each_authored_contract_error() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugin"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest_path = write_python_dynamic_manifest(&plugin_dir, "acme.python-validation"); + let (manifest, manifest_ref) = DynamicPluginManifest::load_from_path(&manifest_path).unwrap(); + + let mut missing_source = manifest.clone(); + missing_source.source = None; + let error = environment::validate_python_entrypoint_artifact(&missing_source, &manifest_ref) + .unwrap_err(); + assert!( + error.contains("must declare source.manifest_root"), + "{error}" + ); + + let mut missing_separator = manifest.clone(); + let DynamicPluginManifestLoad::Worker(load) = &mut missing_separator.load else { + panic!("fixture must be a worker plugin"); + }; + load.entrypoint = Some("plugin".into()); + let error = environment::validate_python_entrypoint_artifact(&missing_separator, &manifest_ref) + .unwrap_err(); + assert!(error.contains("module:function form"), "{error}"); + + let mut empty_module = manifest.clone(); + let DynamicPluginManifestLoad::Worker(load) = &mut empty_module.load else { + panic!("fixture must be a worker plugin"); + }; + load.entrypoint = Some(":main".into()); + let error = + environment::validate_python_entrypoint_artifact(&empty_module, &manifest_ref).unwrap_err(); + assert!(error.contains("module:function form"), "{error}"); + + let mut missing_root = manifest; + missing_root + .source + .as_mut() + .expect("fixture declares source") + .manifest_root = Some("missing-root".into()); + let error = + environment::validate_python_entrypoint_artifact(&missing_root, &manifest_ref).unwrap_err(); + assert!( + error.contains("could not resolve Python plugin source.manifest_root"), + "{error}" + ); +} + +#[test] +fn python_environment_attestation_rejects_invalid_json_and_source_identity_drift() { + let temp = tempfile::tempdir().unwrap(); + let environment_path = temp.path().join("environment"); + std::fs::create_dir_all(&environment_path).unwrap(); + let attestation_path = environment_path.join(environment::ENVIRONMENT_ATTESTATION_FILE); + + std::fs::write(&attestation_path, "{not-json").unwrap(); + let error = + environment::read_environment_attestation(&environment_path, "expected").unwrap_err(); + assert!(error.contains("attestation"), "{error}"); + assert!(error.contains("is invalid"), "{error}"); + + std::fs::write( + &attestation_path, + serde_json::to_vec(&serde_json::json!({ + "version": 1, + "source_artifact_sha256": "different", + "environment_sha256": "0".repeat(64), + "authentication": "unused" + })) + .unwrap(), + ) + .unwrap(); + let error = + environment::read_environment_attestation(&environment_path, "expected").unwrap_err(); + assert!( + error.contains("does not match the trusted source artifact"), + "{error}" + ); +} + #[test] fn add_registers_dynamic_plugin_in_project_plugins_toml() { let temp = tempfile::tempdir().unwrap(); From d93025eb00a87fda1e9119598d92db71c636aeaa Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 10:16:17 -0400 Subject: [PATCH 013/102] refactor(cli): simplify shared gateway bootstrap Signed-off-by: Will Killian --- crates/cli/src/installer.rs | 238 +---- crates/cli/src/main.rs | 4 - crates/cli/src/mcp/gateway.rs | 38 +- crates/cli/src/server.rs | 3 +- crates/cli/src/sidecar.rs | 935 ++++-------------- crates/cli/src/sidecar/health.rs | 23 - crates/cli/src/sidecar/process.rs | 402 +------- crates/cli/src/sidecar/state.rs | 914 +++-------------- crates/cli/tests/cli_tests.rs | 71 +- crates/cli/tests/coverage/installer_tests.rs | 109 +- .../cli/tests/coverage/mcp_gateway_tests.rs | 136 +-- .../cli/tests/coverage/plugin_host_tests.rs | 662 +------------ .../cli/tests/coverage/sidecar_state_tests.rs | 765 ++++---------- crates/cli/tests/coverage/sidecar_tests.rs | 611 ++++-------- .../release-notes/known-issues.mdx | 8 +- docs/nemo-relay-cli/basic-usage.mdx | 6 +- docs/nemo-relay-cli/claude-code.mdx | 7 +- docs/nemo-relay-cli/codex.mdx | 19 +- docs/nemo-relay-cli/hermes.mdx | 13 +- docs/nemo-relay-cli/plugin-installation.mdx | 40 +- 20 files changed, 850 insertions(+), 4154 deletions(-) diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 899187e71..e73efea6c 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -14,11 +14,10 @@ use crate::config::{ CodingAgent, GATEWAY_URL_ENV, GatewayMode, HookForwardCommand, TRANSPARENT_RUN_ENV, }; use crate::error::CliError; -use crate::install_generation::{ActiveGenerationGuard, InstallGeneration}; +use crate::install_generation::InstallGeneration; const HOOK_FORWARD_TIMEOUT: Duration = Duration::from_secs(2); const HOOK_GATEWAY_RETRY_TIMEOUT: Duration = Duration::from_secs(20); -const HOOK_GATEWAY_RETRY_INTERVAL: Duration = Duration::from_millis(50); const MAX_HOOK_RESPONSE_BYTES: usize = 1024 * 1024; /// Forwards a hook payload from an installed shell command to a running gateway. @@ -53,7 +52,9 @@ pub(crate) async fn hook_forward(command: HookForwardCommand) -> Result<(), CliE Ok(gateway) => gateway, Err(error) => return handle_hook_error(error, fail_closed), }; - let mut generation_guard = if destination.lifecycle == HookGatewayLifecycle::Recover { + let _generation_guard = if destination.lifecycle == HookGatewayLifecycle::Existing + && !command.forward_only + { let install_host = command.agent.install_arg(); let Some(generation_file) = command.generation_file.clone() else { return handle_hook_error( @@ -80,18 +81,6 @@ pub(crate) async fn hook_forward(command: HookForwardCommand) -> Result<(), CliE } else { None }; - if destination.lifecycle == HookGatewayLifecycle::Existing { - let gateway = persistent - .as_ref() - .expect("existing persistent destinations resolve a gateway") - .gateway - .clone(); - if let Err(error) = - wait_for_existing_gateway(gateway, destination.gateway_url.clone()).await - { - return handle_hook_error(error, fail_closed); - } - } let input = match read_hook_payload( persistent .as_ref() @@ -102,67 +91,26 @@ pub(crate) async fn hook_forward(command: HookForwardCommand) -> Result<(), CliE Ok(input) => input, Err(error) => return handle_hook_error(error, fail_closed), }; - // Keep this short-lived lease through delivery. Hooks therefore share the same endpoint - // recovery cohort as overlapping MCP clients instead of creating unaccounted replacements. - let mut gateway_acquisition = if destination.lifecycle == HookGatewayLifecycle::Recover { - let launch = persistent + if destination.lifecycle == HookGatewayLifecycle::Existing { + let gateway = persistent .as_ref() - .expect("recoverable destinations resolve a gateway"); - let generation_guard = generation_guard - .take() - .expect("recoverable destinations have an install generation"); - match acquire_hook_gateway(launch.gateway.clone(), generation_guard).await { - Ok(acquisition) => Some(acquisition), - Err(error) => return handle_hook_error(error, fail_closed), + .expect("existing persistent destinations resolve a gateway") + .gateway + .clone(); + if let Err(error) = + wait_for_existing_gateway(gateway, destination.gateway_url.clone()).await + { + return handle_hook_error(error, fail_closed); } - } else { - None - }; + } let verified_gateway = persistent .as_ref() .map(|launch| &launch.gateway) .or(transparent_gateway.as_ref()); if let Some(gateway) = verified_gateway { - let mut response = send_verified_hook_forward_request( - &command, - gateway, - &destination.gateway_url, - input.clone(), - ) - .await?; - if response - .as_ref() - .is_err_and(crate::sidecar::VerifiedHttpError::is_retryable) - && destination.lifecycle == HookGatewayLifecycle::Recover - { - let acquisition = gateway_acquisition - .as_mut() - .expect("recoverable destinations hold a gateway acquisition"); - if let Err(start_error) = recover_hook_gateway(acquisition).await { - let transport_error = response - .as_ref() - .expect_err("recovery only follows a retryable transport error"); - let error = format!( - "nemo-relay hook forward failed: {transport_error}; sidecar recovery failed: {start_error}" - ); - eprintln!("{error}"); - return if fail_closed { - Err(CliError::Install(error)) - } else { - Ok(()) - }; - } - let recovered_gateway = persistent - .as_ref() - .expect("recoverable destinations resolve a persistent gateway"); - response = send_verified_hook_forward_request( - &command, - &recovered_gateway.gateway, - &destination.gateway_url, - input, - ) - .await?; - } + let response = + send_verified_hook_forward_request(&command, gateway, &destination.gateway_url, input) + .await?; return handle_verified_hook_forward_response(response, fail_closed); } @@ -214,15 +162,12 @@ struct HookDestination { enum HookGatewayLifecycle { /// A transparent run owns the dynamic gateway and passes its URL through the environment. Transparent, - /// A source plugin may use an authenticated shared gateway but never start or recover one. + /// Persistent hooks use the authenticated gateway started and maintained by MCP. Existing, - /// An installed, generation-fenced hook participates in shared gateway recovery. - Recover, } -// Installed hooks use the shared fixed gateway and may recover it. Transparent runs set the -// dynamic environment URL and already own that gateway's lifecycle, so hook subprocesses never -// replace it with a persistent sidecar. +// Installed hooks use the shared fixed gateway that MCP owns. Transparent runs set the dynamic +// environment URL and already own that gateway's lifecycle. fn hook_destination(command: &HookForwardCommand) -> HookDestination { resolve_hook_destination( command.gateway_url.clone(), @@ -259,7 +204,7 @@ fn resolve_hook_destination( if let Some(gateway_url) = command_url { return HookDestination { gateway_url, - lifecycle: HookGatewayLifecycle::Recover, + lifecycle: HookGatewayLifecycle::Existing, }; } if let Some(gateway_url) = environment_url { @@ -270,7 +215,7 @@ fn resolve_hook_destination( } HookDestination { gateway_url: crate::sidecar::DEFAULT_URL.into(), - lifecycle: HookGatewayLifecycle::Recover, + lifecycle: HookGatewayLifecycle::Existing, } } @@ -288,7 +233,7 @@ async fn wait_for_existing_gateway( } Ok(None) => { return Err(format!( - "no compatible Relay gateway became ready at {gateway_url}; start the plugin's `nemo-relay mcp` bootstrap before using --forward-only" + "no compatible Relay gateway became ready at {gateway_url}; ensure the host started `nemo-relay mcp`" )); } Err(error) => return Err(error), @@ -311,143 +256,6 @@ fn transparent_gateway_spec(gateway_url: &str) -> Result Result { - tokio::task::spawn_blocking(move || { - let (gateway, cohort_guard) = acquire_pinned_hook_gateway(&gateway)?; - Ok(HookGatewayAcquisition { - gateway, - cohort_guard: Some(cohort_guard), - _generation_guard: generation_guard, - }) - }) - .await - .map_err(|error| CliError::Launch(format!("hook acquisition task failed: {error}")))? - .map_err(CliError::Launch) -} - -fn gateway_cohort_was_retired(error: &str) -> bool { - error.contains("was retired by an integration update") -} - -fn acquire_pinned_hook_gateway( - gateway: &crate::sidecar::GatewaySpec, -) -> Result< - ( - crate::sidecar::GatewayAcquisition, - crate::sidecar::GatewayCohortGuard, - ), - String, -> { - retry_retired_gateway_cohort(|| { - let acquisition = gateway.acquire()?; - let guard = acquisition.guard_cohort()?; - Ok((acquisition, guard)) - }) -} - -fn retry_retired_gateway_cohort( - operation: impl FnMut() -> Result, -) -> Result { - retry_retired_gateway_cohort_until(Instant::now() + HOOK_GATEWAY_RETRY_TIMEOUT, operation) -} - -fn retry_retired_gateway_cohort_until( - deadline: Instant, - operation: impl FnMut() -> Result, -) -> Result { - retry_retired_gateway_cohort_with_clock(deadline, Instant::now, std::thread::sleep, operation) -} - -fn retry_retired_gateway_cohort_with_clock( - deadline: Instant, - mut now: impl FnMut() -> Instant, - mut sleep: impl FnMut(Duration), - mut operation: impl FnMut() -> Result, -) -> Result { - loop { - match operation() { - Ok(value) => return Ok(value), - Err(error) if gateway_cohort_was_retired(&error) => { - let current = now(); - if current >= deadline { - return Err(error); - } - let remaining = deadline.saturating_duration_since(current); - sleep(HOOK_GATEWAY_RETRY_INTERVAL.min(remaining)); - } - Err(error) => return Err(error), - } - } -} - -struct HookGatewayAcquisition { - gateway: crate::sidecar::GatewayAcquisition, - // The shared endpoint guard blocks replacement while allowing concurrent deliveries; the - // generation guard separately prevents this host's installed command from being retired. - // Both remain held until the HTTP response is accepted or delivery fails. - cohort_guard: Option, - _generation_guard: ActiveGenerationGuard, -} - -#[cfg(test)] -fn hook_generation_guard(generation: &InstallGeneration) -> Result { - generation.guard_current() -} - -async fn recover_hook_gateway(acquisition: &mut HookGatewayAcquisition) -> Result<(), CliError> { - // Recovery itself needs the endpoint transaction lock. Release the delivery guard, then - // reacquire and validate the same cohort before the caller retries the HTTP request. - drop(acquisition.cohort_guard.take()); - let gateway = acquisition.gateway.spec.clone(); - let expected_instance = acquisition.gateway.endpoint.instance_id.clone(); - let gateway_url = acquisition.gateway.endpoint.url.clone(); - let cohort_id = acquisition.gateway.lease.cohort_id().to_string(); - let refreshed = tokio::task::spawn_blocking(move || { - let targeted = gateway - .recover(&expected_instance, &cohort_id) - .and_then(|endpoint| { - crate::sidecar::guard_gateway_cohort(&gateway_url, &cohort_id) - .map(|guard| (endpoint, guard)) - }); - match targeted { - Ok((endpoint, guard)) => Ok(HookGatewayRefresh::Recovered(endpoint, guard)), - Err(error) if gateway_cohort_was_retired(&error) => { - let (acquisition, guard) = acquire_pinned_hook_gateway(&gateway)?; - Ok(HookGatewayRefresh::Reacquired(acquisition, guard)) - } - Err(error) => Err(error), - } - }) - .await - .map_err(|error| CliError::Launch(format!("hook recovery task failed: {error}")))? - .map_err(CliError::Launch)?; - match refreshed { - HookGatewayRefresh::Recovered(endpoint, guard) => { - acquisition.gateway.endpoint = endpoint; - acquisition.cohort_guard = Some(guard); - } - HookGatewayRefresh::Reacquired(gateway, guard) => { - acquisition.gateway = gateway; - acquisition.cohort_guard = Some(guard); - } - } - Ok(()) -} - -enum HookGatewayRefresh { - Recovered( - crate::sidecar::GatewayEndpoint, - crate::sidecar::GatewayCohortGuard, - ), - Reacquired( - crate::sidecar::GatewayAcquisition, - crate::sidecar::GatewayCohortGuard, - ), -} - async fn send_verified_hook_forward_request( command: &HookForwardCommand, gateway: &crate::sidecar::GatewaySpec, diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 712ffd5ed..342026a4c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -66,10 +66,6 @@ fn main() -> ExitCode { // exit. Errors are printed once here so subcommands can return structured errors without also // owning process-level reporting. async fn async_main() -> ExitCode { - if let Err(error) = sidecar::join_sidecar_job_from_env() { - eprintln!("{error}"); - return ExitCode::FAILURE; - } match run().await { Ok(code) => code, Err(error) => { diff --git a/crates/cli/src/mcp/gateway.rs b/crates/cli/src/mcp/gateway.rs index 9e43bc6c8..d95073978 100644 --- a/crates/cli/src/mcp/gateway.rs +++ b/crates/cli/src/mcp/gateway.rs @@ -46,25 +46,16 @@ impl GatewayPlan { } pub(super) async fn acquire(mut self) -> Result { - let acquisition = acquire_gateway(self.spec.clone(), self.generation_guard.take()).await?; - let endpoint = acquisition.endpoint; - self.spec = acquisition.spec; - let monitor = tokio::spawn(async move { self.monitor(endpoint, acquisition.lease).await }); + let endpoint = acquire_gateway(self.spec.clone(), self.generation_guard.take()).await?; + let monitor = tokio::spawn(async move { self.monitor(endpoint).await }); Ok(GatewayLease { monitor }) } - async fn monitor( - self, - endpoint: crate::sidecar::GatewayEndpoint, - endpoint_lease: crate::sidecar::EndpointLease, - ) -> Result<(), CliError> { + async fn monitor(self, endpoint: crate::sidecar::GatewayEndpoint) -> Result<(), CliError> { let health_spec = self.spec.clone(); let restart_spec = self.spec.clone(); let restart_generation = self.generation.clone(); let verify_generation = self.generation; - let recovery_cohort = endpoint_lease.cohort_id().to_string(); - let verify_cohort = recovery_cohort.clone(); - let verify_url = endpoint.url.clone(); maintain_gateway_instances_with_generation( self.spec.bind(), endpoint, @@ -84,14 +75,11 @@ impl GatewayPlan { restart_spec.clone(), restart_generation.clone(), expected_instance, - recovery_cohort.clone(), ) }, move || { let generation = verify_generation.clone(); - let cohort = verify_cohort.clone(); - let url = verify_url.clone(); - async move { verify_lifecycle_async(generation, cohort, url).await } + async move { verify_lifecycle_async(generation).await } }, ) .await @@ -193,7 +181,7 @@ impl Drop for GatewayLease { async fn acquire_gateway( spec: GatewaySpec, generation_guard: Option, -) -> Result { +) -> Result { tokio::task::spawn_blocking(move || { let _generation_guard = generation_guard; spec.acquire() @@ -207,40 +195,28 @@ async fn recover_gateway( spec: GatewaySpec, generation: Option, expected_instance: String, - recovery_cohort: String, ) -> Result { tokio::task::spawn_blocking(move || { let _generation_guard = generation .as_ref() .map(InstallGeneration::guard_current) .transpose()?; - spec.recover(&expected_instance, &recovery_cohort) + spec.recover(&expected_instance) }) .await .map_err(|error| CliError::Launch(format!("gateway recovery task failed: {error}")))? .map_err(CliError::Launch) } -async fn verify_lifecycle_async( - generation: Option, - recovery_cohort: String, - gateway_url: String, -) -> Result<(), CliError> { +async fn verify_lifecycle_async(generation: Option) -> Result<(), CliError> { loop { let generation = generation.clone(); - let recovery_cohort = recovery_cohort.clone(); - let gateway_url = gateway_url.clone(); let current = tokio::task::spawn_blocking(move || { if let Some(generation) = generation.as_ref() && !generation.try_verify_current()? { return Ok(false); } - if crate::sidecar::check_gateway_cohort(&gateway_url, &recovery_cohort)? - == crate::sidecar::GatewayCohortStatus::UpdateInProgress - { - return Ok(false); - } Ok(true) }) .await diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index 02ff32f45..abbb051e6 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -250,7 +250,8 @@ async fn serve_listener_with_dynamic_inner( if let Some(identity) = managed_bootstrap.as_ref() { identity.verify_current()?; } - crate::sidecar::publish_sidecar_owner_from_env(local_address).map_err(CliError::Launch)?; + let _owner = + crate::sidecar::publish_sidecar_owner_from_env(local_address).map_err(CliError::Launch)?; if let Some(path) = ready_file { write_ready_file(path, local_address, &instance_id)?; } diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs index 7db6b3eb7..0821f8e77 100644 --- a/crates/cli/src/sidecar.rs +++ b/crates/cli/src/sidecar.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Shared native gateway sidecar lifecycle. +//! Thin bootstrap coordinator for the existing Relay gateway server. mod health; mod process; @@ -13,68 +13,40 @@ use std::fs::{self, OpenOptions}; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; -use std::sync::{OnceLock, mpsc}; use std::thread; use std::time::{Duration, Instant}; +use serde::Deserialize; + use crate::config::{ServerArgs, resolve_persistent_server_config}; use crate::error::CliError; -use crate::file_io::{LockAttempt, try_lock_exclusive, try_lock_shared}; -use health::{ - RelayHealth, probe_after_lock as probe_relay_health_after_lock, - probe_with_instance as probe_relay_health_with_instance, -}; +use health::{RelayHealth, probe_with_instance as probe_relay_health_with_instance}; pub(crate) use health::{ VerifiedHttpError, VerifiedHttpResponse, authenticated_instance_id, healthz, healthz_compatible, loopback_bind, }; -use process::DetachedSidecarProcess; -#[cfg(all(windows, not(test)))] -use process::SidecarJob; -#[cfg(all(test, windows))] -pub(crate) use process::SidecarJob; -pub(crate) use process::join_sidecar_job_from_env; -#[cfg(all(test, unix))] -pub(crate) use process::terminate_sidecar_process_tree; #[cfg(test)] pub(crate) use process::{ WINDOWS_CREATE_BREAKAWAY_FROM_JOB, WINDOWS_CREATE_NEW_PROCESS_GROUP, WINDOWS_CREATE_NO_WINDOW, - WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK, WINDOWS_JOB_OBJECT_LIMIT_KILL_ON_CLOSE, - WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, terminate_unready_sidecar, - windows_sidecar_creation_flags, + WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK, WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, + windows_creation_flags as windows_sidecar_creation_flags, }; -pub(crate) use process::{configure_detached_sidecar, spawn_detached_sidecar}; pub(crate) use state::BOOTSTRAP_STATE_DIR_ENV; -pub(crate) use state::EndpointLease; -use state::{ - RecoveryEpoch, create_private_runtime_dir, lock_endpoint_shared, - open_lock as open_sidecar_lock, read_owner_record, read_ready_file as read_sidecar_ready_file, - read_recovery_epoch, runtime_dir, validate_recovery_cohort, write_recovery_epoch, -}; -pub(crate) use state::{ - lock_endpoint as lock_sidecar_endpoint, lock_path as sidecar_lock_path, - owner_path as sidecar_owner_path, owner_paths as sidecar_owner_paths, - owner_pid_path as sidecar_owner_pid_path, pid_path as sidecar_pid_path, - state_dir as sidecar_state_dir, validate_owner as validate_sidecar_owner, -}; #[cfg(test)] +pub(crate) use state::lock_name as sidecar_lock_name; pub(crate) use state::{ - lock_endpoint_for as lock_sidecar_endpoint_for, lock_name as sidecar_lock_name, - runtime_dir_for, stop_owned_record as stop_owned_sidecar_record, - write_owner as write_sidecar_owner, -}; -pub(crate) use state::{ - publish_owner_from_env as publish_sidecar_owner_from_env, + publish_owner_from_env as publish_sidecar_owner_from_env, state_dir as sidecar_state_dir, stop_owned_and_reset as stop_owned_sidecar_and_reset, }; pub(crate) const DEFAULT_BIND: &str = "127.0.0.1:47632"; pub(crate) const DEFAULT_URL: &str = "http://127.0.0.1:47632"; pub(crate) const HEALTHZ_TIMEOUT: Duration = Duration::from_millis(500); +pub(crate) const BOOTSTRAP_PROTOCOL_VERSION: u64 = 2; pub(super) const SIDECAR_LOCK_TIMEOUT: Duration = Duration::from_secs(20); const SIDECAR_START_TIMEOUT: Duration = Duration::from_secs(10); -pub(crate) const BOOTSTRAP_PROTOCOL_VERSION: u64 = 2; + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct GatewayEndpoint { pub(crate) address: SocketAddr, @@ -82,38 +54,11 @@ pub(crate) struct GatewayEndpoint { pub(crate) instance_id: String, } -pub(crate) struct GatewayAcquisition { - pub(crate) endpoint: GatewayEndpoint, - pub(crate) lease: EndpointLease, - pub(crate) spec: GatewaySpec, -} - -/// Endpoint transaction lock retained while a hook request is in flight. -pub(crate) struct GatewayCohortGuard { - _lock: fs::File, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum GatewayCohortStatus { - Current, - UpdateInProgress, -} - -impl GatewayAcquisition { - pub(crate) fn guard_cohort(&self) -> Result { - guard_gateway_cohort(&self.endpoint.url, self.lease.cohort_id()) - } -} - -/// Complete launch and compatibility contract for one shared gateway. -/// -/// Callers construct this once and use the same value for discovery, startup, health checks, and -/// recovery. Keeping those inputs together prevents a gateway from being started with one -/// identity and later adopted under another. +/// Inputs required to identify and, when absent, start one persistent gateway. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct GatewaySpec { bind: SocketAddr, - sidecar_args: Vec, + launch_args: Vec, bootstrap_fingerprint: Option, user_config_scope: bool, } @@ -122,14 +67,14 @@ impl GatewaySpec { pub(crate) fn new(bind: SocketAddr) -> Self { Self { bind, - sidecar_args: Vec::new(), + launch_args: Vec::new(), bootstrap_fingerprint: None, user_config_scope: false, } } pub(crate) fn with_launch_args(mut self, args: Vec) -> Self { - self.sidecar_args = args; + self.launch_args = args; self } @@ -147,47 +92,26 @@ impl GatewaySpec { self.bind } - pub(crate) fn acquire(&self) -> Result { + /// Return the compatible gateway already bound at this endpoint, or start the existing server + /// under a per-user lock and wait for authenticated readiness. + pub(crate) fn acquire(&self) -> Result { acquire_gateway(self) } - #[cfg(test)] - pub(crate) fn acquire_with_lease( - &self, - lease: EndpointLease, - ) -> Result { - let url = format!("http://{}", self.bind); - let state = sidecar_state_dir()?; - acquire_fixed_gateway(self, &state, &url, lease) - } - - pub(crate) fn recover( - &self, - expected_instance: &str, - cohort_id: &str, - ) -> Result { - recover_gateway(self, expected_instance, cohort_id) + pub(crate) fn recover(&self, expected_instance: &str) -> Result { + recover_gateway(self, expected_instance) } pub(crate) fn healthy_instance(&self, url: &str) -> Option { health::compatible_instance_id(url, self.bootstrap_fingerprint.as_deref()) } - /// Checks an already-bound endpoint without starting or replacing it. - /// - /// `Ok(None)` means no listener is ready yet, which lets source-plugin hooks wait for their - /// concurrently starting MCP bootstrap. Identity or protocol failures are terminal so a - /// foreign listener never receives a lifecycle payload. pub(crate) fn existing_healthy_instance(&self, url: &str) -> Result, String> { match probe_relay_health_with_instance(url, self.bootstrap_fingerprint.as_deref()) { (RelayHealth::Compatible, instance_id) => Ok(instance_id), (RelayHealth::Unavailable, _) => Ok(None), - (RelayHealth::Incompatible, _) => Err(format!( - "an incompatible NeMo Relay gateway is already listening at {url}" - )), - (RelayHealth::Foreign, _) => Err(format!( - "a foreign process is already listening at the shared Relay gateway URL {url}" - )), + (RelayHealth::Incompatible, _) => Err(incompatible_relay_error(url)), + (RelayHealth::Foreign, _) => Err(foreign_listener_error(url)), } } @@ -215,330 +139,84 @@ impl GatewaySpec { } } -fn acquire_gateway(spec: &GatewaySpec) -> Result { +fn acquire_gateway(spec: &GatewaySpec) -> Result { if !spec.bind.ip().is_loopback() { return Err(format!( - "plugin sidecars require a loopback bind address, got {}", + "plugin gateways require a loopback bind address, got {}", spec.bind )); } - if spec.bind.port() == 0 { - let endpoint = start_automatic_gateway(spec)?; - let effective_spec = GatewaySpec { - bind: endpoint.address, - ..spec.clone() - }; - let state = sidecar_state_dir()?; - let lease = EndpointLease::acquire(&state, &endpoint.url)?; - record_gateway_epoch( - &effective_spec, - &state, - &endpoint.url, - lease.cohort_id(), - lease.fresh_epoch(), - &endpoint, - )?; - return Ok(GatewayAcquisition { - endpoint, - lease, - spec: effective_spec, - }); - } let url = format!("http://{}", spec.bind); - let state = sidecar_state_dir()?; - let lease = EndpointLease::acquire(&state, &url)?; - acquire_fixed_gateway(spec, &state, &url, lease) -} - -fn acquire_fixed_gateway( - spec: &GatewaySpec, - state: &Path, - url: &str, - lease: EndpointLease, -) -> Result { - debug_assert_ne!( - spec.bind.port(), - 0, - "fixed acquisition requires a fixed port" - ); - let runtime = runtime_dir(); - prepare_sidecar_directories(&runtime, state)?; - let lock = lock_sidecar_endpoint(state, url)?; - validate_recovery_cohort(state, url, lease.cohort_id())?; - let (health, instance_id) = - probe_relay_health_after_lock(url, spec.bootstrap_fingerprint.as_deref()); - let endpoint = match health { - RelayHealth::Compatible => compatible_endpoint(spec.bind, url.to_string(), instance_id)?, - RelayHealth::Incompatible => return Err(incompatible_relay_error(url)), - RelayHealth::Foreign => return Err(foreign_listener_error(url)), - RelayHealth::Unavailable => { - let epoch = (!lease.fresh_epoch()) - .then(|| read_recovery_epoch(state, url, lease.cohort_id())) - .transpose()? - .flatten(); - if let Some(epoch) = epoch { - restart_gateway_after_lock( - spec, - &runtime, - state, - url, - lease.cohort_id(), - &epoch.instance_id, - &lock, - )? - } else { - start_sidecar_bind(spec, &runtime, state, Some(&lock)) - .map_err(|error| sidecar_start_error(&runtime, &error))? + if spec.bind.port() != 0 { + match probe_relay_health_with_instance(&url, spec.bootstrap_fingerprint.as_deref()) { + (RelayHealth::Compatible, instance_id) => { + return compatible_endpoint(spec.bind, url, instance_id); } + (RelayHealth::Incompatible, _) => return Err(incompatible_relay_error(&url)), + // A gateway may already be binding while another MCP process owns the + // startup lock. Serialize before deciding whether either state is a + // genuine conflict. + (RelayHealth::Foreign | RelayHealth::Unavailable, _) => {} } - }; - record_gateway_epoch_after_lock( - spec, - state, - url, - lease.cohort_id(), - lease.fresh_epoch(), - &endpoint, - )?; - Ok(GatewayAcquisition { - endpoint, - lease, - spec: spec.clone(), - }) -} - -fn recover_gateway( - spec: &GatewaySpec, - expected_instance: &str, - cohort_id: &str, -) -> Result { - debug_assert_ne!(spec.bind.port(), 0, "acquisition resolves automatic ports"); - let url = format!("http://{}", spec.bind); - let runtime = runtime_dir(); - let state = sidecar_state_dir()?; - prepare_sidecar_directories(&runtime, &state)?; - let lock = lock_sidecar_endpoint(&state, &url)?; - validate_recovery_cohort(&state, &url, cohort_id)?; - let (health, instance_id) = - probe_relay_health_after_lock(&url, spec.bootstrap_fingerprint.as_deref()); - match health { - RelayHealth::Compatible => { - let endpoint = compatible_endpoint(spec.bind, url.clone(), instance_id)?; - reconcile_gateway_epoch(&state, &url, cohort_id, false, &endpoint.instance_id)?; - return Ok(endpoint); - } - RelayHealth::Incompatible => return Err(incompatible_relay_error(&url)), - RelayHealth::Foreign => return Err(foreign_listener_error(&url)), - RelayHealth::Unavailable => {} } - let endpoint = restart_gateway_after_lock( - spec, - &runtime, - &state, - &url, - cohort_id, - expected_instance, - &lock, - )?; - record_gateway_epoch_after_lock(spec, &state, &url, cohort_id, false, &endpoint)?; - Ok(endpoint) -} -fn restart_gateway_after_lock( - spec: &GatewaySpec, - runtime: &Path, - state: &Path, - url: &str, - cohort_id: &str, - expected_instance: &str, - lock: &fs::File, -) -> Result { - let mut epoch = read_recovery_epoch(state, url, cohort_id)? - .ok_or_else(|| "shared gateway recovery epoch is missing".to_string())?; - if epoch.instance_id != expected_instance { - return Err(format!( - "shared Relay gateway recovery moved from instance {expected_instance} to {}", - epoch.instance_id - )); - } - if epoch.restarts >= 1 || epoch.pending { - return Err( - "shared Relay gateway became unhealthy after its endpoint-scoped restart".into(), - ); - } - epoch.restarts = 1; - epoch.pending = true; - write_recovery_epoch(state, &epoch)?; - start_sidecar_bind(spec, runtime, state, Some(lock)) - .map_err(|error| sidecar_start_error(runtime, &error)) -} - -fn record_gateway_epoch( - spec: &GatewaySpec, - state: &Path, - url: &str, - cohort_id: &str, - fresh: bool, - endpoint: &GatewayEndpoint, -) -> Result<(), String> { - let _lock = lock_sidecar_endpoint(state, url)?; - record_gateway_epoch_after_lock(spec, state, url, cohort_id, fresh, endpoint) -} - -fn record_gateway_epoch_after_lock( - spec: &GatewaySpec, - state: &Path, - url: &str, - cohort_id: &str, - fresh: bool, - endpoint: &GatewayEndpoint, -) -> Result<(), String> { - validate_recovery_cohort(state, url, cohort_id)?; - let current = spec.healthy_instance(url).ok_or_else(|| { - "shared Relay gateway disappeared before recovery was recorded".to_string() - })?; - let instance_id = if current == endpoint.instance_id { - &endpoint.instance_id - } else { - ¤t - }; - reconcile_gateway_epoch(state, url, cohort_id, fresh, instance_id) -} - -fn reconcile_gateway_epoch( - state: &Path, - url: &str, - cohort_id: &str, - fresh: bool, - instance_id: &str, -) -> Result<(), String> { - let mut epoch = read_recovery_epoch(state, url, cohort_id)?; - if fresh || epoch.is_none() { - return write_recovery_epoch(state, &RecoveryEpoch::new(url, cohort_id, instance_id)); - } - let epoch = epoch.as_mut().expect("gateway epoch is present"); - if epoch.instance_id == instance_id { - epoch.pending = false; - return write_recovery_epoch(state, epoch); + let state = sidecar_state_dir()?; + state::create_private_dir(&state)?; + let _startup_lock = state::lock_endpoint(&state, &url)?; + if spec.bind.port() == 0 { + return start_gateway(spec, &state); } - if epoch.pending || epoch.restarts == 0 { - epoch.instance_id = instance_id.into(); - epoch.restarts = 1; - epoch.pending = false; - return write_recovery_epoch(state, epoch); + match probe_relay_health_with_instance(&url, spec.bootstrap_fingerprint.as_deref()) { + (RelayHealth::Compatible, instance_id) => compatible_endpoint(spec.bind, url, instance_id), + (RelayHealth::Incompatible, _) => Err(incompatible_relay_error(&url)), + (RelayHealth::Foreign, _) => Err(foreign_listener_error(&url)), + (RelayHealth::Unavailable, _) => start_gateway(spec, &state), } - Err("shared Relay gateway was replaced again after its endpoint-scoped restart".into()) -} - -/// Persistent plugin gateway settings shared by MCP bootstrap and hook recovery. -pub(crate) struct PluginGatewaySpec { - pub(crate) gateway: GatewaySpec, - pub(crate) max_hook_payload_bytes: usize, -} - -pub(crate) fn resolve_plugin_gateway( - server_args: &ServerArgs, - bind: SocketAddr, -) -> Result { - let mut persistent_args = server_args.clone(); - persistent_args.bind = Some(bind); - let resolved = resolve_persistent_server_config(&persistent_args)?; - let bootstrap_fingerprint = resolved - .bootstrap_fingerprint - .expect("persistent gateway resolution sets a bootstrap fingerprint"); - let max_hook_payload_bytes = resolved.gateway.max_hook_payload_bytes; - let sidecar_args = [ - ("--openai-base-url", resolved.gateway.openai_base_url), - ("--anthropic-base-url", resolved.gateway.anthropic_base_url), - ( - "--max-hook-payload-bytes", - resolved.gateway.max_hook_payload_bytes.to_string(), - ), - ( - "--max-passthrough-body-bytes", - resolved.gateway.max_passthrough_body_bytes.to_string(), - ), - ] - .into_iter() - .flat_map(|(flag, value)| [OsString::from(flag), OsString::from(value)]) - .collect(); - Ok(PluginGatewaySpec { - gateway: GatewaySpec::new(bind) - .with_launch_args(sidecar_args) - .with_fingerprint(bootstrap_fingerprint) - .with_user_config_scope(), - max_hook_payload_bytes, - }) } -#[cfg(test)] -pub(crate) fn validate_gateway_cohort(url: &str, cohort_id: &str) -> Result<(), String> { - loop { - match check_gateway_cohort(url, cohort_id)? { - GatewayCohortStatus::Current => return Ok(()), - GatewayCohortStatus::UpdateInProgress => thread::sleep(Duration::from_millis(50)), +fn recover_gateway(spec: &GatewaySpec, expected_instance: &str) -> Result { + let requested_url = format!("http://{}", spec.bind); + let state = sidecar_state_dir()?; + state::create_private_dir(&state)?; + let _startup_lock = state::lock_endpoint(&state, &requested_url)?; + + if spec.bind.port() != 0 { + match probe_relay_health_with_instance( + &requested_url, + spec.bootstrap_fingerprint.as_deref(), + ) { + (RelayHealth::Compatible, instance_id) => { + return compatible_endpoint(spec.bind, requested_url, instance_id); + } + (RelayHealth::Incompatible, _) => return Err(incompatible_relay_error(&requested_url)), + (RelayHealth::Foreign, _) => return Err(foreign_listener_error(&requested_url)), + (RelayHealth::Unavailable, _) => {} } } -} -/// Check one committed endpoint lifecycle snapshot without blocking on an in-progress update. -pub(crate) fn check_gateway_cohort( - url: &str, - cohort_id: &str, -) -> Result { - let state = sidecar_state_dir()?; - let lock = open_sidecar_lock(&sidecar_lock_path(&state, url))?; - match try_lock_shared(&lock) - .map_err(|error| format!("failed to inspect gateway lifecycle at {url}: {error}"))? + if let Some(previous) = state::read_recovery(&state, &requested_url)? + && previous.from_instance == expected_instance { - LockAttempt::Contended => Ok(GatewayCohortStatus::UpdateInProgress), - LockAttempt::Acquired => { - validate_recovery_cohort(&state, url, cohort_id)?; - Ok(GatewayCohortStatus::Current) + if spec.healthy_instance(&previous.endpoint_url).as_deref() + == Some(previous.to_instance.as_str()) + { + let address = loopback_bind(&previous.endpoint_url)?; + return compatible_endpoint(address, previous.endpoint_url, Some(previous.to_instance)); } + return Err("shared Relay gateway became unhealthy after its coordinated restart".into()); } -} - -/// Pin one committed gateway cohort across a short-lived operation such as hook delivery. -/// -/// Unlike heartbeat validation this remains bounded: a hook must fail open/closed according to -/// its own policy instead of waiting indefinitely for an installer that is still running. -pub(crate) fn guard_gateway_cohort( - url: &str, - cohort_id: &str, -) -> Result { - let state = sidecar_state_dir()?; - let lock = lock_endpoint_shared(&state, url)?; - validate_recovery_cohort(&state, url, cohort_id)?; - Ok(GatewayCohortGuard { _lock: lock }) -} - -fn start_automatic_gateway(spec: &GatewaySpec) -> Result { - debug_assert_eq!( - spec.bind.port(), - 0, - "automatic gateway startup requires port zero" - ); - let runtime = runtime_dir(); - let state = sidecar_state_dir()?; - prepare_sidecar_directories(&runtime, &state)?; - start_sidecar_bind(spec, &runtime, &state, None) - .map_err(|error| sidecar_start_error(&runtime, &error)) -} -fn prepare_sidecar_directories(runtime: &Path, state: &Path) -> Result<(), String> { - create_private_runtime_dir(runtime).map_err(|error| { - sidecar_start_error( - runtime, - &format!("failed to create {}: {error}", runtime.display()), - ) - })?; - create_private_runtime_dir(state).map_err(|error| { - sidecar_start_error( - runtime, - &format!("failed to create {}: {error}", state.display()), - ) - }) + let endpoint = start_gateway(spec, &state)?; + state::write_recovery( + &state, + &requested_url, + &state::RecoveryRecord { + from_instance: expected_instance.into(), + endpoint_url: endpoint.url.clone(), + to_instance: endpoint.instance_id.clone(), + }, + )?; + Ok(endpoint) } fn compatible_endpoint( @@ -562,122 +240,15 @@ fn foreign_listener_error(url: &str) -> String { fn incompatible_relay_error(url: &str) -> String { format!( - "{url} is occupied by NeMo Relay with a different version or persistent configuration; stop it, wait for its idle shutdown, or reinstall the affected integration with `nemo-relay install --force` before retrying" + "{url} is occupied by NeMo Relay with a different version or persistent configuration; stop it, wait for idle shutdown, or reinstall the integration with --force" ) } -fn sidecar_start_error(runtime: &Path, error: &str) -> String { - let log_path = runtime.join("gateway-sidecar.log"); - format!("{error}; inspect {}", log_path.display()) -} - -#[cfg(all(test, unix))] -pub(super) fn start_sidecar(url: &str, runtime: &Path) -> Result<(), String> { - let spec = GatewaySpec::new(loopback_bind(url)?); - start_sidecar_bind(&spec, runtime, runtime, None).map(|_| ()) -} - -struct ArmedSidecarChild { - process: Option, - startup_pid_path: PathBuf, - state: PathBuf, - pid: u32, -} - -impl ArmedSidecarChild { - fn new( - child: Child, - startup_pid_path: PathBuf, - state: &Path, - #[cfg(windows)] prepared_job: Option, - ) -> Self { - let pid = child.id(); - Self { - process: Some(DetachedSidecarProcess::new( - child, - #[cfg(windows)] - prepared_job, - )), - startup_pid_path, - state: state.to_path_buf(), - pid, - } - } - - fn id(&self) -> u32 { - self.pid - } - - fn try_wait(&mut self) -> std::io::Result> { - self.process - .as_mut() - .expect("armed sidecar process is present") - .try_wait() - } - - fn disarm(mut self) -> DetachedSidecarProcess { - self.process - .take() - .expect("armed sidecar process is present") - } -} - -impl Drop for ArmedSidecarChild { - fn drop(&mut self) { - if let Some(mut process) = self.process.take() { - // The launcher may exit before readiness while leaving descendants behind. Always - // target the detached process group even when the direct child has already exited. - process.terminate(); - cleanup_sidecar_records_for_pid(&self.state, self.pid); - } - let _ = fs::remove_file(&self.startup_pid_path); - } -} - -fn cleanup_sidecar_records_for_pid(runtime: &Path, pid: u32) { - let Ok(paths) = sidecar_owner_paths(runtime) else { - return; - }; - for owner_path in paths { - let Ok(Some(owner)) = read_owner_record(&owner_path) else { - continue; - }; - if owner.pid != pid { - continue; - } - let _ = fs::remove_file(&owner_path); - let _ = fs::remove_file(sidecar_owner_pid_path(runtime, &owner_path, &owner.url)); - } -} - -fn start_sidecar_bind( - spec: &GatewaySpec, - runtime: &Path, - state: &Path, - startup_lock: Option<&fs::File>, -) -> Result { - let bind = spec.bind; - let sidecar_args = &spec.sidecar_args; - let bootstrap_fingerprint = spec.bootstrap_fingerprint.as_deref(); - let requested_url = format!("http://{bind}"); - if bind.port() != 0 { - let (health, instance_id) = - probe_relay_health_with_instance(&requested_url, bootstrap_fingerprint); - match health { - RelayHealth::Compatible => { - return compatible_endpoint(bind, requested_url, instance_id); - } - RelayHealth::Incompatible => { - return Err(incompatible_relay_error(&requested_url)); - } - RelayHealth::Foreign => return Err(foreign_listener_error(&requested_url)), - RelayHealth::Unavailable => {} - } - } +fn start_gateway(spec: &GatewaySpec, state: &Path) -> Result { let relay = relay_binary()?; - let log_path = runtime.join("gateway-sidecar.log"); - let ready_path = runtime.join(format!( - "gateway-sidecar-{}-{}.ready.json", + let log_path = state.join("gateway-sidecar.log"); + let ready_path = state.join(format!( + "gateway-{}-{}.ready.json", std::process::id(), uuid::Uuid::now_v7() )); @@ -689,25 +260,22 @@ fn start_sidecar_bind( .map_err(|error| format!("failed to open {}: {error}", log_path.display()))?; let err_log = log .try_clone() - .map_err(|error| format!("failed to clone sidecar log handle: {error}"))?; - let idle_timeout = plugin_idle_timeout()?; + .map_err(|error| format!("failed to clone gateway log handle: {error}"))?; let shutdown_token = uuid::Uuid::now_v7().to_string(); - #[cfg(windows)] - let sidecar_job = SidecarJob::create()?; let mut command = Command::new(relay); command .arg("--bind") - .arg(bind.to_string()) + .arg(spec.bind.to_string()) .arg("--ready-file") .arg(&ready_path) - .args(sidecar_args) + .args(&spec.launch_args) .env( - "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", - idle_timeout.as_secs().to_string(), + crate::config::PLUGIN_IDLE_TIMEOUT_ENV, + plugin_idle_timeout()?.as_secs().to_string(), ) .env( crate::config::BOOTSTRAP_FINGERPRINT_ENV, - bootstrap_fingerprint.unwrap_or_default(), + spec.bootstrap_fingerprint.as_deref().unwrap_or_default(), ) .env(BOOTSTRAP_STATE_DIR_ENV, state) .env("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", &shutdown_token) @@ -716,96 +284,53 @@ fn start_sidecar_bind( .stdin(Stdio::null()) .stdout(Stdio::from(log)) .stderr(Stdio::from(err_log)); - #[cfg(windows)] - sidecar_job.configure_child(&mut command); if spec.user_config_scope { command.env("NEMO_RELAY_CONFIG_SCOPE", "user"); if let Some(config_dir) = crate::config::user_config_dir() { fs::create_dir_all(&config_dir).map_err(|error| { format!( - "failed to create plugin sidecar working directory {}: {error}", + "failed to create gateway working directory {}: {error}", config_dir.display() ) })?; command.current_dir(config_dir); } } - configure_detached_sidecar(&mut command); - let child = spawn_detached_sidecar(&mut command) - .map_err(|error| format!("failed to spawn nemo-relay sidecar: {error}"))?; - let startup_pid_path = ready_path.with_extension("pid"); - let _ = fs::write(&startup_pid_path, child.id().to_string()); - let mut child = ArmedSidecarChild::new( - child, - startup_pid_path, - state, - #[cfg(windows)] - Some(sidecar_job), - ); + process::configure_detached(&mut command); + let child = process::spawn_detached(&mut command) + .map_err(|error| format!("failed to spawn nemo-relay gateway: {error}"))?; + let mut child = ArmedChild::new(child); let deadline = Instant::now() + SIDECAR_START_TIMEOUT; while Instant::now() < deadline { - match read_sidecar_ready_file(&ready_path) { - Ok(Some(endpoint)) - if (bind.port() == 0 || endpoint.address == bind) - && health::compatible_instance_id(&endpoint.url, bootstrap_fingerprint) - .as_deref() - == Some(endpoint.instance_id.as_str()) => - { - let owned_lock = startup_lock - .is_none() - .then(|| lock_sidecar_endpoint(state, &endpoint.url)) - .transpose()?; - let _ownership_lock = startup_lock - .or(owned_lock.as_ref()) - .expect("sidecar readiness holds an endpoint lock"); - let owner_path = sidecar_owner_path(state, &endpoint.url); - let pid_path = sidecar_pid_path(state, &endpoint.url); - let pid = child.id(); - validate_sidecar_owner( - &owner_path, - &pid_path, - pid, - &endpoint.url, - &shutdown_token, - bootstrap_fingerprint, - )?; - if let Err(error) = handoff_detached_sidecar_to_reaper( - child.disarm(), - owner_path.clone(), - pid_path.clone(), - sidecar_lock_path(state, &endpoint.url), - ) { - let _ = fs::remove_file(&owner_path); - let _ = fs::remove_file(&pid_path); - let _ = fs::remove_file(&ready_path); - return Err(error); - } - let _ = fs::remove_file(&ready_path); - return Ok(endpoint); - } - Ok(_) => {} - Err(error) => { - let _ = fs::remove_file(&ready_path); - return Err(error); - } + if let Some(endpoint) = read_ready_file(&ready_path)? + && (endpoint.address == spec.bind + || (spec.bind.port() == 0 && endpoint.address.ip() == spec.bind.ip())) + && spec.healthy_instance(&endpoint.url).as_deref() + == Some(endpoint.instance_id.as_str()) + { + let mut detached = child.disarm(); + let _ = thread::Builder::new() + .name("nemo-relay-gateway-wait".into()) + .spawn(move || { + let _ = detached.wait(); + }); + let _ = fs::remove_file(&ready_path); + return Ok(endpoint); } match child.try_wait() { Ok(Some(status)) => { let _ = fs::remove_file(&ready_path); - let (health, instance_id) = - probe_relay_health_with_instance(&requested_url, bootstrap_fingerprint); - if bind.port() != 0 && health == RelayHealth::Compatible { - return compatible_endpoint(bind, requested_url, instance_id); - } return Err(format!( - "nemo-relay sidecar exited before becoming ready at {requested_url}: {status}" + "nemo-relay gateway exited before becoming ready at http://{}: {status}; inspect {}", + spec.bind, + log_path.display() )); } Ok(None) => {} Err(error) => { let _ = fs::remove_file(&ready_path); return Err(format!( - "failed to inspect nemo-relay sidecar process: {error}" + "failed to inspect nemo-relay gateway process: {error}" )); } } @@ -813,165 +338,121 @@ fn start_sidecar_bind( } let _ = fs::remove_file(&ready_path); Err(format!( - "nemo-relay sidecar did not become ready at {requested_url}; terminated startup process" + "nemo-relay gateway did not become ready at http://{}; inspect {}", + spec.bind, + log_path.display() )) } -struct SidecarReapRequest { - process: DetachedSidecarProcess, - exited: bool, - owner_path: PathBuf, - pid_path: PathBuf, - lock_path: PathBuf, -} +struct ArmedChild(Option); -static SIDECAR_REAPER: OnceLock, String>> = OnceLock::new(); - -fn sidecar_reaper_sender() -> Result<&'static mpsc::Sender, String> { - match SIDECAR_REAPER.get_or_init(|| { - let (sender, receiver) = mpsc::channel(); - thread::Builder::new() - .name("nemo-relay-sidecar-reaper".into()) - .spawn(move || run_sidecar_reaper(receiver)) - .map(|_| sender) - .map_err(|error| format!("failed to start nemo-relay sidecar reaper: {error}")) - }) { - Ok(sender) => Ok(sender), - Err(error) => Err(error.clone()), +impl ArmedChild { + fn new(child: Child) -> Self { + Self(Some(child)) } -} - -#[cfg(test)] -pub(super) fn handoff_sidecar_to_reaper( - child: Child, - owner_path: PathBuf, - pid_path: PathBuf, - lock_path: PathBuf, -) -> Result<(), String> { - let process = DetachedSidecarProcess::new( - child, - #[cfg(windows)] - None, - ); - handoff_detached_sidecar_to_reaper(process, owner_path, pid_path, lock_path) -} -fn handoff_detached_sidecar_to_reaper( - process: DetachedSidecarProcess, - owner_path: PathBuf, - pid_path: PathBuf, - lock_path: PathBuf, -) -> Result<(), String> { - let sender = match sidecar_reaper_sender() { - Ok(sender) => sender, - Err(error) => { - return terminate_reaper_handoff(process, &pid_path, error); - } - }; - let request = SidecarReapRequest { - process, - exited: false, - owner_path, - pid_path, - lock_path, - }; - match sender.send(request) { - Ok(()) => Ok(()), - Err(error) => terminate_reaper_handoff( - error.0.process, - &error.0.pid_path, - "nemo-relay sidecar reaper stopped unexpectedly".into(), - ), + fn try_wait(&mut self) -> std::io::Result> { + self.0 + .as_mut() + .expect("armed gateway child is present") + .try_wait() } -} -fn terminate_reaper_handoff( - mut process: DetachedSidecarProcess, - pid_path: &Path, - error: String, -) -> Result<(), String> { - process.terminate(); - let _ = fs::remove_file(pid_path); - Err(error) + fn disarm(mut self) -> Child { + self.0.take().expect("armed gateway child is present") + } } -fn run_sidecar_reaper(receiver: mpsc::Receiver) { - let mut children = Vec::new(); - loop { - match receiver.recv_timeout(Duration::from_millis(100)) { - Ok(request) => children.push(request), - Err(mpsc::RecvTimeoutError::Timeout) => {} - Err(mpsc::RecvTimeoutError::Disconnected) if children.is_empty() => return, - Err(mpsc::RecvTimeoutError::Disconnected) => {} - } - while let Ok(request) = receiver.try_recv() { - children.push(request); - } - let mut index = 0; - while index < children.len() { - if !children[index].exited { - match children[index].process.has_exited_for_tree_cleanup() { - Ok(true) => { - children[index].exited = true; - if let Err(error) = children[index].process.terminate_retained_descendants() - { - eprintln!( - "failed to terminate nemo-relay sidecar descendants: {error}" - ); - } - } - Ok(false) => { - index += 1; - continue; - } - Err(error) => { - eprintln!("failed to inspect nemo-relay sidecar process: {error}"); - index += 1; - continue; - } - } - } - if cleanup_reaped_sidecar(&children[index]) { - let request = children.swap_remove(index); - drop(request); - } else { - index += 1; - } +impl Drop for ArmedChild { + fn drop(&mut self) { + if let Some(mut child) = self.0.take() { + process::terminate_tree(&mut child); } } } -fn cleanup_reaped_sidecar(request: &SidecarReapRequest) -> bool { - let lock = match open_sidecar_lock(&request.lock_path) { - Ok(lock) => lock, +#[derive(Deserialize)] +struct ReadyRecord { + service: String, + version: String, + bootstrap_protocol: u64, + address: SocketAddr, + instance_id: String, +} + +fn read_ready_file(path: &Path) -> Result, String> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => { - eprintln!("failed to clean up nemo-relay sidecar ownership: {error}"); - return true; + return Err(format!( + "failed to read gateway readiness {}: {error}", + path.display() + )); } }; - match try_lock_exclusive(&lock) { - Ok(LockAttempt::Acquired) => {} - Ok(LockAttempt::Contended) => return false, - Err(error) => { - eprintln!("failed to lock nemo-relay sidecar ownership for cleanup: {error}"); - return true; - } - } - let pid = request.process.id(); - if read_owner_record(&request.owner_path) - .ok() - .flatten() - .is_some_and(|owner| owner.pid == pid) - { - let _ = fs::remove_file(&request.owner_path); - } - if fs::read_to_string(&request.pid_path) - .ok() - .is_some_and(|value| value.trim() == pid.to_string()) + let record = serde_json::from_slice::(&bytes).map_err(|error| { + format!( + "failed to parse gateway readiness {}: {error}", + path.display() + ) + })?; + if record.service != "nemo-relay" + || record.version != env!("CARGO_PKG_VERSION") + || record.bootstrap_protocol != BOOTSTRAP_PROTOCOL_VERSION + || record.instance_id.is_empty() { - let _ = fs::remove_file(&request.pid_path); + return Err(format!( + "gateway readiness {} has an incompatible identity", + path.display() + )); } - true + Ok(Some(GatewayEndpoint { + address: record.address, + url: format!("http://{}", record.address), + instance_id: record.instance_id, + })) +} + +/// Persistent plugin settings shared by MCP bootstrap and forward-only hooks. +pub(crate) struct PluginGatewaySpec { + pub(crate) gateway: GatewaySpec, + pub(crate) max_hook_payload_bytes: usize, +} + +pub(crate) fn resolve_plugin_gateway( + server_args: &ServerArgs, + bind: SocketAddr, +) -> Result { + let mut persistent_args = server_args.clone(); + persistent_args.bind = Some(bind); + let resolved = resolve_persistent_server_config(&persistent_args)?; + let bootstrap_fingerprint = resolved + .bootstrap_fingerprint + .expect("persistent gateway resolution sets a bootstrap fingerprint"); + let max_hook_payload_bytes = resolved.gateway.max_hook_payload_bytes; + let launch_args = [ + ("--openai-base-url", resolved.gateway.openai_base_url), + ("--anthropic-base-url", resolved.gateway.anthropic_base_url), + ( + "--max-hook-payload-bytes", + resolved.gateway.max_hook_payload_bytes.to_string(), + ), + ( + "--max-passthrough-body-bytes", + resolved.gateway.max_passthrough_body_bytes.to_string(), + ), + ] + .into_iter() + .flat_map(|(flag, value)| [OsString::from(flag), OsString::from(value)]) + .collect(); + Ok(PluginGatewaySpec { + gateway: GatewaySpec::new(bind) + .with_launch_args(launch_args) + .with_fingerprint(bootstrap_fingerprint) + .with_user_config_scope(), + max_hook_payload_bytes, + }) } pub(super) fn relay_binary() -> Result { diff --git a/crates/cli/src/sidecar/health.rs b/crates/cli/src/sidecar/health.rs index fd15df844..7e96303ac 100644 --- a/crates/cli/src/sidecar/health.rs +++ b/crates/cli/src/sidecar/health.rs @@ -5,7 +5,6 @@ use std::io::{Read, Write}; use std::net::{Ipv4Addr, SocketAddr, TcpStream, ToSocketAddrs}; -use std::thread; use std::time::Duration; use reqwest::Url; @@ -33,28 +32,21 @@ pub(crate) struct VerifiedHttpResponse { #[derive(Debug)] pub(crate) struct VerifiedHttpError { message: String, - retryable: bool, } impl VerifiedHttpError { fn before_payload(message: impl Into) -> Self { Self { message: message.into(), - retryable: true, } } fn after_payload(message: impl Into) -> Self { Self { message: message.into(), - retryable: false, } } - pub(crate) fn is_retryable(&self) -> bool { - self.retryable - } - pub(super) fn missing_fingerprint() -> Self { Self::after_payload("managed Relay gateway is missing its bootstrap fingerprint") } @@ -213,21 +205,6 @@ pub(crate) fn authenticated_instance_id(url: &str, bootstrap_fingerprint: &str) compatible_instance_id(url, Some(bootstrap_fingerprint)) } -pub(super) fn probe_after_lock( - url: &str, - bootstrap_fingerprint: Option<&str>, -) -> (RelayHealth, Option) { - let mut result = probe_with_instance(url, bootstrap_fingerprint); - for _ in 1..3 { - if result.0 != RelayHealth::Foreign { - break; - } - thread::sleep(Duration::from_millis(50)); - result = probe_with_instance(url, bootstrap_fingerprint); - } - result -} - pub(super) fn probe(url: &str, bootstrap_fingerprint: Option<&str>) -> RelayHealth { probe_with_instance(url, bootstrap_fingerprint).0 } diff --git a/crates/cli/src/sidecar/process.rs b/crates/cli/src/sidecar/process.rs index 7192da808..ea488769e 100644 --- a/crates/cli/src/sidecar/process.rs +++ b/crates/cli/src/sidecar/process.rs @@ -1,29 +1,24 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Cross-platform process detachment and tree cleanup for sidecars. +//! Minimal cross-platform process detachment for the shared gateway. -#[cfg(windows)] -use std::env; use std::process::{Child, Command}; -#[cfg(windows)] -use std::sync::{Mutex, OnceLock}; #[cfg(windows)] -const SIDECAR_JOB_NAME_ENV: &str = "NEMO_RELAY_SIDECAR_JOB_NAME"; -#[cfg(windows)] -static RETAINED_SIDECAR_JOB: OnceLock = OnceLock::new(); +use std::sync::Mutex; + #[cfg(windows)] static SIDECAR_SPAWN_LOCK: Mutex<()> = Mutex::new(()); #[cfg(windows)] -pub(super) struct HandleInheritanceGuard { +struct HandleInheritanceGuard { handles: Vec, } #[cfg(windows)] impl HandleInheritanceGuard { - pub(super) fn suppress( + fn suppress( handles: impl IntoIterator, ) -> std::io::Result { use windows_sys::Win32::Foundation::{ @@ -39,7 +34,7 @@ impl HandleInheritanceGuard { continue; } let mut flags = 0; - // SAFETY: `handle` is a live process handle and `flags` is writable storage. + // SAFETY: handle is live and flags is writable storage. if unsafe { GetHandleInformation(handle, &mut flags) } == 0 { return Err(std::io::Error::last_os_error()); } @@ -82,13 +77,9 @@ impl Drop for HandleInheritanceGuard { } #[cfg(windows)] -pub(crate) fn spawn_detached_sidecar(command: &mut Command) -> std::io::Result { +pub(crate) fn spawn_detached(command: &mut Command) -> std::io::Result { use std::os::windows::io::AsRawHandle; - // Rust's stable Windows process API passes `bInheritHandles = TRUE`. Temporarily suppress the - // host's inherited standard handles so a sidecar launched by a captured hook cannot retain the - // hook's output pipes. `Command` creates separate inheritable handles for the explicitly - // configured sidecar log files while this lock is held. let _spawn_guard = SIDECAR_SPAWN_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -97,19 +88,9 @@ pub(crate) fn spawn_detached_sidecar(command: &mut Command) -> std::io::Result Ok(child), (Err(error), Ok(())) => Err(error), @@ -126,308 +107,15 @@ pub(crate) fn spawn_detached_sidecar(command: &mut Command) -> std::io::Result std::io::Result { +pub(crate) fn spawn_detached(command: &mut Command) -> std::io::Result { command.spawn() } -pub(super) struct DetachedSidecarProcess { - child: Child, - #[cfg(windows)] - job: Option, -} - -impl DetachedSidecarProcess { - pub(super) fn new(child: Child, #[cfg(windows)] prepared_job: Option) -> Self { - #[cfg(windows)] - let job = prepared_job; - Self { - child, - #[cfg(windows)] - job, - } - } - - pub(super) fn id(&self) -> u32 { - self.child.id() - } - - pub(super) fn try_wait(&mut self) -> std::io::Result> { - self.child.try_wait() - } - - /// Observe direct-child exit without releasing the Unix process-group identifier first. - pub(super) fn has_exited_for_tree_cleanup(&mut self) -> std::io::Result { - #[cfg(unix)] - { - let mut information = std::mem::MaybeUninit::::zeroed(); - // SAFETY: `information` is writable, the child PID is stable while its `Child` handle - // remains unreaped, and WNOWAIT intentionally preserves that zombie until the owned - // process group has been terminated. - if unsafe { - libc::waitid( - libc::P_PID, - self.child.id() as libc::id_t, - information.as_mut_ptr(), - libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, - ) - } == -1 - { - return Err(std::io::Error::last_os_error()); - } - // SAFETY: `waitid` initialized the signal information on success. POSIX requires a - // zero PID for WNOHANG when the selected child has not changed state. - Ok(unsafe { information.assume_init().si_pid() } != 0) - } - #[cfg(not(unix))] - { - self.child.try_wait().map(|status| status.is_some()) - } - } - - pub(super) fn terminate(&mut self) { - #[cfg(windows)] - if let Some(job) = self.job.as_ref() { - job.terminate(); - } - terminate_sidecar_process_tree(&mut self.child); - } - - pub(super) fn terminate_retained_descendants(&mut self) -> std::io::Result<()> { - #[cfg(unix)] - { - let process_group = -(self.child.id() as i32); - // SAFETY: Detached sidecars call `setsid` before exec, so the direct child's PID is the - // owned process-group ID. Unix exit observation left the leader unreaped to prevent - // reuse while this signal is delivered. - let terminated = if unsafe { libc::kill(process_group, libc::SIGKILL) } == -1 { - let error = std::io::Error::last_os_error(); - if error.raw_os_error() == Some(libc::ESRCH) { - Ok(()) - } else { - Err(error) - } - } else { - Ok(()) - }; - let reaped = self.child.wait().map(|_| ()); - match (terminated, reaped) { - (Ok(()), Ok(())) => Ok(()), - (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), - (Err(terminate_error), Err(reap_error)) => Err(std::io::Error::new( - terminate_error.kind(), - format!( - "{terminate_error}; additionally failed to reap the sidecar: {reap_error}" - ), - )), - } - } - #[cfg(not(unix))] - { - #[cfg(windows)] - if let Some(job) = self.job.as_ref() { - job.terminate(); - } - Ok(()) - } - } -} - -#[cfg(windows)] -pub(crate) struct SidecarJob { - handle: windows_sys::Win32::Foundation::HANDLE, - name: String, -} - -// SAFETY: Job Object handles can be used from any thread, and this wrapper uniquely owns it. -#[cfg(windows)] -unsafe impl Send for SidecarJob {} -// SAFETY: Windows Job Object operations are thread-safe for a live kernel handle. -#[cfg(windows)] -unsafe impl Sync for SidecarJob {} - -#[cfg(windows)] -impl SidecarJob { - pub(crate) fn create() -> Result { - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Foundation::CloseHandle; - use windows_sys::Win32::System::JobObjects::{ - CreateJobObjectW, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, - JobObjectExtendedLimitInformation, SetInformationJobObject, - }; - - let name = format!("Local\\NeMoRelaySidecar-{}", uuid::Uuid::now_v7().simple()); - let wide = std::ffi::OsStr::new(&name) - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - // SAFETY: Null security attributes select defaults and `wide` is NUL-terminated. - let handle = unsafe { CreateJobObjectW(std::ptr::null(), wide.as_ptr()) }; - if handle.is_null() { - return Err(format!( - "failed to create detached sidecar Job Object: {}", - std::io::Error::last_os_error() - )); - } - let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - limits.BasicLimitInformation.LimitFlags = WINDOWS_JOB_OBJECT_LIMIT_KILL_ON_CLOSE; - // SAFETY: `handle` is live and `limits` is correctly sized for the requested class. - let configured = unsafe { - SetInformationJobObject( - handle, - JobObjectExtendedLimitInformation, - std::ptr::from_ref(&limits).cast(), - std::mem::size_of::() as u32, - ) - }; - if configured == 0 { - let error = std::io::Error::last_os_error(); - // SAFETY: `handle` was created above and has not been transferred. - unsafe { CloseHandle(handle) }; - return Err(format!( - "failed to configure detached sidecar Job Object cleanup: {error}" - )); - } - Ok(Self { handle, name }) - } - - #[cfg(test)] - pub(crate) fn name(&self) -> &str { - &self.name - } - - pub(super) fn configure_child(&self, command: &mut Command) { - command.env(SIDECAR_JOB_NAME_ENV, &self.name); - } - - #[cfg(test)] - pub(crate) fn assign(&self, child: &Child) -> Result<(), String> { - use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; - - // SAFETY: Both handles are live kernel handles owned by this process. - let assigned = - unsafe { AssignProcessToJobObject(self.handle, child.as_raw_handle().cast()) }; - if assigned == 0 { - return Err(format!( - "failed to assign detached sidecar {} to its Job Object: {}", - child.id(), - std::io::Error::last_os_error() - )); - } - Ok(()) - } - - pub(crate) fn terminate(&self) { - use windows_sys::Win32::System::JobObjects::TerminateJobObject; - - // SAFETY: The retained handle owns the Job Object assigned to this sidecar process tree. - let _ = unsafe { TerminateJobObject(self.handle, 1) }; - } -} - -#[cfg(windows)] -pub(crate) fn join_sidecar_job_from_env() -> Result<(), String> { - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Foundation::CloseHandle; - use windows_sys::Win32::System::JobObjects::{AssignProcessToJobObject, OpenJobObjectW}; - use windows_sys::Win32::System::SystemServices::{ - JOB_OBJECT_ASSIGN_PROCESS, JOB_OBJECT_TERMINATE, - }; - use windows_sys::Win32::System::Threading::GetCurrentProcess; - - let Some(name) = env::var_os(SIDECAR_JOB_NAME_ENV) else { - return Ok(()); - }; - // SAFETY: Windows environment mutation is synchronized by the operating system. Remove the - // private handoff value before any plugin worker can inherit it. - unsafe { env::remove_var(SIDECAR_JOB_NAME_ENV) }; - let wide = name - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - // SAFETY: `wide` is NUL-terminated and requests only assignment/termination rights. - let job = unsafe { - OpenJobObjectW( - JOB_OBJECT_ASSIGN_PROCESS | JOB_OBJECT_TERMINATE, - 0, - wide.as_ptr(), - ) - }; - if job.is_null() { - return Err(format!( - "failed to open detached sidecar Job Object; persistent bootstrap cannot guarantee process-tree cleanup: {}", - std::io::Error::last_os_error() - )); - } - // SAFETY: `job` is live and the pseudo current-process handle is valid. - let assigned = unsafe { AssignProcessToJobObject(job, GetCurrentProcess()) }; - if assigned == 0 { - let error = std::io::Error::last_os_error(); - // SAFETY: `job` was opened above and has not been transferred. - unsafe { CloseHandle(job) }; - return Err(format!( - "failed to join detached sidecar Job Object; the current Windows Job Object may reject nested assignment, so persistent bootstrap cannot guarantee process-tree cleanup: {error}" - )); - } - let retained = SidecarJob { - handle: job, - name: name.to_string_lossy().into_owned(), - }; - RETAINED_SIDECAR_JOB.set(retained).map_err(|_| { - "detached sidecar Job Object was initialized more than once in one process".to_string() - }) -} - -#[cfg(not(windows))] -pub(crate) fn join_sidecar_job_from_env() -> Result<(), String> { - Ok(()) -} - -#[cfg(windows)] -impl Drop for SidecarJob { - fn drop(&mut self) { - use windows_sys::Win32::Foundation::CloseHandle; - - // SAFETY: `handle` is uniquely owned by this wrapper and closed exactly once. - unsafe { CloseHandle(self.handle) }; - } -} - -#[cfg(unix)] -pub(crate) fn terminate_sidecar_process_tree(child: &mut Child) { - let process_group = -(child.id() as i32); - // SAFETY: The detached sidecar calls `setsid` before exec, so its PID is also the process-group - // ID. A negative PID targets that complete group and does not dereference memory. - if unsafe { libc::kill(process_group, libc::SIGKILL) } == -1 { - let _ = child.kill(); - } - let _ = child.wait(); -} - -#[cfg(windows)] -pub(crate) fn terminate_sidecar_process_tree(child: &mut Child) { - let status = Command::new("taskkill") - .args(["/PID", &child.id().to_string(), "/T", "/F"]) - .status(); - if !status.is_ok_and(|status| status.success()) { - let _ = child.kill(); - } - let _ = child.wait(); -} - -#[cfg(not(any(unix, windows)))] -pub(crate) fn terminate_sidecar_process_tree(child: &mut Child) { - let _ = child.kill(); - let _ = child.wait(); -} - #[cfg(unix)] -pub(crate) fn configure_detached_sidecar(command: &mut Command) { +pub(crate) fn configure_detached(command: &mut Command) { use std::os::unix::process::CommandExt; - // SAFETY: `setsid` is async-signal-safe and has no memory-safety preconditions. It runs in the - // post-fork child before exec so the shared sidecar is outside the MCP client's session and - // process group. + // SAFETY: setsid is async-signal-safe and runs in the post-fork child before exec. unsafe { command.pre_exec(|| { if libc::setsid() == -1 { @@ -449,14 +137,9 @@ pub(crate) const WINDOWS_CREATE_NO_WINDOW: u32 = 0x0800_0000; pub(crate) const WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK: u32 = 0x0000_0800; #[cfg(any(test, windows))] pub(crate) const WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK: u32 = 0x0000_1000; -#[cfg(any(test, windows))] -pub(crate) const WINDOWS_JOB_OBJECT_LIMIT_KILL_ON_CLOSE: u32 = 0x0000_2000; #[cfg(any(test, windows))] -pub(crate) fn windows_sidecar_creation_flags( - in_job: bool, - job_limit_flags: Option, -) -> (u32, bool) { +pub(crate) fn windows_creation_flags(in_job: bool, job_limit_flags: Option) -> (u32, bool) { let base = WINDOWS_CREATE_NEW_PROCESS_GROUP | WINDOWS_CREATE_NO_WINDOW; if !in_job { return (base, false); @@ -479,8 +162,7 @@ fn current_windows_job_limits() -> (bool, Option) { use windows_sys::Win32::System::Threading::GetCurrentProcess; let mut in_job = 0; - // SAFETY: The pseudo current-process handle and null current-job handle are valid for this - // query, and `in_job` points to writable storage for the result. + // SAFETY: The pseudo current-process handle and null current-job handle are valid here. if unsafe { IsProcessInJob(GetCurrentProcess(), std::ptr::null_mut(), &mut in_job) } == 0 { return (true, None); } @@ -488,8 +170,7 @@ fn current_windows_job_limits() -> (bool, Option) { return (false, Some(0)); } let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - // SAFETY: A null job handle queries the job associated with the current process. The buffer is - // correctly sized and aligned for the requested information class. + // SAFETY: The output buffer matches the requested information class. let queried = unsafe { QueryInformationJobObject( std::ptr::null_mut(), @@ -507,11 +188,11 @@ fn current_windows_job_limits() -> (bool, Option) { } #[cfg(windows)] -pub(crate) fn configure_detached_sidecar(command: &mut Command) { +pub(crate) fn configure_detached(command: &mut Command) { use std::os::windows::process::CommandExt; let (in_job, limits) = current_windows_job_limits(); - let (flags, limited_lifetime) = windows_sidecar_creation_flags(in_job, limits); + let (flags, limited_lifetime) = windows_creation_flags(in_job, limits); if limited_lifetime { eprintln!( "warning: the current Windows Job Object does not permit process breakaway; the shared Relay gateway lifetime is limited to the host job" @@ -521,38 +202,29 @@ pub(crate) fn configure_detached_sidecar(command: &mut Command) { } #[cfg(not(any(unix, windows)))] -pub(crate) fn configure_detached_sidecar(_command: &mut Command) {} - -#[cfg(test)] -pub(crate) fn terminate_unready_sidecar( - mut child: Child, - pid_path: &std::path::Path, - url: &str, -) -> Result<(), String> { - match child.try_wait() { - Ok(Some(status)) => { - let _ = std::fs::remove_file(pid_path); - return Err(format!( - "nemo-relay sidecar exited before becoming ready at {url}: {status}" - )); +pub(crate) fn configure_detached(_command: &mut Command) {} + +pub(crate) fn terminate_tree(child: &mut Child) { + #[cfg(unix)] + { + let process_group = -(child.id() as i32); + // SAFETY: Detached gateways call setsid, so the child PID is the process-group ID. + if unsafe { libc::kill(process_group, libc::SIGKILL) } == -1 { + let _ = child.kill(); } - Ok(None) => {} - Err(error) => { - let _ = std::fs::remove_file(pid_path); - return Err(format!( - "failed to inspect nemo-relay sidecar process: {error}" - )); + } + #[cfg(windows)] + { + let status = Command::new("taskkill") + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .status(); + if !status.is_ok_and(|status| status.success()) { + let _ = child.kill(); } } - if let Err(error) = child.kill() { - let _ = std::fs::remove_file(pid_path); - return Err(format!( - "nemo-relay sidecar did not become ready at {url}; failed to terminate startup process: {error}" - )); + #[cfg(not(any(unix, windows)))] + { + let _ = child.kill(); } let _ = child.wait(); - let _ = std::fs::remove_file(pid_path); - Err(format!( - "nemo-relay sidecar did not become ready at {url}; terminated startup process" - )) } diff --git a/crates/cli/src/sidecar/state.rs b/crates/cli/src/sidecar/state.rs index a9fb66e8b..77c4eb4e8 100644 --- a/crates/cli/src/sidecar/state.rs +++ b/crates/cli/src/sidecar/state.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Per-user ownership, locking, and readiness state for managed sidecars. +//! Per-user startup lock and ownership record for the shared gateway. use std::env; use std::fs::{self, OpenOptions}; @@ -13,26 +13,34 @@ use std::time::{Duration, Instant}; use reqwest::Url; use serde::{Deserialize, Serialize}; -use crate::file_io::{LockAttempt, atomic_write, try_lock_exclusive, try_lock_shared}; +use crate::file_io::{LockAttempt, atomic_write, try_lock_exclusive}; use super::health::{RelayHealth, probe, request_shutdown}; -use super::{BOOTSTRAP_PROTOCOL_VERSION, GatewayEndpoint, SIDECAR_LOCK_TIMEOUT}; +use super::{BOOTSTRAP_PROTOCOL_VERSION, SIDECAR_LOCK_TIMEOUT}; pub(crate) const BOOTSTRAP_STATE_DIR_ENV: &str = "NEMO_RELAY_BOOTSTRAP_STATE_DIR"; +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); -#[derive(Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] pub(super) struct OwnerRecord { service: String, version: String, bootstrap_protocol: u64, - pub(super) pid: u32, - pub(super) url: String, + pid: u32, + url: String, shutdown_token: String, bootstrap_fingerprint: Option, } +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct RecoveryRecord { + pub(super) from_instance: String, + pub(super) endpoint_url: String, + pub(super) to_instance: String, +} + impl OwnerRecord { - fn new(pid: u32, url: &str, shutdown_token: &str, bootstrap_fingerprint: Option<&str>) -> Self { + fn new(pid: u32, url: &str, shutdown_token: &str, fingerprint: Option<&str>) -> Self { Self { service: "nemo-relay".into(), version: env!("CARGO_PKG_VERSION").into(), @@ -40,403 +48,128 @@ impl OwnerRecord { pid, url: url.into(), shutdown_token: shutdown_token.into(), - bootstrap_fingerprint: bootstrap_fingerprint.map(str::to_owned), + bootstrap_fingerprint: fingerprint.map(str::to_owned), } } - fn matches( - &self, - pid: u32, - url: &str, - shutdown_token: &str, - bootstrap_fingerprint: Option<&str>, - ) -> bool { + fn valid_for(&self, url: &str) -> bool { self.service == "nemo-relay" && self.version == env!("CARGO_PKG_VERSION") && self.bootstrap_protocol == BOOTSTRAP_PROTOCOL_VERSION - && self.pid == pid && self.url == url - && self.shutdown_token == shutdown_token - && self.bootstrap_fingerprint.as_deref() == bootstrap_fingerprint - } -} - -#[derive(Debug, Deserialize)] -struct ReadyRecord { - service: String, - version: String, - bootstrap_protocol: u64, - address: String, - instance_id: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub(super) struct RecoveryEpoch { - service: String, - bootstrap_protocol: u64, - url: String, - cohort_id: String, - pub(super) instance_id: String, - pub(super) restarts: u8, - pub(super) pending: bool, -} - -impl RecoveryEpoch { - pub(super) fn new(url: &str, cohort_id: &str, instance_id: &str) -> Self { - Self { - service: "nemo-relay".into(), - bootstrap_protocol: BOOTSTRAP_PROTOCOL_VERSION, - url: url.into(), - cohort_id: cohort_id.into(), - instance_id: instance_id.into(), - restarts: 0, - pending: false, - } - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct RecoveryCohort { - service: String, - bootstrap_protocol: u64, - url: String, - cohort_id: String, -} - -impl RecoveryCohort { - fn new(url: &str) -> Self { - Self { - service: "nemo-relay".into(), - bootstrap_protocol: BOOTSTRAP_PROTOCOL_VERSION, - url: url.into(), - cohort_id: uuid::Uuid::now_v7().to_string(), - } + && !self.shutdown_token.is_empty() } } -/// Process-lifetime registration used to delimit one shared recovery epoch. -pub(crate) struct EndpointLease { - file: Option, +/// Removes this process's ownership record when the gateway server exits. +#[derive(Debug)] +pub(crate) struct OwnerGuard { path: PathBuf, - runtime: PathBuf, - url: String, - cohort_id: String, - fresh_epoch: bool, + record: OwnerRecord, } -impl EndpointLease { - pub(crate) fn acquire(runtime: &Path, url: &str) -> Result { - create_private_runtime_dir(runtime).map_err(|error| { - format!( - "failed to create bootstrap state directory {}: {error}", - runtime.display() - ) - })?; - let registry_path = lease_registry_path(runtime, url); - let _registry = lock_file_for(®istry_path, SIDECAR_LOCK_TIMEOUT)?; - let cohort_id = current_or_create_recovery_cohort(runtime, url)?; - let all_leases_prefix = format!("{}-lease-", lock_name(url)); - let prefix = format!("{all_leases_prefix}{cohort_id}-"); - let mut active = false; - let entries = fs::read_dir(runtime).map_err(|error| { - format!( - "failed to inspect bootstrap leases in {}: {error}", - runtime.display() - ) - })?; - for entry in entries.filter_map(Result::ok) { - let name = entry.file_name(); - let Some(name) = name.to_str() else { - continue; - }; - if !name.starts_with(&all_leases_prefix) || !name.ends_with(".lock") { - continue; - } - let path = entry.path(); - let file = match OpenOptions::new().read(true).write(true).open(&path) { - Ok(file) => file, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => { - return Err(format!( - "failed to inspect bootstrap lease {}: {error}", - path.display() - )); - } - }; - match try_lock_exclusive(&file) { - Ok(LockAttempt::Acquired) => { - drop(file); - let _ = fs::remove_file(path); - } - Ok(LockAttempt::Contended) if name.starts_with(&prefix) => active = true, - Ok(LockAttempt::Contended) => {} - Err(error) => { - return Err(format!( - "failed to inspect bootstrap lease {}: {error}", - path.display() - )); - } - } - } - let path = runtime.join(format!( - "{prefix}{}-{}.lock", - std::process::id(), - uuid::Uuid::now_v7() - )); - let file = OpenOptions::new() - .create_new(true) - .read(true) - .write(true) - .open(&path) - .map_err(|error| { - format!( - "failed to create bootstrap lease {}: {error}", - path.display() - ) - })?; - match try_lock_exclusive(&file) { - Ok(LockAttempt::Acquired) => {} - Ok(LockAttempt::Contended) => { - return Err(format!( - "new bootstrap lease {} was unexpectedly contended", - path.display() - )); - } - Err(error) => { - return Err(format!( - "failed to lock bootstrap lease {}: {error}", - path.display() - )); - } - } - if !active { - let _ = fs::remove_file(recovery_path(runtime, url)); - } - Ok(Self { - file: Some(file), - path, - runtime: runtime.to_path_buf(), - url: url.to_string(), - cohort_id, - fresh_epoch: !active, - }) - } - - pub(crate) fn fresh_epoch(&self) -> bool { - self.fresh_epoch - } - - pub(crate) fn cohort_id(&self) -> &str { - &self.cohort_id - } -} - -impl Drop for EndpointLease { +impl Drop for OwnerGuard { fn drop(&mut self) { - // Acquisition and release share this registry lock. This closes the handoff race where a - // new lease could observe the old lease as active immediately before it disappeared and - // become the sole client of an exhausted recovery epoch. - let Ok(_registry) = lock_file_for( - &lease_registry_path(&self.runtime, &self.url), - SIDECAR_LOCK_TIMEOUT, - ) else { - // Releasing without the registry would reintroduce the last-client handoff race. - // Conservatively retain the advisory lock until process exit; a later process can - // clean up the lease file after the operating system releases the descriptor. - if let Some(file) = self.file.take() { - std::mem::forget(file); - } - return; - }; - drop(self.file.take()); - let _ = fs::remove_file(&self.path); + let _ = remove_if_matches(&self.path, &self.record); } } -fn lease_registry_path(runtime: &Path, url: &str) -> PathBuf { - runtime.join(format!("{}-leases.lock", lock_name(url))) -} - -fn recovery_cohort_path(runtime: &Path, url: &str) -> PathBuf { - runtime.join(format!("sidecar-{}.cohort.json", lock_name(url))) -} - -fn current_or_create_recovery_cohort(runtime: &Path, url: &str) -> Result { - if let Some(cohort) = read_recovery_cohort(runtime, url)? { - return Ok(cohort.cohort_id); - } - let cohort = RecoveryCohort::new(url); - write_recovery_cohort(runtime, &cohort)?; - Ok(cohort.cohort_id) +pub(crate) fn state_dir() -> Result { + crate::config::user_config_dir() + .map(|path| path.join("bootstrap")) + .ok_or_else(|| { + "cannot determine the per-user NeMo Relay bootstrap state directory; set HOME or USERPROFILE" + .into() + }) } -fn read_recovery_cohort(runtime: &Path, url: &str) -> Result, String> { - let path = recovery_cohort_path(runtime, url); - let raw = match fs::read(&path) { - Ok(raw) => raw, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => { - return Err(format!( - "failed to read gateway recovery cohort {}: {error}", - path.display() - )); - } - }; - let cohort = serde_json::from_slice::(&raw).map_err(|error| { - format!( - "invalid gateway recovery cohort {}: {error}", - path.display() - ) - })?; - if cohort.service != "nemo-relay" - || cohort.bootstrap_protocol != BOOTSTRAP_PROTOCOL_VERSION - || cohort.url != url - || cohort.cohort_id.is_empty() +pub(crate) fn create_private_dir(path: &Path) -> Result<(), String> { + fs::create_dir_all(path) + .map_err(|error| format!("failed to create {}: {error}", path.display()))?; + #[cfg(unix)] { - return Err(format!( - "incompatible gateway recovery cohort {}", - path.display() - )); + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("failed to secure {}: {error}", path.display()))?; } - Ok(Some(cohort)) + Ok(()) } -fn write_recovery_cohort(runtime: &Path, cohort: &RecoveryCohort) -> Result<(), String> { - let path = recovery_cohort_path(runtime, &cohort.url); - let bytes = serde_json::to_vec(cohort) - .map_err(|error| format!("failed to encode gateway recovery cohort: {error}"))?; - atomic_write(&path, &bytes) +pub(crate) fn owner_path(state: &Path, url: &str) -> PathBuf { + state.join(format!("sidecar-{}.owner.json", lock_name(url))) } -pub(super) fn validate_recovery_cohort( - runtime: &Path, - url: &str, - expected: &str, -) -> Result<(), String> { - match read_recovery_cohort(runtime, url)? { - Some(cohort) if cohort.cohort_id == expected => Ok(()), - _ => Err(format!( - "shared Relay gateway lifecycle at {url} was retired by an integration update" - )), - } +pub(crate) fn lock_path(state: &Path, url: &str) -> PathBuf { + state.join(format!("gateway-{}.lock", lock_name(url))) } -pub(super) fn read_owner_record(path: &Path) -> Result, String> { - let raw = match fs::read(path) { - Ok(raw) => raw, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => { - return Err(format!( - "failed to read sidecar ownership {}: {error}", - path.display() - )); - } - }; - serde_json::from_slice(&raw) - .map(Some) - .map_err(|error| format!("invalid sidecar ownership file {}: {error}", path.display())) +fn recovery_path(state: &Path, url: &str) -> PathBuf { + state.join(format!("gateway-{}.recovery.json", lock_name(url))) } -pub(super) fn read_ready_file(path: &Path) -> Result, String> { - let raw = match fs::read(path) { - Ok(raw) => raw, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => { - return Err(format!( - "failed to read sidecar readiness file {}: {error}", +pub(super) fn read_recovery(state: &Path, url: &str) -> Result, String> { + let path = recovery_path(state, url); + match fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(|error| { + format!( + "failed to parse gateway recovery {}: {error}", path.display() - )); - } - }; - let record = serde_json::from_slice::(&raw) - .map_err(|error| format!("invalid sidecar readiness file {}: {error}", path.display()))?; - if record.service != "nemo-relay" - || record.version != env!("CARGO_PKG_VERSION") - || record.bootstrap_protocol != BOOTSTRAP_PROTOCOL_VERSION - || record.instance_id.is_empty() - { - return Err(format!( - "incompatible sidecar readiness file {}", + ) + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!( + "failed to read gateway recovery {}: {error}", path.display() - )); + )), } - let address = record - .address - .parse::() - .map_err(|error| format!("invalid sidecar address in {}: {error}", path.display()))?; - Ok(Some(GatewayEndpoint { - address, - url: format!("http://{address}"), - instance_id: record.instance_id, - })) } -pub(crate) fn owner_path(runtime: &Path, url: &str) -> PathBuf { - runtime.join(format!("sidecar-{}.owner.json", lock_name(url))) -} - -pub(crate) fn pid_path(runtime: &Path, url: &str) -> PathBuf { - runtime.join(format!("sidecar-{}.pid", lock_name(url))) -} - -pub(crate) fn lock_path(runtime: &Path, url: &str) -> PathBuf { - runtime.join(format!("{}-sidecar.lock", lock_name(url))) -} - -pub(crate) fn state_dir() -> Result { - crate::config::user_config_dir() - .map(|path| path.join("bootstrap")) - .ok_or_else(|| { - "cannot determine the per-user NeMo Relay bootstrap state directory; set HOME or USERPROFILE" - .into() - }) -} - -pub(crate) fn lock_endpoint(runtime: &Path, url: &str) -> Result { - lock_endpoint_for(runtime, url, SIDECAR_LOCK_TIMEOUT) +pub(super) fn write_recovery( + state: &Path, + url: &str, + record: &RecoveryRecord, +) -> Result<(), String> { + let path = recovery_path(state, url); + let bytes = serde_json::to_vec(record) + .map_err(|error| format!("failed to encode gateway recovery: {error}"))?; + atomic_write(&path, &bytes) } -pub(crate) fn lock_endpoint_shared(runtime: &Path, url: &str) -> Result { - let path = lock_path(runtime, url); - lock_file_for_mode(&path, SIDECAR_LOCK_TIMEOUT, false) +pub(crate) fn lock_endpoint(state: &Path, url: &str) -> Result { + lock_endpoint_for(state, url, SIDECAR_LOCK_TIMEOUT) } pub(crate) fn lock_endpoint_for( - runtime: &Path, + state: &Path, url: &str, timeout: Duration, ) -> Result { - let path = lock_path(runtime, url); - lock_file_for(&path, timeout) -} - -fn lock_file_for(path: &Path, timeout: Duration) -> Result { - lock_file_for_mode(path, timeout, true) -} - -fn lock_file_for_mode(path: &Path, timeout: Duration, exclusive: bool) -> Result { - let lock = open_lock(path)?; + create_private_dir(state)?; + let path = lock_path(state, url); + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&path) + .map_err(|error| format!("failed to open gateway lock {}: {error}", path.display()))?; let deadline = Instant::now() + timeout; loop { - let result = if exclusive { - try_lock_exclusive(&lock) - } else { - try_lock_shared(&lock) - }; - match result { + match try_lock_exclusive(&lock) { Ok(LockAttempt::Acquired) => return Ok(lock), - Ok(LockAttempt::Contended) => { - if Instant::now() >= deadline { - return Err(format!( - "timed out waiting for sidecar lock {}", - path.display() - )); - } + Ok(LockAttempt::Contended) if Instant::now() < deadline => { thread::sleep(Duration::from_millis(50)); } + Ok(LockAttempt::Contended) => { + return Err(format!( + "timed out waiting for gateway startup lock {}", + path.display() + )); + } Err(error) => { return Err(format!( - "failed to acquire sidecar lock {}: {error}", + "failed to acquire gateway startup lock {}: {error}", path.display() )); } @@ -444,81 +177,11 @@ fn lock_file_for_mode(path: &Path, timeout: Duration, exclusive: bool) -> Result } } -pub(super) fn recovery_path(runtime: &Path, url: &str) -> PathBuf { - runtime.join(format!("sidecar-{}.recovery.json", lock_name(url))) -} - -pub(super) fn read_recovery_epoch( - runtime: &Path, - url: &str, - cohort_id: &str, -) -> Result, String> { - let path = recovery_path(runtime, url); - let raw = match fs::read(&path) { - Ok(raw) => raw, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => { - return Err(format!( - "failed to read gateway recovery epoch {}: {error}", - path.display() - )); - } - }; - let epoch = serde_json::from_slice::(&raw) - .map_err(|error| format!("invalid gateway recovery epoch {}: {error}", path.display()))?; - if epoch.service != "nemo-relay" - || epoch.bootstrap_protocol != BOOTSTRAP_PROTOCOL_VERSION - || epoch.url != url - || epoch.cohort_id != cohort_id - || epoch.instance_id.is_empty() - || epoch.restarts > 1 - { - return Err(format!( - "incompatible gateway recovery epoch {}", - path.display() - )); - } - Ok(Some(epoch)) -} - -pub(super) fn write_recovery_epoch(runtime: &Path, epoch: &RecoveryEpoch) -> Result<(), String> { - let path = recovery_path(runtime, &epoch.url); - let bytes = serde_json::to_vec(epoch) - .map_err(|error| format!("failed to encode gateway recovery epoch: {error}"))?; - atomic_write(&path, &bytes) -} - -pub(super) fn open_lock(path: &Path) -> Result { - OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .open(path) - .map_err(|error| format!("failed to open sidecar lock {}: {error}", path.display())) -} - -pub(crate) fn write_owner( - path: &Path, - pid: u32, - url: &str, - shutdown_token: &str, - bootstrap_fingerprint: Option<&str>, -) -> Result<(), String> { - let record = OwnerRecord::new(pid, url, shutdown_token, bootstrap_fingerprint); - let bytes = serde_json::to_vec(&record) - .map_err(|error| format!("failed to encode sidecar ownership: {error}"))?; - atomic_write(path, &bytes) -} - -pub(crate) fn publish_owner_from_env(address: SocketAddr) -> Result<(), String> { +pub(crate) fn publish_owner_from_env(address: SocketAddr) -> Result, String> { let state = env::var_os(BOOTSTRAP_STATE_DIR_ENV); let token = env::var("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN").ok(); - let bootstrap_fingerprint = env::var(crate::config::BOOTSTRAP_FINGERPRINT_ENV) - .ok() - .filter(|fingerprint| !fingerprint.is_empty()); if state.is_none() && token.is_none() { - return Ok(()); + return Ok(None); } let state = state .map(PathBuf::from) @@ -537,340 +200,100 @@ pub(crate) fn publish_owner_from_env(address: SocketAddr) -> Result<(), String> "managed bootstrap ownership requires a loopback address, got {address}" )); } - create_private_runtime_dir(&state).map_err(|error| { - format!( - "failed to create bootstrap state directory {}: {error}", - state.display() - ) - })?; + create_private_dir(&state)?; let url = format!("http://{address}"); - let pid = std::process::id(); - let owner_path = owner_path(&state, &url); - let pid_path = pid_path(&state, &url); - for stale_path in owner_paths(&state)? { - if stale_path == owner_path { - continue; - } - let Ok(Some(stale)) = read_owner_record(&stale_path) else { - continue; - }; - if stale.url == url { - let stale_pid = owner_pid_path(&state, &stale_path, &url); - let _ = fs::remove_file(stale_path); - let _ = fs::remove_file(stale_pid); - } - } - atomic_write(&pid_path, pid.to_string().as_bytes())?; - if let Err(error) = write_owner( - &owner_path, - pid, - &url, - &token, - bootstrap_fingerprint.as_deref(), - ) { - let _ = fs::remove_file(&pid_path); - return Err(error); - } - Ok(()) -} - -pub(crate) fn validate_owner( - owner_path: &Path, - pid_path: &Path, - pid: u32, - url: &str, - shutdown_token: &str, - bootstrap_fingerprint: Option<&str>, -) -> Result<(), String> { - let owner = read_owner_record(owner_path)?.ok_or_else(|| { - format!( - "failed to read sidecar ownership {}: file does not exist", - owner_path.display() - ) - })?; - let valid = owner.matches(pid, url, shutdown_token, bootstrap_fingerprint) - && fs::read_to_string(pid_path) - .ok() - .is_some_and(|value| value.trim() == pid.to_string()); - if valid { - Ok(()) - } else { - Err(format!( - "sidecar ownership {} does not match the ready process", - owner_path.display() - )) - } -} - -/// Stop a plugin-owned endpoint and begin a fresh recovery cohort as one serialized operation. -/// -/// The endpoint and lease-registry locks prevent a heartbeat or hook from recovering the old -/// gateway between shutdown and cohort rotation. If shutdown fails, both lifecycle files are -/// restored byte-for-byte so the still-active clients retain their original recovery budget. -pub(crate) fn stop_owned_and_reset(target_url: &str) -> Result<(), String> { - stop_owned_and_reset_after_rotation(target_url, || {}) -} - -fn stop_owned_and_reset_after_rotation( - target_url: &str, - after_rotation: impl FnOnce(), -) -> Result<(), String> { - let runtime = state_dir()?; - create_private_runtime_dir(&runtime).map_err(|error| { - format!( - "failed to create bootstrap state directory {}: {error}", - runtime.display() - ) - })?; - let _endpoint = lock_endpoint(&runtime, target_url)?; - let _registry = lock_file_for( - &lease_registry_path(&runtime, target_url), - SIDECAR_LOCK_TIMEOUT, - )?; - let cohort_path = recovery_cohort_path(&runtime, target_url); - let epoch_path = recovery_path(&runtime, target_url); - let cohort_snapshot = read_optional_bytes(&cohort_path)?; - let epoch_snapshot = read_optional_bytes(&epoch_path)?; - let replacement = RecoveryCohort::new(target_url); - let result = write_recovery_cohort(&runtime, &replacement) - .and_then(|()| remove_optional_state_file(&epoch_path)) - .and_then(|()| { - after_rotation(); - stop_owned_matching_records(&runtime, target_url, true) - }); - if let Err(error) = result { - let mut rollback_errors = Vec::new(); - if let Err(restore_error) = restore_optional_bytes(&cohort_path, cohort_snapshot.as_deref()) - { - rollback_errors.push(restore_error); - } - if let Err(restore_error) = restore_optional_bytes(&epoch_path, epoch_snapshot.as_deref()) { - rollback_errors.push(restore_error); - } - return if rollback_errors.is_empty() { - Err(error) - } else { - Err(format!( - "{error}; additionally failed to restore gateway recovery state: {}", - rollback_errors.join("; ") - )) - }; - } - Ok(()) -} - -#[cfg(test)] -fn stop_owned(target_url: &str) -> Result<(), String> { - let runtime = state_dir()?; - stop_owned_matching_records(&runtime, target_url, false) -} - -fn stop_owned_matching_records( - runtime: &Path, - target_url: &str, - endpoint_is_locked: bool, -) -> Result<(), String> { - let mut errors = Vec::new(); - for owner_path in owner_paths(runtime)? { - match read_owner_record(&owner_path) { - Ok(Some(owner)) if owner.url == target_url => { - let result = if endpoint_is_locked { - stop_owned_record_after_lock(runtime, &owner_path) - } else { - stop_owned_record(runtime, &owner_path) - }; - if let Err(error) = result { - errors.push(error); - } - } - Ok(_) => {} - Err(error) => errors.push(error), - } - } - if errors.is_empty() { - Ok(()) - } else { - Err(errors.join("; ")) - } -} - -fn read_optional_bytes(path: &Path) -> Result>, String> { - match fs::read(path) { - Ok(bytes) => Ok(Some(bytes)), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(format!("failed to read {}: {error}", path.display())), - } -} - -fn restore_optional_bytes(path: &Path, bytes: Option<&[u8]>) -> Result<(), String> { - match bytes { - Some(bytes) => atomic_write(path, bytes), - None => remove_optional_state_file(path), - } -} - -fn remove_optional_state_file(path: &Path) -> Result<(), String> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(format!("failed to remove {}: {error}", path.display())), - } -} - -pub(crate) fn owner_paths(runtime: &Path) -> Result, String> { - let entries = match fs::read_dir(runtime) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(error) => { - return Err(format!( - "failed to enumerate sidecar ownership in {}: {error}", - runtime.display() - )); - } - }; - let mut paths = entries - .filter_map(Result::ok) - .filter_map(|entry| { - let name = entry.file_name(); - let name = name.to_str()?; - (is_sidecar_owner_name(name) && entry.file_type().ok()?.is_file()).then(|| entry.path()) - }) - .collect::>(); - paths.sort(); - Ok(paths) -} - -fn is_sidecar_owner_name(name: &str) -> bool { - name == "sidecar.owner.json" - || (name.starts_with("sidecar-") && name.ends_with(".owner.json")) - || (name.ends_with("-sidecar.owner.json")) - || (name.contains("-sidecar-") && name.ends_with(".owner.json")) -} - -pub(crate) fn owner_pid_path(runtime: &Path, owner_path: &Path, url: &str) -> PathBuf { - let name = owner_path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or_default(); - if name == "sidecar.owner.json" { - return runtime.join("sidecar.pid"); - } - if name.ends_with("-sidecar.owner.json") { - return owner_path.with_file_name(name.replace(".owner.json", ".pid")); - } - if name.contains("-sidecar-") && !name.starts_with("sidecar-") { - return owner_path.with_file_name(name.replace(".owner.json", ".pid")); - } - pid_path(runtime, url) + let fingerprint = env::var(crate::config::BOOTSTRAP_FINGERPRINT_ENV) + .ok() + .filter(|value| !value.is_empty()); + let record = OwnerRecord::new(std::process::id(), &url, &token, fingerprint.as_deref()); + let path = owner_path(&state, &url); + write_owner_record(&path, &record)?; + Ok(Some(OwnerGuard { path, record })) } -pub(crate) fn stop_owned_record(runtime: &Path, owner_path: &Path) -> Result<(), String> { - let Some(initial_owner) = read_owner_record(owner_path)? else { +pub(crate) fn stop_owned_and_reset(url: &str) -> Result<(), String> { + let state = state_dir()?; + if !state.exists() { return Ok(()); - }; - let _lock = lock_endpoint(runtime, &initial_owner.url)?; - stop_owned_record_after_lock(runtime, owner_path) -} - -fn stop_owned_record_after_lock(runtime: &Path, owner_path: &Path) -> Result<(), String> { - let Some(owner) = read_owner_record(owner_path)? else { + } + let _lock = lock_endpoint(&state, url)?; + let path = owner_path(&state, url); + let Some(owner) = read_owner_record(&path)? else { return Ok(()); }; - let url = owner.url.as_str(); - let token = (!owner.shutdown_token.is_empty()) - .then_some(owner.shutdown_token.as_str()) - .ok_or_else(|| { - format!( - "sidecar ownership {} has no shutdown token", - owner_path.display() - ) - })?; - let bootstrap_fingerprint = owner - .bootstrap_fingerprint - .as_deref() - .filter(|fingerprint| !fingerprint.is_empty()) - .ok_or_else(|| { - format!( - "sidecar ownership {} has no authenticated bootstrap fingerprint", - owner_path.display() - ) - })?; - let pid_path = owner_pid_path(runtime, owner_path, url); - match probe(url, Some(bootstrap_fingerprint)) { + if !owner.valid_for(url) { + return Err(format!( + "refusing to stop gateway from invalid ownership record {}", + path.display() + )); + } + match probe(url, owner.bootstrap_fingerprint.as_deref()) { RelayHealth::Unavailable => { - let _ = fs::remove_file(owner_path); - let _ = fs::remove_file(&pid_path); + remove_if_matches(&path, &owner)?; return Ok(()); } RelayHealth::Compatible => {} RelayHealth::Incompatible | RelayHealth::Foreign => { return Err(format!( - "refusing to stop {url}: sidecar ownership points to a foreign listener" + "refusing to stop an unverified process at managed gateway URL {url}" )); } } - request_shutdown(url, token)?; - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - if probe(url, Some(bootstrap_fingerprint)) == RelayHealth::Unavailable { - let _ = fs::remove_file(owner_path); - let _ = fs::remove_file(&pid_path); - return Ok(()); + request_shutdown(url, &owner.shutdown_token)?; + let deadline = Instant::now() + SHUTDOWN_TIMEOUT; + loop { + match probe(url, owner.bootstrap_fingerprint.as_deref()) { + RelayHealth::Unavailable => break, + RelayHealth::Compatible if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(50)); + } + RelayHealth::Compatible => { + return Err(format!("managed Relay gateway at {url} did not stop")); + } + RelayHealth::Incompatible | RelayHealth::Foreign => { + return Err(format!( + "a different process replaced the managed Relay gateway at {url} during shutdown" + )); + } } - thread::sleep(Duration::from_millis(50)); } - Err(format!( - "timed out waiting for managed sidecar at {url} to stop" - )) -} - -pub(super) fn runtime_dir() -> PathBuf { - runtime_dir_for( - env::var_os("XDG_RUNTIME_DIR"), - env::var_os("TMPDIR"), - env::var_os("TEMP"), - env::temp_dir(), - verified_runtime_user(), - None, - ) + remove_if_matches(&path, &owner) } -#[cfg(unix)] -fn verified_runtime_user() -> Option { - // SAFETY: `geteuid` has no preconditions and does not dereference pointers. - Some(format!("uid-{}", unsafe { libc::geteuid() }).into()) -} - -#[cfg(not(unix))] -fn verified_runtime_user() -> Option { - env::var_os("USERNAME").or_else(|| env::var_os("USER")) +fn write_owner_record(path: &Path, record: &OwnerRecord) -> Result<(), String> { + let bytes = serde_json::to_vec(record) + .map_err(|error| format!("failed to encode gateway ownership: {error}"))?; + atomic_write(path, &bytes) } -pub(crate) fn runtime_dir_for( - xdg_runtime_dir: Option, - tmpdir: Option, - temp: Option, - temp_dir: PathBuf, - user: Option, - username: Option, -) -> PathBuf { - if let Some(base) = xdg_runtime_dir { - return PathBuf::from(base).join("nemo-relay-plugin"); +pub(super) fn read_owner_record(path: &Path) -> Result, String> { + match fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(|error| { + format!( + "failed to parse gateway ownership {}: {error}", + path.display() + ) + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!( + "failed to read gateway ownership {}: {error}", + path.display() + )), } - PathBuf::from(tmpdir.or(temp).unwrap_or_else(|| temp_dir.into_os_string())) - .join(runtime_user_segment(user, username)) - .join("nemo-relay-plugin") } -pub(super) fn create_private_runtime_dir(path: &Path) -> std::io::Result<()> { - fs::create_dir_all(path)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; +fn remove_if_matches(path: &Path, expected: &OwnerRecord) -> Result<(), String> { + if read_owner_record(path)?.as_ref() != Some(expected) { + return Ok(()); + } + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "failed to remove gateway ownership {}: {error}", + path.display() + )), } - Ok(()) } pub(crate) fn lock_name(url: &str) -> String { @@ -882,22 +305,7 @@ pub(crate) fn lock_name(url: &str) -> String { Some(format!("{host}-{port}")) }) .unwrap_or_else(|| url.to_string()); - sanitize_filesystem_segment(&raw) -} - -fn runtime_user_segment( - user: Option, - username: Option, -) -> String { - let raw = user - .or(username) - .and_then(|value| value.into_string().ok()) - .unwrap_or_else(|| "unknown-user".into()); - sanitize_filesystem_segment(&raw) -} - -fn sanitize_filesystem_segment(raw: &str) -> String { - let sanitized: String = raw + let sanitized = raw .chars() .map(|character| { if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { @@ -906,7 +314,7 @@ fn sanitize_filesystem_segment(raw: &str) -> String { '_' } }) - .collect(); + .collect::(); if sanitized.is_empty() { "unknown".into() } else { diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index b3f7bed3c..4127829ae 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -577,6 +577,10 @@ fn cli_hooks_and_mcp_share_the_same_persistent_identity_for_each_host() { drop(probe); let gateway_url = format!("http://{address}"); let generation = write_active_generation(temp.path()); + let (mut mcp, mcp_stdin) = start_mcp_client_with_idle_timeout(temp.path(), address, "10"); + let owner = wait_for_owned_sidecar(temp.path(), None); + assert_eq!(owner["url"], gateway_url); + let mut hook = Command::new(gateway_bin()) .args(["hook-forward", agent, "--gateway-url", &gateway_url]) .arg("--generation-file") @@ -601,16 +605,11 @@ fn cli_hooks_and_mcp_share_the_same_persistent_identity_for_each_host() { let hook_output = wait_child_with_output(hook); assert!( hook_output.status.success(), - "{agent} cold hook recovery failed: {}", + "{agent} hook failed: {}", String::from_utf8_lossy(&hook_output.stderr) ); - - let (mut mcp, mcp_stdin) = start_mcp_client_with_idle_timeout(temp.path(), address, "10"); drop(mcp_stdin); assert!(wait_child(&mut mcp).success()); - - let owner = wait_for_owned_sidecar(temp.path(), None); - assert_eq!(owner["url"], gateway_url); stop_owned_sidecar(&owner); wait_for_port_closed(address); } @@ -1246,17 +1245,13 @@ fn cli_mcp_restarts_one_stopped_gateway_then_fails_after_the_second_stop() { #[test] fn cli_mcp_staggered_clients_share_one_endpoint_restart_budget() { let temp = tempfile::tempdir().unwrap(); + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = probe.local_addr().unwrap(); + drop(probe); let (mut fast_client, _fast_stdin) = - start_mcp_client_with_idle_timeout(temp.path(), "127.0.0.1:0".parse().unwrap(), "1"); + start_mcp_client_with_idle_timeout(temp.path(), address, "1"); let first = wait_for_owned_sidecar(temp.path(), None); let first_pid = first["pid"].as_u64().unwrap(); - let address = first["url"] - .as_str() - .unwrap() - .strip_prefix("http://") - .unwrap() - .parse() - .unwrap(); // Offset the heartbeat phases while keeping the persistent fingerprint identical. thread::sleep(Duration::from_millis(150)); let (mut slow_client, _slow_stdin) = @@ -1284,52 +1279,6 @@ fn cli_mcp_staggered_clients_share_one_endpoint_restart_budget() { ); } -#[test] -fn cli_hook_recovery_consumes_the_same_restart_as_an_overlapping_mcp() { - let temp = tempfile::tempdir().unwrap(); - let generation = temp.path().join("plugin/.nemo-relay-generation"); - std::fs::create_dir_all(generation.parent().unwrap()).unwrap(); - std::fs::write(&generation, "active-generation\n").unwrap(); - let (mut mcp, _stdin) = start_mcp_client_with_generation( - temp.path(), - "127.0.0.1:0".parse().unwrap(), - "1", - Some(&generation), - ); - let first = wait_for_owned_sidecar(temp.path(), None); - let first_pid = first["pid"].as_u64().unwrap(); - let address = first["url"] - .as_str() - .unwrap() - .strip_prefix("http://") - .unwrap() - .parse::() - .unwrap(); - - stop_owned_sidecar(&first); - let hook = run_persistent_hook(temp.path(), address, &generation, true); - assert!( - hook.status.success(), - "hook recovery failed: {}", - String::from_utf8_lossy(&hook.stderr) - ); - let second = wait_for_owned_sidecar(temp.path(), Some(first_pid)); - let second_pid = second["pid"].as_u64().unwrap(); - stop_owned_sidecar(&second); - - assert!(!wait_child(&mut mcp).success()); - let unexpected = find_runtime_files_matching(temp.path(), "sidecar-", ".owner.json") - .into_iter() - .filter_map(|path| std::fs::read(path).ok()) - .filter_map(|raw| serde_json::from_slice::(&raw).ok()) - .any(|owner| { - owner["pid"] - .as_u64() - .is_some_and(|pid| pid != first_pid && pid != second_pid) - }); - assert!(!unexpected, "the MCP performed an unaccounted third launch"); -} - #[test] fn cli_retired_persistent_hook_cannot_restart_the_gateway() { let temp = tempfile::tempdir().unwrap(); @@ -3359,7 +3308,7 @@ fn cli_forward_only_unfenced_hook_rejects_a_foreign_listener_before_posting() { let (output, requests) = run_forward_only_fake_bootstrap_listener(FakeBootstrapProof::Missing); assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("foreign process")); + assert!(String::from_utf8_lossy(&output.stderr).contains("not a compatible")); assert!(requests.iter().all(|request| !request.starts_with("POST "))); } diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs index 9a138b918..4319c05b5 100644 --- a/crates/cli/tests/coverage/installer_tests.rs +++ b/crates/cli/tests/coverage/installer_tests.rs @@ -110,111 +110,6 @@ async fn transparent_hook_delivery_authenticates_the_wrapper_gateway() { .unwrap(); } -#[test] -fn hook_delivery_generation_guard_blocks_replacement_until_drop() { - let dir = tempfile::tempdir().unwrap(); - let generation_path = dir.path().join(".nemo-relay-generation"); - crate::install_generation::write_new_generation(&generation_path).unwrap(); - let generation = InstallGeneration::capture(generation_path.clone()).unwrap(); - let guard = hook_generation_guard(&generation).unwrap(); - - let error = crate::install_generation::GenerationRetirement::acquire_with_timeout( - &generation_path, - std::time::Duration::from_millis(25), - ) - .err() - .expect("hook delivery guard did not block generation retirement"); - assert!(error.contains("timed out waiting"), "{error}"); - - drop(guard); - assert!( - crate::install_generation::GenerationRetirement::acquire_with_timeout( - &generation_path, - std::time::Duration::from_secs(1), - ) - .unwrap() - .is_some() - ); -} - -#[test] -fn hook_gateway_retries_retirement_during_acquisition_and_delivery_pinning() { - let mut outcomes = std::collections::VecDeque::from([ - Err("shared Relay gateway lifecycle was retired by an integration update".to_string()), - Err("shared Relay gateway lifecycle was retired by an integration update".to_string()), - Ok("pinned"), - ]); - let mut attempts = 0; - - let result = retry_retired_gateway_cohort(|| { - attempts += 1; - outcomes.pop_front().unwrap() - }) - .unwrap(); - - assert_eq!(result, "pinned"); - assert_eq!(attempts, 3); -} - -#[test] -fn hook_gateway_does_not_retry_non_retirement_failures() { - let mut attempts = 0; - let error = retry_retired_gateway_cohort::<()>(|| { - attempts += 1; - Err("foreign listener".into()) - }) - .unwrap_err(); - - assert_eq!(error, "foreign listener"); - assert_eq!(attempts, 1); -} - -#[test] -fn hook_gateway_stops_retrying_retirement_at_its_deadline() { - let mut attempts = 0; - let error = retry_retired_gateway_cohort_until::<()>(Instant::now(), || { - attempts += 1; - Err("shared Relay gateway lifecycle was retired by an integration update".into()) - }) - .unwrap_err(); - - assert!(error.contains("retired by an integration update")); - assert_eq!(attempts, 1); -} - -#[test] -fn hook_gateway_retirement_retries_are_rate_limited() { - let mut attempts = 0; - let started = Instant::now(); - let current = std::cell::Cell::new(started); - let sleeps = std::cell::RefCell::new(Vec::new()); - let deadline = started + Duration::from_millis(125); - let error = retry_retired_gateway_cohort_with_clock::<()>( - deadline, - || current.get(), - |duration| { - sleeps.borrow_mut().push(duration); - current.set(current.get() + duration); - }, - || { - attempts += 1; - Err("shared Relay gateway lifecycle was retired by an integration update".into()) - }, - ) - .unwrap_err(); - - assert!(error.contains("retired by an integration update")); - assert_eq!(attempts, 4); - assert_eq!( - sleeps.into_inner(), - [ - Duration::from_millis(50), - Duration::from_millis(50), - Duration::from_millis(25), - ] - ); -} - #[test] fn hook_payload_reader_normalizes_blank_input_and_accepts_the_exact_limit() { assert_eq!(read_hook_payload_from(" \n\t".as_bytes(), 3).unwrap(), "{}"); @@ -259,7 +154,7 @@ fn explicit_persistent_destinations_ignore_ambient_urls() { false, ); assert_eq!(destination.gateway_url, "http://installed"); - assert_eq!(destination.lifecycle, HookGatewayLifecycle::Recover); + assert_eq!(destination.lifecycle, HookGatewayLifecycle::Existing); let destination = resolve_hook_destination(None, Some("http://dynamic".into()), false, false); assert_eq!(destination.gateway_url, "http://dynamic"); @@ -284,7 +179,7 @@ fn explicit_persistent_destinations_ignore_ambient_urls() { let destination = resolve_hook_destination(None, None, false, false); assert_eq!(destination.gateway_url, crate::sidecar::DEFAULT_URL); - assert_eq!(destination.lifecycle, HookGatewayLifecycle::Recover); + assert_eq!(destination.lifecycle, HookGatewayLifecycle::Existing); } #[test] diff --git a/crates/cli/tests/coverage/mcp_gateway_tests.rs b/crates/cli/tests/coverage/mcp_gateway_tests.rs index 5d0074858..b53650abc 100644 --- a/crates/cli/tests/coverage/mcp_gateway_tests.rs +++ b/crates/cli/tests/coverage/mcp_gateway_tests.rs @@ -74,13 +74,13 @@ async fn lifecycle_retirement_is_checked_before_a_healthy_heartbeat() { async { Ok(Some("replacement".into())) } }, |_address, _expected| async { panic!("retired lifecycle attempted recovery") }, - || async { Err(CliError::Launch("cohort retired".into())) }, + || async { Err(CliError::Launch("generation retired".into())) }, )); tokio::time::advance(Duration::from_secs(30)).await; let error = monitor.await.unwrap().unwrap_err(); - assert!(error.to_string().contains("cohort retired")); + assert!(error.to_string().contains("generation retired")); assert_eq!(health_calls.load(Ordering::SeqCst), 0); } @@ -112,7 +112,7 @@ async fn lifecycle_retirement_during_health_is_checked_before_adoption() { let retired = retired_for_verification.load(Ordering::SeqCst); async move { if retired { - Err(CliError::Launch("cohort retired during health".into())) + Err(CliError::Launch("generation retired during health".into())) } else { Ok(()) } @@ -130,85 +130,9 @@ async fn lifecycle_retirement_during_health_is_checked_before_adoption() { assert_eq!(health_calls.load(Ordering::SeqCst), 1); } -#[test] -fn production_lifecycle_verifier_rejects_a_rotated_cohort() { - use std::ffi::OsString; - - struct EnvRestore { - previous: Option, - } - impl Drop for EnvRestore { - fn drop(&mut self) { - // SAFETY: This test holds the repository-wide environment mutex. - unsafe { - match self.previous.take() { - Some(value) => { - std::env::set_var(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV, value) - } - None => std::env::remove_var(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV), - } - } - } - } - - let _environment = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); - let state = tempfile::tempdir().unwrap(); - let previous = std::env::var_os(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV); - // SAFETY: This test holds the repository-wide environment mutex. - unsafe { std::env::set_var(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV, state.path()) }; - let _restore = EnvRestore { previous }; - let url = "http://127.0.0.1:47632"; - let old = crate::sidecar::EndpointLease::acquire(state.path(), url).unwrap(); - let cohort = old.cohort_id().to_string(); - crate::sidecar::stop_owned_sidecar_and_reset(url).unwrap(); - - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - let error = runtime - .block_on(verify_lifecycle_async(None, cohort, url.into())) - .unwrap_err(); - - assert!( - error - .to_string() - .contains("retired by an integration update"), - "{error}" - ); -} - #[test] fn generation_transaction_polling_is_cancellable_for_clean_mcp_shutdown() { - struct EnvRestore(Option); - impl Drop for EnvRestore { - fn drop(&mut self) { - // SAFETY: The test holds the repository-wide environment mutex. - unsafe { - match self.0.take() { - Some(value) => { - std::env::set_var(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV, value) - } - None => std::env::remove_var(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV), - } - } - } - } - - let _environment = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); let dir = tempfile::tempdir().unwrap(); - let state = dir.path().join("state"); - let previous = std::env::var_os(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV); - // SAFETY: This test holds the repository-wide environment mutex. - unsafe { std::env::set_var(crate::sidecar::BOOTSTRAP_STATE_DIR_ENV, &state) }; - let _restore = EnvRestore(previous); - let gateway_url = "http://127.0.0.1:47632"; - let endpoint_lease = crate::sidecar::EndpointLease::acquire(&state, gateway_url).unwrap(); - let cohort = endpoint_lease.cohort_id().to_string(); let path = dir .path() .join(crate::install_generation::GENERATION_FILE_NAME); @@ -224,11 +148,7 @@ fn generation_transaction_polling_is_cancellable_for_clean_mcp_shutdown() { .build() .unwrap(); runtime.block_on(async { - let verification = tokio::spawn(verify_lifecycle_async( - Some(generation), - cohort, - gateway_url.into(), - )); + let verification = tokio::spawn(verify_lifecycle_async(Some(generation))); tokio::time::sleep(Duration::from_millis(50)).await; assert!(!verification.is_finished()); verification.abort(); @@ -240,54 +160,6 @@ fn generation_transaction_polling_is_cancellable_for_clean_mcp_shutdown() { }); retirement.restore_after_rollback().unwrap(); } - -#[test] -fn endpoint_transaction_polling_is_cancellable_for_clean_mcp_shutdown() { - struct EnvRestore(Option); - impl Drop for EnvRestore { - fn drop(&mut self) { - // SAFETY: The test holds the repository-wide environment mutex. - unsafe { - match self.0.take() { - Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - } - } - } - - let _environment = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); - let dir = tempfile::tempdir().unwrap(); - let previous = std::env::var_os("XDG_CONFIG_HOME"); - // SAFETY: The test holds the repository-wide environment mutex. - unsafe { std::env::set_var("XDG_CONFIG_HOME", dir.path()) }; - let _restore = EnvRestore(previous); - let state = crate::sidecar::sidecar_state_dir().unwrap(); - let url = "http://127.0.0.1:47632"; - let lease = crate::sidecar::EndpointLease::acquire(&state, url).unwrap(); - let cohort = lease.cohort_id().to_string(); - let transaction = crate::sidecar::lock_sidecar_endpoint(&state, url).unwrap(); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - - runtime.block_on(async { - let verification = tokio::spawn(verify_lifecycle_async(None, cohort, url.into())); - tokio::time::sleep(Duration::from_millis(50)).await; - assert!(!verification.is_finished()); - verification.abort(); - let error = tokio::time::timeout(Duration::from_millis(250), verification) - .await - .expect("endpoint lifecycle poll ignored MCP cancellation") - .unwrap_err(); - assert!(error.is_cancelled()); - }); - drop(transaction); -} - #[tokio::test] async fn concurrent_clients_consume_the_same_replacement_allowance() { use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/cli/tests/coverage/plugin_host_tests.rs b/crates/cli/tests/coverage/plugin_host_tests.rs index df99bb815..bc6b6558e 100644 --- a/crates/cli/tests/coverage/plugin_host_tests.rs +++ b/crates/cli/tests/coverage/plugin_host_tests.rs @@ -10,7 +10,7 @@ use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::thread; -use std::time::{Duration, Instant}; +use std::time::Duration; use serde_json::{Value, json}; use tempfile::tempdir; @@ -21,24 +21,6 @@ use crate::config::{BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey}; const TEST_PLUGIN_GENERATION: &str = "test-generation"; -#[cfg(unix)] -fn wait_for_published_pid(path: &Path, process: &str) -> i32 { - let deadline = Instant::now() + Duration::from_secs(3); - loop { - if let Some(pid) = fs::read_to_string(path) - .ok() - .and_then(|raw| raw.trim().parse::().ok()) - { - return pid; - } - assert!( - Instant::now() < deadline, - "{process} did not publish a complete PID" - ); - thread::sleep(Duration::from_millis(10)); - } -} - #[derive(Default)] struct FakeCodexHooksClient { hook_lists: VecDeque, String>>, @@ -2750,414 +2732,6 @@ fn claude_reinstall_uses_fresh_backup_after_prior_restore() { assert!(!backup_path(&settings).exists()); } -#[test] -fn advisory_sidecar_lock_blocks_until_the_owner_releases_it() { - let dir = tempdir().unwrap(); - let url = "http://127.0.0.1:47632"; - let owner = lock_sidecar_endpoint(dir.path(), url).unwrap(); - let path = dir.path().to_path_buf(); - let (ready_sender, ready_receiver) = std::sync::mpsc::channel(); - let (acquired_sender, acquired_receiver) = std::sync::mpsc::channel(); - let contender = thread::spawn(move || { - ready_sender.send(()).unwrap(); - let lock = lock_sidecar_endpoint(&path, url).unwrap(); - acquired_sender.send(()).unwrap(); - drop(lock); - }); - - ready_receiver.recv_timeout(Duration::from_secs(5)).unwrap(); - let error = lock_sidecar_endpoint_for(dir.path(), url, Duration::ZERO).unwrap_err(); - assert!(error.contains("timed out waiting for sidecar lock")); - drop(owner); - acquired_receiver - .recv_timeout(Duration::from_secs(5)) - .unwrap(); - contender.join().unwrap(); -} - -#[test] -fn sidecar_lock_zero_wait_fails_when_the_endpoint_is_owned() { - let dir = tempdir().unwrap(); - let url = "http://127.0.0.1:47632"; - let owner = lock_sidecar_endpoint(dir.path(), url).unwrap(); - - let error = lock_sidecar_endpoint_for(dir.path(), url, Duration::ZERO).unwrap_err(); - - assert!(error.contains("timed out waiting for sidecar lock")); - drop(owner); -} - -#[test] -fn sidecar_ownership_records_are_endpoint_scoped_and_recognize_legacy_files() { - let dir = tempdir().unwrap(); - let first = sidecar_owner_path(dir.path(), "http://127.0.0.1:47632"); - let second = sidecar_owner_path(dir.path(), "http://127.0.0.1:47633"); - let legacy = dir.path().join("codex-sidecar.owner.json"); - let ignored = sidecar_owner_path(dir.path(), "http://127.0.0.1:47634"); - for path in [&first, &second, &legacy, &ignored] { - fs::write(path, "{}").unwrap(); - } - - let paths = sidecar_owner_paths(dir.path()).unwrap(); - - assert_eq!(paths.len(), 4); - assert!(paths.contains(&first)); - assert!(paths.contains(&second)); - assert!(paths.contains(&legacy)); - assert!(paths.contains(&ignored)); - assert_ne!(first, second); -} - -#[test] -fn managed_sidecar_publishes_valid_ownership_before_parent_validation() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let state = dir.path().join("bootstrap-state"); - let _state = EnvVarGuard::set_path(BOOTSTRAP_STATE_DIR_ENV, &state); - let _token = - EnvVarGuard::set_value("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", "test-shutdown-token"); - let _fingerprint = - EnvVarGuard::set_value(crate::config::BOOTSTRAP_FINGERPRINT_ENV, "test-fingerprint"); - let address = "127.0.0.1:47632".parse().unwrap(); - - publish_sidecar_owner_from_env(address).unwrap(); - - let url = format!("http://{address}"); - let owner_path = sidecar_owner_path(&state, &url); - let pid_path = sidecar_pid_path(&state, &url); - validate_sidecar_owner( - &owner_path, - &pid_path, - std::process::id(), - &url, - "test-shutdown-token", - Some("test-fingerprint"), - ) - .unwrap(); -} - -#[test] -fn managed_shutdown_rejects_a_listener_without_authenticated_health_proof() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let state = dir.path().join("bootstrap-state"); - fs::create_dir_all(&state).unwrap(); - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap(); - let url = format!("http://{address}"); - let owner_path = sidecar_owner_path(&state, &url); - write_sidecar_owner( - &owner_path, - std::process::id(), - &url, - "must-not-be-sent", - Some("hmac-sha256:fake-owner-fingerprint"), - ) - .unwrap(); - let (request_sender, request_receiver) = std::sync::mpsc::channel(); - let listener_thread = thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let request = read_http_request(&mut stream); - request_sender.send(request).unwrap(); - let body = format!( - r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{},"instance_id":"test-instance"}}"#, - env!("CARGO_PKG_VERSION"), - BOOTSTRAP_PROTOCOL_VERSION - ); - stream - .write_all( - format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ) - .as_bytes(), - ) - .unwrap(); - }); - - let error = stop_owned_sidecar_record(&state, &owner_path).unwrap_err(); - - assert!(error.contains("foreign listener"), "{error}"); - let request = request_receiver - .recv_timeout(Duration::from_secs(2)) - .unwrap(); - let request = String::from_utf8(request).unwrap(); - assert!(request.starts_with("GET /healthz HTTP/1.1"), "{request}"); - assert!(!request.contains("must-not-be-sent"), "{request}"); - assert!(owner_path.exists()); - listener_thread.join().unwrap(); -} - -#[cfg(unix)] -#[test] -fn failed_sidecar_startup_terminates_the_detached_process_group() { - let dir = tempdir().unwrap(); - let grandchild_pid_path = dir.path().join("grandchild.pid"); - let mut command = std::process::Command::new("sh"); - command - .args(["-c", "sleep 30 & echo $! > \"$1\"; exit 1", "sh"]) - .arg(&grandchild_pid_path); - configure_detached_sidecar(&mut command); - let mut child = command.spawn().unwrap(); - let grandchild_pid = wait_for_published_pid(&grandchild_pid_path, "sidecar grandchild"); - assert!(!child.wait().unwrap().success()); - - terminate_sidecar_process_tree(&mut child); - - let deadline = Instant::now() + Duration::from_secs(3); - loop { - // SAFETY: Signal 0 performs an existence check and does not alter the target process. - let result = unsafe { libc::kill(grandchild_pid, 0) }; - if result == -1 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { - break; - } - if Instant::now() >= deadline { - // SAFETY: Best-effort cleanup of the test process if the process-group assertion fails. - unsafe { libc::kill(grandchild_pid, libc::SIGKILL) }; - panic!("detached sidecar grandchild {grandchild_pid} survived startup termination"); - } - thread::sleep(Duration::from_millis(20)); - } -} - -#[cfg(windows)] -#[test] -fn windows_sidecar_job_terminates_an_assigned_process() { - let job = SidecarJob::create().unwrap(); - assert!(job.name().starts_with("Local\\NeMoRelaySidecar-")); - let mut command = std::process::Command::new("cmd"); - command.args(["/C", "ping -n 30 127.0.0.1 >NUL"]); - configure_detached_sidecar(&mut command); - let mut child = command.spawn().unwrap(); - match job.assign(&child) { - Ok(()) => { - job.terminate(); - assert!(!child.wait().unwrap().success()); - } - Err(error) => { - // Restrictive host Job Objects may forbid nested assignment; production reports the - // error and refuses to serve without its process-tree cleanup guarantee. - assert!(error.contains("Job Object"), "{error}"); - let _ = child.kill(); - let _ = child.wait(); - } - } -} - -#[test] -fn sidecar_reaper_removes_only_the_exited_process_records() { - let dir = tempdir().unwrap(); - let url = "http://127.0.0.1:47632"; - let owner_path = sidecar_owner_path(dir.path(), url); - let pid_path = sidecar_pid_path(dir.path(), url); - let endpoint_lock = lock_sidecar_endpoint(dir.path(), url).unwrap(); - #[cfg(windows)] - let mut command = { - let mut command = std::process::Command::new("cmd"); - command.args(["/C", "ping -n 2 127.0.0.1 >NUL"]); - command - }; - #[cfg(not(windows))] - let mut command = { - let mut command = std::process::Command::new("sh"); - command.args(["-c", "sleep 0.1"]); - command - }; - let child = command.spawn().unwrap(); - let pid = child.id(); - write_sidecar_owner( - &owner_path, - pid, - url, - "test-shutdown-token", - Some("test-fingerprint"), - ) - .unwrap(); - fs::write(&pid_path, pid.to_string()).unwrap(); - - handoff_sidecar_to_reaper( - child, - owner_path.clone(), - pid_path.clone(), - sidecar_lock_path(dir.path(), url), - ) - .unwrap(); - drop(endpoint_lock); - - let deadline = Instant::now() + Duration::from_secs(3); - while (owner_path.exists() || pid_path.exists()) && Instant::now() < deadline { - thread::sleep(Duration::from_millis(20)); - } - assert!(!owner_path.exists()); - assert!(!pid_path.exists()); -} - -#[cfg(unix)] -#[test] -fn sidecar_reaper_terminates_descendants_left_by_an_exited_gateway() { - let dir = tempdir().unwrap(); - let url = "http://127.0.0.1:47632"; - let owner_path = sidecar_owner_path(dir.path(), url); - let pid_path = sidecar_pid_path(dir.path(), url); - let descendant_pid_path = dir.path().join("descendant.pid"); - let endpoint_lock = lock_sidecar_endpoint(dir.path(), url).unwrap(); - let mut command = std::process::Command::new("sh"); - command - .args(["-c", "sleep 30 & echo $! > \"$1\"; exit 0", "sh"]) - .arg(&descendant_pid_path); - configure_detached_sidecar(&mut command); - let child = command.spawn().unwrap(); - let pid = child.id(); - write_sidecar_owner( - &owner_path, - pid, - url, - "test-shutdown-token", - Some("test-fingerprint"), - ) - .unwrap(); - fs::write(&pid_path, pid.to_string()).unwrap(); - handoff_sidecar_to_reaper( - child, - owner_path.clone(), - pid_path.clone(), - sidecar_lock_path(dir.path(), url), - ) - .unwrap(); - drop(endpoint_lock); - - let descendant_pid = wait_for_published_pid(&descendant_pid_path, "sidecar descendant"); - let deadline = Instant::now() + Duration::from_secs(3); - loop { - // SAFETY: Signal 0 only checks whether the finite test process is still present. - let descendant_gone = unsafe { libc::kill(descendant_pid, 0) } == -1 - && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH); - if descendant_gone && !owner_path.exists() && !pid_path.exists() { - break; - } - if Instant::now() >= deadline { - // SAFETY: Best-effort cleanup of the finite test process on assertion failure. - unsafe { libc::kill(descendant_pid, libc::SIGKILL) }; - panic!("sidecar descendant {descendant_pid} survived direct gateway exit"); - } - thread::sleep(Duration::from_millis(20)); - } -} - -#[test] -fn sidecar_reaper_does_not_block_other_cleanup_on_a_contended_lock() { - let dir = tempdir().unwrap(); - let blocked_url = "http://127.0.0.1:47632"; - let free_url = "http://127.0.0.1:47633"; - let blocked_lock = lock_sidecar_endpoint(dir.path(), blocked_url).unwrap(); - let mut records = Vec::new(); - for url in [blocked_url, free_url] { - let child = short_lived_command().spawn().unwrap(); - let pid = child.id(); - let owner_path = sidecar_owner_path(dir.path(), url); - let pid_path = sidecar_pid_path(dir.path(), url); - write_sidecar_owner( - &owner_path, - pid, - url, - "test-shutdown-token", - Some("test-fingerprint"), - ) - .unwrap(); - fs::write(&pid_path, pid.to_string()).unwrap(); - handoff_sidecar_to_reaper( - child, - owner_path.clone(), - pid_path.clone(), - sidecar_lock_path(dir.path(), url), - ) - .unwrap(); - records.push((owner_path, pid_path)); - } - - let deadline = Instant::now() + Duration::from_secs(3); - while (records[1].0.exists() || records[1].1.exists()) && Instant::now() < deadline { - thread::sleep(Duration::from_millis(20)); - } - assert!(!records[1].0.exists()); - assert!(!records[1].1.exists()); - assert!(records[0].0.exists()); - - drop(blocked_lock); - let deadline = Instant::now() + Duration::from_secs(3); - while (records[0].0.exists() || records[0].1.exists()) && Instant::now() < deadline { - thread::sleep(Duration::from_millis(20)); - } - assert!(!records[0].0.exists()); - assert!(!records[0].1.exists()); -} - -#[test] -fn sidecar_state_directory_does_not_follow_runtime_environment() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let _config_home = EnvVarGuard::remove("XDG_CONFIG_HOME"); - let first = { - let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &dir.path().join("runtime-a")); - sidecar_state_dir().unwrap() - }; - let second = { - let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &dir.path().join("runtime-b")); - sidecar_state_dir().unwrap() - }; - - assert_eq!(first, second); -} - -#[test] -fn sidecar_lock_name_uses_gateway_host_and_port() { - assert_eq!( - sidecar_lock_name("http://127.0.0.1:47632/hooks"), - "127.0.0.1-47632" - ); - assert_eq!(sidecar_lock_name("http://localhost"), "localhost-80"); - assert_eq!( - sidecar_lock_name("not a url/with spaces"), - "not_a_url_with_spaces" - ); -} - -#[test] -fn runtime_dir_fallback_is_user_scoped() { - let runtime = runtime_dir_for( - None, - None, - None, - std::path::PathBuf::from("/tmp"), - Some("alice/example".into()), - None, - ); - - assert_eq!( - runtime, - std::path::PathBuf::from("/tmp") - .join("alice_example") - .join("nemo-relay-plugin") - ); -} - -#[test] -fn runtime_dir_prefers_explicit_runtime_base_without_user_segment() { - let runtime = runtime_dir_for( - Some("/run/user/1000".into()), - None, - None, - std::path::PathBuf::from("/tmp"), - Some("alice".into()), - None, - ); - - assert_eq!( - runtime, - std::path::PathBuf::from("/run/user/1000").join("nemo-relay-plugin") - ); -} - #[test] fn windows_shell_argument_quoting_and_hook_encoding_preserve_paths() { let relay = std::path::PathBuf::from(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"); @@ -3272,23 +2846,6 @@ fn compile_windows_hook_test_relay(output: &Path) { ); } -#[test] -fn windows_sidecar_flags_only_request_permitted_job_breakaway() { - let base = WINDOWS_CREATE_NEW_PROCESS_GROUP | WINDOWS_CREATE_NO_WINDOW; - assert_eq!(windows_sidecar_creation_flags(false, None), (base, false)); - assert_eq!( - windows_sidecar_creation_flags(true, Some(WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK)), - (base | WINDOWS_CREATE_BREAKAWAY_FROM_JOB, false) - ); - assert_eq!( - windows_sidecar_creation_flags(true, Some(WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK)), - (base, false) - ); - assert_eq!(windows_sidecar_creation_flags(true, Some(0)), (base, true)); - assert_eq!(windows_sidecar_creation_flags(true, None), (base, true)); - assert_eq!(WINDOWS_JOB_OBJECT_LIMIT_KILL_ON_CLOSE, 0x0000_2000); -} - #[test] fn posix_shell_argument_quoting_and_hook_encoding_preserve_paths() { let relay = std::path::PathBuf::from("/tmp/NeMo $Relay`test'/bin/nemo-relay"); @@ -3309,66 +2866,6 @@ fn posix_shell_argument_quoting_and_hook_encoding_preserve_paths() { ); } -#[test] -fn unready_sidecar_child_is_terminated_and_pid_removed() { - let dir = tempdir().unwrap(); - let pid_path = dir.path().join("codex-sidecar.pid"); - let mut command = long_lived_command(); - let child = command.spawn().unwrap(); - fs::write(&pid_path, child.id().to_string()).unwrap(); - - let error = terminate_unready_sidecar(child, &pid_path, DEFAULT_URL).unwrap_err(); - - assert!(error.contains("terminated startup process")); - assert!(!pid_path.exists()); -} - -#[cfg(unix)] -#[test] -fn spawned_sidecar_uses_an_independent_process_group() { - let mut command = long_lived_command(); - configure_detached_sidecar(&mut command); - let mut child = command.spawn().unwrap(); - - let output = std::process::Command::new("ps") - .args(["-o", "pgid=", "-p", &child.id().to_string()]) - .output() - .unwrap(); - let process_group = String::from_utf8(output.stdout) - .unwrap() - .trim() - .parse::() - .unwrap(); - - assert_eq!(process_group, child.id()); - child.kill().unwrap(); - child.wait().unwrap(); -} - -#[test] -fn ensure_sidecar_releases_lock_when_startup_fails_fast() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let runtime = dir.path().join("runtime"); - let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &runtime); - - let error = loopback_bind("not a loopback url") - .and_then(|bind| { - GatewaySpec::new(bind) - .acquire() - .map(|result| result.endpoint) - }) - .unwrap_err(); - - assert!(error.contains("loopback URL")); - assert!( - !runtime - .join("nemo-relay-plugin") - .join("not_a_loopback_url-sidecar.lock") - .exists() - ); -} - #[test] fn healthz_rejects_foreign_success_response() { let dir = tempdir().unwrap(); @@ -3376,7 +2873,7 @@ fn healthz_rejects_foreign_success_response() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let handle = thread::spawn(move || { - for _ in 0..3 { + for _ in 0..2 { let (mut stream, _) = listener.accept().unwrap(); let _ = read_http_request(&mut stream); stream @@ -3389,8 +2886,7 @@ fn healthz_rejects_foreign_success_response() { let error = GatewaySpec::new(address) .acquire() - .err() - .expect("foreign listener unexpectedly acquired"); + .expect_err("foreign listener unexpectedly acquired"); assert!( error.contains("not a compatible NeMo Relay gateway"), "{error}" @@ -3398,111 +2894,6 @@ fn healthz_rejects_foreign_success_response() { handle.join().unwrap(); } -#[test] -fn startup_reprobes_a_transient_foreign_health_result_after_locking() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap(); - let handle = thread::spawn(move || { - let (mut starting, _) = listener.accept().unwrap(); - let _ = read_http_request(&mut starting); - starting - .write_all(b"HTTP/1.1 503 Starting\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") - .unwrap(); - drop(starting); - - let body = format!( - r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{},"instance_id":"test-instance"}}"#, - env!("CARGO_PKG_VERSION"), - BOOTSTRAP_PROTOCOL_VERSION - ); - for _ in 0..2 { - let (mut ready, _) = listener.accept().unwrap(); - let _ = read_http_request(&mut ready); - ready - .write_all( - format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ) - .as_bytes(), - ) - .unwrap(); - } - }); - - let endpoint = GatewaySpec::new(address).acquire().unwrap().endpoint; - - assert_eq!(endpoint.address, address); - handle.join().unwrap(); -} - -#[test] -fn active_startup_lock_waits_for_relay_identity_instead_of_rejecting_listener() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let runtime_base = dir.path().join("runtime"); - let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &runtime_base); - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap(); - let state = sidecar_state_dir().unwrap(); - fs::create_dir_all(&state).unwrap(); - let url = format!("http://{address}"); - let lock_path = sidecar_lock_path(&state, &url); - let owner_lock = lock_sidecar_endpoint(&state, &url).unwrap(); - let handle = thread::spawn(move || { - thread::sleep(Duration::from_millis(50)); - drop(owner_lock); - - let body = format!( - r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{},"instance_id":"test-instance"}}"#, - env!("CARGO_PKG_VERSION"), - BOOTSTRAP_PROTOCOL_VERSION - ); - for _ in 0..2 { - let (mut ready, _) = listener.accept().unwrap(); - let _ = read_http_request(&mut ready); - ready - .write_all( - format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ) - .as_bytes(), - ) - .unwrap(); - } - }); - - let endpoint = GatewaySpec::new(address).acquire().unwrap().endpoint; - - assert_eq!(endpoint.address, address); - assert_eq!(endpoint.url, format!("http://{address}")); - handle.join().unwrap(); - assert!(lock_path.exists()); -} - -#[cfg(unix)] -#[test] -fn start_sidecar_reports_child_exit_before_healthz_ready() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let relay = dir.path().join("nemo-relay"); - fs::write(&relay, "#!/bin/sh\nexit 7\n").unwrap(); - let mut permissions = fs::metadata(&relay).unwrap().permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&relay, permissions).unwrap(); - let _binary = EnvVarGuard::set_path("NEMO_RELAY_PLUGIN_BINARY", &relay); - - let error = start_sidecar("http://127.0.0.1:0", dir.path()).unwrap_err(); - - assert!(error.contains("exited before becoming ready"), "{error}"); - assert!(!dir.path().join("codex-sidecar.pid").exists()); -} - #[test] fn codex_uninstall_removes_only_exact_generated_hook_groups() { let dir = tempdir().unwrap(); @@ -3595,34 +2986,6 @@ fn codex_uninstall_hooks_removes_all_generated_url_variants_for_launcher() { )); } -#[cfg(windows)] -fn long_lived_command() -> std::process::Command { - let mut command = std::process::Command::new("cmd"); - command.args(["/C", "ping -n 60 127.0.0.1 >NUL"]); - command -} - -#[cfg(windows)] -fn short_lived_command() -> std::process::Command { - let mut command = std::process::Command::new("cmd"); - command.args(["/C", "ping -n 2 127.0.0.1 >NUL"]); - command -} - -#[cfg(not(windows))] -fn long_lived_command() -> std::process::Command { - let mut command = std::process::Command::new("sh"); - command.args(["-c", "sleep 60"]); - command -} - -#[cfg(not(windows))] -fn short_lived_command() -> std::process::Command { - let mut command = std::process::Command::new("sh"); - command.args(["-c", "sleep 0.1"]); - command -} - #[test] fn codex_install_hooks_persist_custom_gateway_url() { let dir = tempdir().unwrap(); @@ -4054,7 +3417,7 @@ fn shared_filesystem_helpers_cover_tables_snapshots_and_lock_branches() { } #[test] -fn shared_defaults_cover_runtime_username_and_empty_segments() { +fn shared_defaults_cover_idle_lifecycle_and_lock_names() { let dir = tempdir().unwrap(); let _home = HomeScope::enter(dir.path()); let _plugin_url = EnvVarGuard::remove("NEMO_RELAY_PLUGIN_GATEWAY_URL"); @@ -4067,24 +3430,7 @@ fn shared_defaults_cover_runtime_username_and_empty_segments() { plugin_heartbeat_interval().unwrap(), Duration::from_secs(30) ); - assert_eq!( - runtime_dir_for( - None, - None, - Some("/tmp/temp-base".into()), - dir.path().join("ignored"), - None, - Some("bob/name".into()), - ), - std::path::PathBuf::from("/tmp/temp-base") - .join("bob_name") - .join("nemo-relay-plugin") - ); assert_eq!(sidecar_lock_name(""), "unknown"); - assert_eq!( - runtime_dir_for(None, None, None, dir.path().into(), None, None), - dir.path().join("unknown-user").join("nemo-relay-plugin") - ); } #[test] diff --git a/crates/cli/tests/coverage/sidecar_state_tests.rs b/crates/cli/tests/coverage/sidecar_state_tests.rs index dd5f7d2d7..1eec74690 100644 --- a/crates/cli/tests/coverage/sidecar_state_tests.rs +++ b/crates/cli/tests/coverage/sidecar_state_tests.rs @@ -1,342 +1,66 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use super::*; use std::ffi::{OsStr, OsString}; use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; -use std::path::Path; -use std::thread; -use std::time::Duration; - -use tempfile::tempdir; - -use super::*; - -#[test] -fn endpoint_leases_reset_recovery_only_after_the_last_client_closes() { - let runtime = tempfile::tempdir().unwrap(); - let url = "http://127.0.0.1:47632"; - let first = EndpointLease::acquire(runtime.path(), url).unwrap(); - let cohort = first.cohort_id().to_string(); - assert!(first.fresh_epoch()); - let mut epoch = RecoveryEpoch::new(url, &cohort, "gateway-1"); - epoch.restarts = 1; - write_recovery_epoch(runtime.path(), &epoch).unwrap(); - - let second = EndpointLease::acquire(runtime.path(), url).unwrap(); - assert!(!second.fresh_epoch()); - assert!( - read_recovery_epoch(runtime.path(), url, &cohort) - .unwrap() - .is_some() - ); - drop(first); - - let third = EndpointLease::acquire(runtime.path(), url).unwrap(); - assert!(!third.fresh_epoch()); - drop(second); - drop(third); - - let next_epoch = EndpointLease::acquire(runtime.path(), url).unwrap(); - assert!(next_epoch.fresh_epoch()); - assert!( - read_recovery_epoch(runtime.path(), url, next_epoch.cohort_id()) - .unwrap() - .is_none() - ); -} - -#[test] -fn endpoint_lease_release_is_serialized_with_registry_handoffs() { - let runtime = tempfile::tempdir().unwrap(); - let url = "http://127.0.0.1:47632"; - let lease = EndpointLease::acquire(runtime.path(), url).unwrap(); - let lease_path = lease.path.clone(); - let registry = lock_file_for( - &lease_registry_path(runtime.path(), url), - Duration::from_secs(1), - ) - .unwrap(); - let (dropping, started) = std::sync::mpsc::channel(); - let release = std::thread::spawn(move || { - dropping.send(()).unwrap(); - drop(lease); - }); - started.recv().unwrap(); - std::thread::sleep(Duration::from_millis(50)); - - let still_locked = OpenOptions::new() - .read(true) - .write(true) - .open(&lease_path) - .unwrap(); - assert_eq!( - try_lock_exclusive(&still_locked).unwrap(), - LockAttempt::Contended - ); - - drop(registry); - release.join().unwrap(); - assert!(!lease_path.exists()); -} - -#[test] -fn intentional_plugin_stop_starts_a_fresh_cohort_despite_old_live_leases() { - let dir = tempdir().unwrap(); - let environment = Environment::isolated(); - environment.set("XDG_CONFIG_HOME", dir.path()); - environment.set("HOME", dir.path()); - environment.remove("USERPROFILE"); - let runtime = state_dir().unwrap(); - let url = crate::sidecar::DEFAULT_URL; - let old = EndpointLease::acquire(&runtime, url).unwrap(); - let old_cohort = old.cohort_id().to_string(); - let mut exhausted = RecoveryEpoch::new(url, &old_cohort, "gateway-old"); - exhausted.restarts = 1; - write_recovery_epoch(&runtime, &exhausted).unwrap(); - - stop_owned_and_reset(url).unwrap(); - - let new = EndpointLease::acquire(&runtime, url).unwrap(); - assert!(new.fresh_epoch()); - assert_ne!(new.cohort_id(), old_cohort); - assert!( - read_recovery_epoch(&runtime, url, new.cohort_id()) - .unwrap() - .is_none() - ); -} - -#[test] -fn failed_plugin_stop_restores_the_previous_cohort_and_recovery_budget() { - let dir = tempdir().unwrap(); - let environment = Environment::isolated(); - environment.set("XDG_CONFIG_HOME", dir.path()); - environment.set("HOME", dir.path()); - environment.remove("USERPROFILE"); - let runtime = state_dir().unwrap(); - let url = "http://127.0.0.1:9"; - let old = EndpointLease::acquire(&runtime, url).unwrap(); - let old_cohort = old.cohort_id().to_string(); - let mut exhausted = RecoveryEpoch::new(url, &old_cohort, "gateway-old"); - exhausted.restarts = 1; - write_recovery_epoch(&runtime, &exhausted).unwrap(); - write_owner(&owner_path(&runtime, url), 42, url, "", Some("fingerprint")).unwrap(); - - let error = stop_owned_and_reset(url).unwrap_err(); - assert!(error.contains("has no shutdown token"), "{error}"); - - let overlapping = EndpointLease::acquire(&runtime, url).unwrap(); - assert!(!overlapping.fresh_epoch()); - assert_eq!(overlapping.cohort_id(), old_cohort); - assert_eq!( - read_recovery_epoch(&runtime, url, &old_cohort) - .unwrap() - .unwrap() - .restarts, - 1 - ); -} - -#[test] -fn failed_plugin_stop_hides_transient_retirement_from_heartbeat_validation() { - let dir = tempdir().unwrap(); - let environment = Environment::isolated(); - environment.set("XDG_CONFIG_HOME", dir.path()); - environment.set("HOME", dir.path()); - environment.remove("USERPROFILE"); - let runtime = state_dir().unwrap(); - let url = "http://127.0.0.1:9"; - let old = EndpointLease::acquire(&runtime, url).unwrap(); - let old_cohort = old.cohort_id().to_string(); - write_owner(&owner_path(&runtime, url), 42, url, "", Some("fingerprint")).unwrap(); - let (rotated_tx, rotated_rx) = std::sync::mpsc::channel(); - let (continue_tx, continue_rx) = std::sync::mpsc::channel(); - - let stop = std::thread::spawn(move || { - stop_owned_and_reset_after_rotation(url, || { - rotated_tx.send(()).unwrap(); - continue_rx.recv().unwrap(); - }) - }); - rotated_rx.recv().unwrap(); - let (validated_tx, validated_rx) = std::sync::mpsc::channel(); - let validate_cohort = old_cohort.clone(); - let validation = std::thread::spawn(move || { - validated_tx - .send(crate::sidecar::validate_gateway_cohort( - url, - &validate_cohort, - )) - .unwrap(); - }); +use std::net::TcpListener; - assert!( - validated_rx - .recv_timeout(std::time::Duration::from_millis(100)) - .is_err(), - "heartbeat validation observed the uncommitted cohort rotation" - ); - continue_tx.send(()).unwrap(); - let error = stop.join().unwrap().unwrap_err(); - assert!(error.contains("has no shutdown token"), "{error}"); - validated_rx.recv().unwrap().unwrap(); - validation.join().unwrap(); +struct EnvScope { + _guard: std::sync::MutexGuard<'static, ()>, + previous: Vec<(&'static str, Option)>, } -#[test] -fn endpoint_cohort_lock_pins_hook_delivery_across_cross_host_replacement() { - let runtime = tempfile::tempdir().unwrap(); - let url = "http://127.0.0.1:47632"; - let lease = EndpointLease::acquire(runtime.path(), url).unwrap(); - let cohort = lease.cohort_id().to_string(); - let delivery_guard = lock_endpoint_shared(runtime.path(), url).unwrap(); - let concurrent_delivery_guard = lock_endpoint_shared(runtime.path(), url).unwrap(); - validate_recovery_cohort(runtime.path(), url, &cohort).unwrap(); - - let runtime_path = runtime.path().to_path_buf(); - let (replaced_tx, replaced_rx) = std::sync::mpsc::channel(); - let replacement = std::thread::spawn(move || { - let _lock = lock_endpoint(&runtime_path, url).unwrap(); - write_recovery_cohort(&runtime_path, &RecoveryCohort::new(url)).unwrap(); - replaced_tx.send(()).unwrap(); - }); - assert!( - replaced_rx - .recv_timeout(Duration::from_millis(100)) - .is_err(), - "a different host replaced the gateway while hook delivery was pinned" - ); - - drop(delivery_guard); - assert!( - replaced_rx - .recv_timeout(Duration::from_millis(100)) - .is_err(), - "replacement ignored a second concurrent hook delivery" - ); - drop(concurrent_delivery_guard); - replaced_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - replacement.join().unwrap(); - assert!(validate_recovery_cohort(runtime.path(), url, &cohort).is_err()); -} - -#[test] -fn recovery_epoch_rejects_identity_and_restart_count_drift() { - let runtime = tempfile::tempdir().unwrap(); - let url = "http://127.0.0.1:47632"; - let path = recovery_path(runtime.path(), url); - std::fs::write( - &path, - serde_json::to_vec(&serde_json::json!({ - "service": "foreign", - "bootstrap_protocol": BOOTSTRAP_PROTOCOL_VERSION, - "url": url, - "cohort_id": "cohort-1", - "instance_id": "gateway-1", - "restarts": 0, - "pending": false, - })) - .unwrap(), - ) - .unwrap(); - assert!( - read_recovery_epoch(runtime.path(), url, "cohort-1") - .unwrap_err() - .contains("incompatible") - ); - - let mut epoch = RecoveryEpoch::new(url, "cohort-1", "gateway-1"); - epoch.restarts = 2; - write_recovery_epoch(runtime.path(), &epoch).unwrap(); - assert!( - read_recovery_epoch(runtime.path(), url, "cohort-1") - .unwrap_err() - .contains("incompatible") - ); -} - -const SHUTDOWN_TOKEN_ENV: &str = "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN"; - -struct Environment { - _lock: std::sync::MutexGuard<'static, ()>, - saved: Vec<(&'static str, Option)>, -} - -impl Environment { - fn isolated() -> Self { - let lock = crate::test_support::ENV_TEST_LOCK +impl EnvScope { + fn set(values: &[(&'static str, Option<&OsStr>)]) -> Self { + let guard = crate::test_support::ENV_TEST_LOCK .lock() - .unwrap_or_else(|error| error.into_inner()); - let keys = [ - "HOME", - "USERPROFILE", - "XDG_CONFIG_HOME", - BOOTSTRAP_STATE_DIR_ENV, - SHUTDOWN_TOKEN_ENV, - crate::config::BOOTSTRAP_FINGERPRINT_ENV, - ]; - let saved = keys - .into_iter() - .map(|key| (key, std::env::var_os(key))) + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = values + .iter() + .map(|(name, _)| (*name, std::env::var_os(name))) .collect(); - Self { _lock: lock, saved } - } - - fn set(&self, key: &'static str, value: impl AsRef) { - // SAFETY: This scope holds the process-wide environment test lock. - unsafe { std::env::set_var(key, value) }; - } - - fn remove(&self, key: &'static str) { - // SAFETY: This scope holds the process-wide environment test lock. - unsafe { std::env::remove_var(key) }; - } - - fn clear_managed_bootstrap(&self) { - self.remove(BOOTSTRAP_STATE_DIR_ENV); - self.remove(SHUTDOWN_TOKEN_ENV); - self.remove(crate::config::BOOTSTRAP_FINGERPRINT_ENV); + for (name, value) in values { + // SAFETY: The process-wide environment lock is held for this scope. + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + Self { + _guard: guard, + previous, + } } } -impl Drop for Environment { +impl Drop for EnvScope { fn drop(&mut self) { - // SAFETY: Restoration happens while the process-wide environment test lock is held. - unsafe { - for (key, value) in self.saved.drain(..).rev() { + for (name, value) in self.previous.drain(..) { + // SAFETY: The process-wide environment lock remains held during restoration. + unsafe { match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), } } } } } -fn configure_managed_bootstrap(environment: &Environment, state: &Path) { - environment.set(BOOTSTRAP_STATE_DIR_ENV, state); - environment.set(SHUTDOWN_TOKEN_ENV, "shutdown-token"); - environment.set(crate::config::BOOTSTRAP_FINGERPRINT_ENV, "fingerprint"); -} - -fn read_http_headers(stream: &mut TcpStream) -> String { - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - let mut request = Vec::new(); - let mut buffer = [0_u8; 1024]; - while !request.windows(4).any(|window| window == b"\r\n\r\n") { - let read = stream.read(&mut buffer).unwrap(); - if read == 0 { - break; - } - request.extend_from_slice(&buffer[..read]); +fn read_headers(stream: &mut std::net::TcpStream) -> String { + let mut bytes = Vec::new(); + let mut byte = [0_u8; 1]; + while !bytes.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + bytes.push(byte[0]); } - String::from_utf8(request).unwrap() + String::from_utf8(bytes).unwrap() } -fn request_header(request: &str, name: &str) -> String { +fn header(request: &str, name: &str) -> String { request .lines() .find_map(|line| { @@ -345,280 +69,171 @@ fn request_header(request: &str, name: &str) -> String { .eq_ignore_ascii_case(name) .then(|| value.trim().to_string()) }) - .unwrap_or_else(|| panic!("missing {name} in request: {request}")) -} - -#[test] -fn state_files_distinguish_absence_from_read_failure() { - let dir = tempdir().unwrap(); - let missing = dir.path().join("missing.json"); - - assert!(read_owner_record(&missing).unwrap().is_none()); - assert!(read_ready_file(&missing).unwrap().is_none()); - - let owner_error = read_owner_record(dir.path()).unwrap_err(); - assert!(owner_error.contains("failed to read sidecar ownership")); - let ready_error = read_ready_file(dir.path()).unwrap_err(); - assert!(ready_error.contains("failed to read sidecar readiness file")); + .unwrap() } #[test] -fn state_directory_requires_a_user_configuration_home() { - let environment = Environment::isolated(); - environment.remove("HOME"); - environment.remove("USERPROFILE"); - environment.remove("XDG_CONFIG_HOME"); - - let error = state_dir().unwrap_err(); - - assert!(error.contains("cannot determine"), "{error}"); -} - -#[test] -fn managed_owner_environment_is_validated_before_writing_state() { - let dir = tempdir().unwrap(); - let environment = Environment::isolated(); - environment.clear_managed_bootstrap(); - - environment.set(BOOTSTRAP_STATE_DIR_ENV, "relative-state"); - environment.set(SHUTDOWN_TOKEN_ENV, "shutdown-token"); - let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); - assert!(error.contains("must be an absolute path"), "{error}"); - - environment.set(BOOTSTRAP_STATE_DIR_ENV, dir.path()); - environment.set(SHUTDOWN_TOKEN_ENV, ""); - let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); - assert!(error.contains(SHUTDOWN_TOKEN_ENV), "{error}"); - - environment.set(SHUTDOWN_TOKEN_ENV, "shutdown-token"); - let error = publish_owner_from_env("0.0.0.0:47632".parse().unwrap()).unwrap_err(); - assert!(error.contains("requires a loopback address"), "{error}"); - - let state_file = dir.path().join("state-file"); - std::fs::write(&state_file, "not a directory").unwrap(); - environment.set(BOOTSTRAP_STATE_DIR_ENV, &state_file); - let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); - assert!( - error.contains("failed to create bootstrap state directory"), - "{error}" - ); -} - -#[test] -fn managed_owner_is_endpoint_scoped_without_a_host_identity() { - let dir = tempdir().unwrap(); - let state = dir.path().join("state"); - let environment = Environment::isolated(); - configure_managed_bootstrap(&environment, &state); - let address = "127.0.0.1:47633".parse().unwrap(); +fn owner_records_are_versioned_endpoint_scoped_and_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + let path = owner_path(dir.path(), url); + let record = OwnerRecord::new(42, url, "shutdown", Some("fingerprint")); - publish_owner_from_env(address).unwrap(); + write_owner_record(&path, &record).unwrap(); - let url = format!("http://{address}"); - validate_owner( - &owner_path(&state, &url), - &pid_path(&state, &url), - std::process::id(), - &url, - "shutdown-token", - Some("fingerprint"), - ) - .unwrap(); + assert_eq!(read_owner_record(&path).unwrap(), Some(record.clone())); + assert!(record.valid_for(url)); + assert!(!record.valid_for("http://127.0.0.1:47633")); + assert!(owner_path(dir.path(), url).ends_with("sidecar-127.0.0.1-47632.owner.json")); + assert_eq!(lock_name("not a url/with spaces"), "not_a_url_with_spaces"); } #[test] -fn publishing_endpoint_owner_migrates_legacy_agent_scoped_records() { - let dir = tempdir().unwrap(); - let state = dir.path().join("state"); - std::fs::create_dir_all(&state).unwrap(); - let environment = Environment::isolated(); - configure_managed_bootstrap(&environment, &state); - let address = "127.0.0.1:47636".parse().unwrap(); - let url = format!("http://{address}"); - let legacy_owner = state.join(format!("codex-sidecar-{}.owner.json", lock_name(&url))); - let legacy_pid = state.join(format!("codex-sidecar-{}.pid", lock_name(&url))); - write_owner( - &legacy_owner, - 41, - &url, - "old-token", - Some("old-fingerprint"), - ) - .unwrap(); - std::fs::write(&legacy_pid, "41").unwrap(); +fn startup_lock_serializes_competing_mcp_processes() { + let dir = tempfile::tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + let owner = lock_endpoint(dir.path(), url).unwrap(); - publish_owner_from_env(address).unwrap(); + let error = lock_endpoint_for(dir.path(), url, Duration::from_millis(25)).unwrap_err(); + assert!(error.contains("timed out waiting"), "{error}"); - assert!(!legacy_owner.exists()); - assert!(!legacy_pid.exists()); - assert!(owner_path(&state, &url).exists()); - assert!(pid_path(&state, &url).exists()); + drop(owner); + lock_endpoint_for(dir.path(), url, Duration::from_millis(25)).unwrap(); } #[test] -fn failed_owner_publish_removes_the_partial_pid_record() { - let dir = tempdir().unwrap(); - let state = dir.path().join("state"); - std::fs::create_dir_all(&state).unwrap(); - let environment = Environment::isolated(); - configure_managed_bootstrap(&environment, &state); +fn managed_owner_environment_is_validated_before_writing() { + let dir = tempfile::tempdir().unwrap(); + let relative = OsStr::new("relative"); + let absolute = dir.path().as_os_str(); let address = "127.0.0.1:47632".parse().unwrap(); - let url = format!("http://{address}"); - let owner = owner_path(&state, &url); - let pid = pid_path(&state, &url); - std::fs::create_dir_all(&owner).unwrap(); - #[cfg(windows)] - { - let file_name = owner.file_name().unwrap().to_string_lossy(); - let stale_backup = owner.with_file_name(format!(".{file_name}.nemo-relay-replace.tmp")); - std::fs::create_dir_all(stale_backup).unwrap(); - } + let _scope = EnvScope::set(&[ + (BOOTSTRAP_STATE_DIR_ENV, Some(relative)), + ( + "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", + Some(OsStr::new("token")), + ), + ]); let error = publish_owner_from_env(address).unwrap_err(); + assert!(error.contains("absolute path"), "{error}"); + drop(_scope); - assert!(error.contains("failed to"), "{error}"); - assert!(!pid.exists()); + let _scope = EnvScope::set(&[ + (BOOTSTRAP_STATE_DIR_ENV, Some(absolute)), + ("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", None), + ]); + let error = publish_owner_from_env(address).unwrap_err(); + assert!(error.contains("SHUTDOWN_TOKEN"), "{error}"); + drop(_scope); + + let _scope = EnvScope::set(&[ + (BOOTSTRAP_STATE_DIR_ENV, Some(absolute)), + ( + "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", + Some(OsStr::new("token")), + ), + ]); + let error = publish_owner_from_env("0.0.0.0:47632".parse().unwrap()).unwrap_err(); + assert!(error.contains("loopback"), "{error}"); } #[test] -fn owner_validation_reports_a_missing_record() { - let dir = tempdir().unwrap(); - let owner = dir.path().join("missing-owner.json"); - let pid = dir.path().join("missing-owner.pid"); - - let error = validate_owner( - &owner, - &pid, - 42, +fn server_owner_guard_cleans_only_its_own_record() { + let dir = tempfile::tempdir().unwrap(); + let address = "127.0.0.1:47632".parse().unwrap(); + let _scope = EnvScope::set(&[ + (BOOTSTRAP_STATE_DIR_ENV, Some(dir.path().as_os_str())), + ( + "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", + Some(OsStr::new("first-token")), + ), + ( + crate::config::BOOTSTRAP_FINGERPRINT_ENV, + Some(OsStr::new("fingerprint")), + ), + ]); + let guard = publish_owner_from_env(address).unwrap().unwrap(); + let path = owner_path(dir.path(), "http://127.0.0.1:47632"); + assert!(path.exists()); + + let replacement = OwnerRecord::new( + std::process::id(), "http://127.0.0.1:47632", - "shutdown-token", + "replacement-token", Some("fingerprint"), - ) - .unwrap_err(); - - assert!(error.contains("file does not exist"), "{error}"); -} - -#[test] -fn owner_enumeration_distinguishes_missing_and_unreadable_directories() { - let dir = tempdir().unwrap(); - assert!(owner_paths(&dir.path().join("missing")).unwrap().is_empty()); - - let file = dir.path().join("not-a-directory"); - std::fs::write(&file, "file").unwrap(); - let error = owner_paths(&file).unwrap_err(); - assert!(error.contains("failed to enumerate"), "{error}"); -} - -#[test] -fn stale_owner_cleanup_requires_shutdown_credentials() { - let dir = tempdir().unwrap(); - let url = "http://127.0.0.1:9"; - let owner = owner_path(dir.path(), url); - - assert!( - stop_owned_record(dir.path(), &owner).is_ok(), - "an already absent owner is clean" ); + write_owner_record(&path, &replacement).unwrap(); + drop(guard); - write_owner(&owner, 42, url, "", Some("fingerprint")).unwrap(); - let error = stop_owned_record(dir.path(), &owner).unwrap_err(); - assert!(error.contains("has no shutdown token"), "{error}"); - - write_owner(&owner, 42, url, "shutdown-token", None).unwrap(); - let error = stop_owned_record(dir.path(), &owner).unwrap_err(); - assert!( - error.contains("no authenticated bootstrap fingerprint"), - "{error}" - ); + assert_eq!(read_owner_record(&path).unwrap(), Some(replacement)); } #[test] -fn unavailable_owned_sidecar_is_removed_without_sending_shutdown() { - let dir = tempdir().unwrap(); +fn stopping_an_absent_or_stale_owned_gateway_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config"); + let _scope = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(config.as_os_str())), + ("HOME", Some(dir.path().as_os_str())), + ("USERPROFILE", None), + ]); let url = "http://127.0.0.1:9"; - let owner = owner_path(dir.path(), url); - let pid = pid_path(dir.path(), url); - write_owner(&owner, 42, url, "shutdown-token", Some("fingerprint")).unwrap(); - std::fs::write(&pid, "42").unwrap(); - - stop_owned_record(dir.path(), &owner).unwrap(); - assert!(!owner.exists()); - assert!(!pid.exists()); -} - -#[test] -fn unavailable_legacy_owner_removes_the_legacy_pid_record() { - let dir = tempdir().unwrap(); - let url = "http://127.0.0.1:9"; - let owner = dir.path().join("codex-sidecar.owner.json"); - let pid = dir.path().join("codex-sidecar.pid"); - write_owner(&owner, 42, url, "shutdown-token", Some("fingerprint")).unwrap(); - std::fs::write(&pid, "42").unwrap(); - - stop_owned_record(dir.path(), &owner).unwrap(); + stop_owned_and_reset(url).unwrap(); + let state = state_dir().unwrap(); + create_private_dir(&state).unwrap(); + let path = owner_path(&state, url); + let owner = OwnerRecord::new(42, url, "shutdown", Some("fingerprint")); + write_owner_record(&path, &owner).unwrap(); - assert!(!owner.exists()); - assert!(!pid.exists()); + stop_owned_and_reset(url).unwrap(); + assert!(!path.exists()); } #[test] -fn authenticated_owned_sidecar_is_shut_down_and_cleaned_up() { - let dir = tempdir().unwrap(); - let environment = Environment::isolated(); - environment.set("XDG_CONFIG_HOME", dir.path().join("config")); - environment.set("HOME", dir.path()); - environment.remove("USERPROFILE"); +fn authenticated_owned_gateway_is_shut_down_and_cleaned_up() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config"); + let _scope = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(config.as_os_str())), + ("HOME", Some(dir.path().as_os_str())), + ("USERPROFILE", None), + ]); let key = crate::config::BootstrapChallengeKey::load().unwrap(); - let reloaded_key = crate::config::BootstrapChallengeKey::load().unwrap(); - assert!(reloaded_key.verify( - "fingerprint", - "test-nonce", - &key.proof("fingerprint", "test-nonce") - )); - let client_token = key.client_token(); - assert!(reloaded_key.verify_client_token(&client_token)); - assert!(!reloaded_key.verify_client_token("hmac-sha256:00")); - assert_ne!( - client_token, - key.proof("fingerprint", "test-nonce"), - "health and client proofs must remain domain-separated" - ); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); - let owner = owner_path(dir.path(), &url); - let pid = pid_path(dir.path(), &url); - write_owner(&owner, 42, &url, "shutdown-token", Some("fingerprint")).unwrap(); - std::fs::write(&pid, "42").unwrap(); - let server = thread::spawn(move || { - for _ in 0..2 { - let (mut health, _) = listener.accept().unwrap(); - let request = read_http_headers(&mut health); - let fingerprint = request_header(&request, "x-nemo-relay-bootstrap-fingerprint"); - let nonce = request_header(&request, "x-nemo-relay-bootstrap-nonce"); - let proof = key.proof(&fingerprint, &nonce); - let body = format!( - r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{},"instance_id":"test-instance"}}"#, - env!("CARGO_PKG_VERSION"), - BOOTSTRAP_PROTOCOL_VERSION - ); - health - .write_all( - format!( - "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ) - .as_bytes(), + let state = state_dir().unwrap(); + create_private_dir(&state).unwrap(); + let path = owner_path(&state, &url); + let owner = OwnerRecord::new(42, &url, "shutdown-token", Some("fingerprint")); + write_owner_record(&path, &owner).unwrap(); + + let server = std::thread::spawn(move || { + let (mut health, _) = listener.accept().unwrap(); + let request = read_headers(&mut health); + let nonce = header(&request, "x-nemo-relay-bootstrap-nonce"); + let proof = key.proof("fingerprint", &nonce); + let body = format!( + "{{\"status\":\"ok\",\"service\":\"nemo-relay\",\"version\":\"{}\",\"bootstrap_protocol\":{},\"instance_id\":\"test-instance\"}}", + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); + health + .write_all( + format!( + "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() ) - .unwrap(); - } + .as_bytes(), + ) + .unwrap(); let (mut shutdown, _) = listener.accept().unwrap(); - let request = read_http_headers(&mut shutdown); + let request = read_headers(&mut shutdown); assert!(request.starts_with("POST /bootstrap/shutdown HTTP/1.1")); assert_eq!( - request_header(&request, "x-nemo-relay-bootstrap-token"), + header(&request, "x-nemo-relay-bootstrap-token"), "shutdown-token" ); shutdown @@ -626,57 +241,7 @@ fn authenticated_owned_sidecar_is_shut_down_and_cleaned_up() { .unwrap(); }); - assert_eq!(probe(&url, Some("fingerprint")), RelayHealth::Compatible); - stop_owned_record(dir.path(), &owner).unwrap(); - + stop_owned_and_reset(&url).unwrap(); server.join().unwrap(); - assert!(!owner.exists()); - assert!(!pid.exists()); -} - -#[test] -fn stop_owned_aggregates_record_errors() { - let dir = tempdir().unwrap(); - let environment = Environment::isolated(); - environment.set("XDG_CONFIG_HOME", dir.path()); - environment.set("HOME", dir.path()); - environment.remove("USERPROFILE"); - let runtime = state_dir().unwrap(); - std::fs::create_dir_all(&runtime).unwrap(); - let url = "http://127.0.0.1:9"; - let owner = owner_path(&runtime, url); - write_owner(&owner, 42, url, "", Some("fingerprint")).unwrap(); - - let error = stop_owned(url).unwrap_err(); - - assert!(error.contains("has no shutdown token"), "{error}"); -} - -#[test] -fn stop_owned_succeeds_when_no_records_exist() { - let dir = tempdir().unwrap(); - let environment = Environment::isolated(); - environment.set("XDG_CONFIG_HOME", dir.path()); - environment.set("HOME", dir.path()); - environment.remove("USERPROFILE"); - - stop_owned(crate::sidecar::DEFAULT_URL).unwrap(); -} - -#[test] -fn stop_owned_leaves_other_managed_endpoints_untouched() { - let dir = tempdir().unwrap(); - let environment = Environment::isolated(); - environment.set("XDG_CONFIG_HOME", dir.path()); - environment.set("HOME", dir.path()); - environment.remove("USERPROFILE"); - let runtime = state_dir().unwrap(); - std::fs::create_dir_all(&runtime).unwrap(); - let other_url = "http://127.0.0.1:47633"; - let other_owner = owner_path(&runtime, other_url); - write_owner(&other_owner, 42, other_url, "", Some("fingerprint")).unwrap(); - - stop_owned(crate::sidecar::DEFAULT_URL).unwrap(); - - assert!(other_owner.exists()); + assert!(!path.exists()); } diff --git a/crates/cli/tests/coverage/sidecar_tests.rs b/crates/cli/tests/coverage/sidecar_tests.rs index 2e6197ce7..c20b88ef0 100644 --- a/crates/cli/tests/coverage/sidecar_tests.rs +++ b/crates/cli/tests/coverage/sidecar_tests.rs @@ -1,487 +1,260 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::ffi::OsString; +use super::*; +use std::ffi::{OsStr, OsString}; use std::io::{Read, Write}; use std::net::TcpListener; -use std::process::Command; -use std::thread; - -use super::*; -use serde_json::json; -struct EnvVarRestore { - key: &'static str, - previous: Option, +struct EnvScope { + _guard: std::sync::MutexGuard<'static, ()>, + previous: Vec<(&'static str, Option)>, } -impl EnvVarRestore { - fn set(key: &'static str, value: &str) -> Self { - let previous = std::env::var_os(key); - // SAFETY: Callers hold the process-wide environment test lock. - unsafe { std::env::set_var(key, value) }; - Self { key, previous } +impl EnvScope { + fn set(values: &[(&'static str, Option<&OsStr>)]) -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = values + .iter() + .map(|(name, _)| (*name, std::env::var_os(name))) + .collect(); + for (name, value) in values { + // SAFETY: The process-wide environment lock is held. + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + Self { + _guard: guard, + previous, + } } } -impl Drop for EnvVarRestore { +impl Drop for EnvScope { fn drop(&mut self) { - // SAFETY: Callers retain the process-wide environment test lock until after this guard. - unsafe { - match self.previous.take() { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), + for (name, value) in self.previous.drain(..) { + // SAFETY: The process-wide environment lock remains held during restoration. + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } } } } } -#[test] -fn gateway_spec_is_the_complete_compatibility_contract() { - let bind = "127.0.0.1:47632".parse().unwrap(); - let first = GatewaySpec::new(bind) - .with_launch_args(vec![ - OsString::from("--openai-base-url"), - OsString::from("mock"), - ]) - .with_fingerprint("fingerprint-a"); - let same = GatewaySpec::new(bind) - .with_launch_args(vec![ - OsString::from("--openai-base-url"), - OsString::from("mock"), - ]) - .with_fingerprint("fingerprint-a"); - let different = GatewaySpec::new(bind) - .with_launch_args(vec![ - OsString::from("--openai-base-url"), - OsString::from("other"), - ]) - .with_fingerprint("fingerprint-a"); - - assert_eq!(first, same); - assert_ne!(first, different); - assert_eq!(first.bind(), bind); +fn read_headers(stream: &mut std::net::TcpStream) -> String { + let mut bytes = Vec::new(); + let mut byte = [0_u8; 1]; + while !bytes.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + bytes.push(byte[0]); + } + String::from_utf8(bytes).unwrap() } -#[test] -fn endpoint_recovery_epoch_rejects_a_staggered_second_replacement() { - let state = tempfile::tempdir().unwrap(); - let url = "http://127.0.0.1:47632"; - let cohort = "cohort-1"; - write_recovery_epoch(state.path(), &RecoveryEpoch::new(url, cohort, "gateway-1")).unwrap(); - - reconcile_gateway_epoch(state.path(), url, cohort, false, "gateway-2").unwrap(); - let recovered = read_recovery_epoch(state.path(), url, cohort) - .unwrap() - .unwrap(); - assert_eq!(recovered.instance_id, "gateway-2"); - assert_eq!(recovered.restarts, 1); - - let error = reconcile_gateway_epoch(state.path(), url, cohort, false, "gateway-3").unwrap_err(); - assert!(error.contains("replaced again"), "{error}"); - let unchanged = read_recovery_epoch(state.path(), url, cohort) +fn header(request: &str, name: &str) -> String { + request + .lines() + .find_map(|line| { + let (candidate, value) = line.split_once(':')?; + candidate + .eq_ignore_ascii_case(name) + .then(|| value.trim().to_string()) + }) .unwrap() - .unwrap(); - assert_eq!(unchanged.instance_id, "gateway-2"); - assert_eq!(unchanged.restarts, 1); } #[test] -fn gateway_spec_rejects_non_loopback_bind_before_launch() { - let error = GatewaySpec::new("0.0.0.0:47632".parse().unwrap()) +fn persistent_gateway_requires_a_loopback_endpoint() { + let non_loopback = GatewaySpec::new("0.0.0.0:47632".parse().unwrap()) .acquire() - .err() - .expect("non-loopback gateway unexpectedly acquired"); - - assert!(error.contains("require a loopback bind address"), "{error}"); + .unwrap_err(); + assert!(non_loopback.contains("loopback"), "{non_loopback}"); } #[test] -fn fixed_acquisition_revalidates_a_captured_cohort_before_launch() { - let _environment = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); - let state = tempfile::tempdir().unwrap(); - let runtime = state.path().join("runtime"); - let _state = EnvVarRestore::set(BOOTSTRAP_STATE_DIR_ENV, state.path().to_str().unwrap()); - let _runtime = EnvVarRestore::set("XDG_RUNTIME_DIR", runtime.to_str().unwrap()); - let missing_binary = state.path().join("must-not-be-launched"); - let _binary = EnvVarRestore::set("NEMO_RELAY_PLUGIN_BINARY", missing_binary.to_str().unwrap()); - let probe = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = probe.local_addr().unwrap(); - drop(probe); - let url = format!("http://{address}"); - let stale = EndpointLease::acquire(state.path(), &url).unwrap(); - - stop_owned_sidecar_and_reset(&url).unwrap(); - let error = GatewaySpec::new(address) - .acquire_with_lease(stale) - .err() - .expect("a retired cohort unexpectedly acquired the endpoint"); - - assert!( - error.contains("retired by an integration update"), - "{error}" - ); - assert!(!error.contains("NEMO_RELAY_PLUGIN_BINARY"), "{error}"); - TcpListener::bind(address).expect("retired acquisition unexpectedly started a listener"); -} - -#[test] -fn typed_owner_record_round_trips_and_rejects_identity_drift() { - let dir = tempfile::tempdir().unwrap(); - let owner = dir.path().join("owner.json"); - let pid = dir.path().join("owner.pid"); - std::fs::write(&pid, "42").unwrap(); - write_sidecar_owner( - &owner, - 42, - "http://127.0.0.1:47632", - "shutdown-token", - Some("fingerprint"), - ) - .unwrap(); - - validate_sidecar_owner( - &owner, - &pid, - 42, - "http://127.0.0.1:47632", - "shutdown-token", - Some("fingerprint"), - ) - .unwrap(); - let error = validate_sidecar_owner( - &owner, - &pid, - 42, - "http://127.0.0.1:47632", - "different-token", - Some("fingerprint"), - ) - .unwrap_err(); - assert!(error.contains("does not match the ready process")); -} - -#[test] -fn typed_owner_record_rejects_missing_required_fields() { - let dir = tempfile::tempdir().unwrap(); - let owner = dir.path().join("owner.json"); - let pid = dir.path().join("owner.pid"); - std::fs::write(&owner, serde_json::to_vec(&json!({"pid": 42})).unwrap()).unwrap(); - std::fs::write(&pid, "42").unwrap(); - - let error = validate_sidecar_owner( - &owner, - &pid, - 42, - "http://127.0.0.1:47632", - "shutdown-token", - Some("fingerprint"), - ) - .unwrap_err(); - - assert!(error.contains("invalid sidecar ownership file"), "{error}"); -} - -#[test] -fn readiness_file_requires_exact_protocol_identity() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("ready.json"); - std::fs::write( - &path, - serde_json::to_vec(&json!({ - "service": "nemo-relay", - "version": env!("CARGO_PKG_VERSION"), - "bootstrap_protocol": BOOTSTRAP_PROTOCOL_VERSION, - "address": "127.0.0.1:47777", - "instance_id": "test-instance" - })) - .unwrap(), - ) - .unwrap(); - - let endpoint = read_sidecar_ready_file(&path).unwrap().unwrap(); - assert_eq!(endpoint.address, "127.0.0.1:47777".parse().unwrap()); - - std::fs::write( - &path, - serde_json::to_vec(&json!({ - "service": "nemo-relay", - "version": env!("CARGO_PKG_VERSION"), - "bootstrap_protocol": BOOTSTRAP_PROTOCOL_VERSION + 1, - "address": "127.0.0.1:47777", - "instance_id": "test-instance" - })) - .unwrap(), - ) - .unwrap(); - let error = read_sidecar_ready_file(&path).unwrap_err(); - assert!(error.contains("incompatible sidecar readiness file")); -} - -fn one_health_response(body: String) -> (SocketAddr, thread::JoinHandle<()>) { +fn compatible_gateway_is_reused_without_starting_another_process() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("config"); + let _environment = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(config.as_os_str())), + ("HOME", Some(temp.path().as_os_str())), + ("USERPROFILE", None), + ]); + let key = crate::config::BootstrapChallengeKey::load().unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); - let server = thread::spawn(move || { + let server = std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); - let mut request = [0_u8; 2048]; - let _ = stream.read(&mut request).unwrap(); + let request = read_headers(&mut stream); + let nonce = header(&request, "x-nemo-relay-bootstrap-nonce"); + let proof = key.proof("fingerprint", &nonce); + let body = format!( + "{{\"status\":\"ok\",\"service\":\"nemo-relay\",\"version\":\"{}\",\"bootstrap_protocol\":{},\"instance_id\":\"existing-instance\"}}", + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); stream .write_all( format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ) .as_bytes(), ) .unwrap(); }); - (address, server) -} - -fn unused_loopback_address() -> SocketAddr { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap(); - drop(listener); - address -} - -#[test] -fn direct_sidecar_start_classifies_existing_listeners_before_spawning() { - let dir = tempfile::tempdir().unwrap(); - let compatible_body = format!( - r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{},"instance_id":"test-instance"}}"#, - env!("CARGO_PKG_VERSION"), - BOOTSTRAP_PROTOCOL_VERSION - ); - let (address, server) = one_health_response(compatible_body); - let endpoint = - start_sidecar_bind(&GatewaySpec::new(address), dir.path(), dir.path(), None).unwrap(); - assert_eq!(endpoint.address, address); - server.join().unwrap(); - let incompatible_body = format!( - r#"{{"status":"ok","service":"nemo-relay","version":"other","bootstrap_protocol":{}}}"#, - BOOTSTRAP_PROTOCOL_VERSION - ); - let (address, server) = one_health_response(incompatible_body); - let error = - start_sidecar_bind(&GatewaySpec::new(address), dir.path(), dir.path(), None).unwrap_err(); - assert!(error.contains("different version"), "{error}"); - server.join().unwrap(); + let endpoint = GatewaySpec::new(address) + .with_fingerprint("fingerprint") + .acquire() + .unwrap(); - let (address, server) = one_health_response("{}".into()); - let error = - start_sidecar_bind(&GatewaySpec::new(address), dir.path(), dir.path(), None).unwrap_err(); - assert!(error.contains("not a compatible NeMo Relay"), "{error}"); server.join().unwrap(); + assert_eq!(endpoint.address, address); + assert_eq!(endpoint.instance_id, "existing-instance"); } #[test] -fn sidecar_record_cleanup_is_scoped_to_the_exited_pid() { - let dir = tempfile::tempdir().unwrap(); - let invalid = sidecar_owner_path(dir.path(), "http://127.0.0.1:47630"); - std::fs::write(&invalid, "not-json").unwrap(); - let matching = sidecar_owner_path(dir.path(), "http://127.0.0.1:47631"); - write_sidecar_owner( - &matching, - 42, - "http://127.0.0.1:47631", - "token", - Some("fingerprint"), - ) - .unwrap(); - let matching_pid = sidecar_pid_path(dir.path(), "http://127.0.0.1:47631"); - std::fs::write(&matching_pid, "42").unwrap(); - let other = sidecar_owner_path(dir.path(), "http://127.0.0.1:47632"); - write_sidecar_owner( - &other, - 43, - "http://127.0.0.1:47632", - "token", - Some("fingerprint"), - ) - .unwrap(); - - cleanup_sidecar_records_for_pid(dir.path(), 42); - - assert!(!matching.exists()); - assert!(!matching_pid.exists()); - assert!(invalid.exists()); - assert!(other.exists()); - - let not_a_directory = dir.path().join("runtime-file"); - std::fs::write(¬_a_directory, "file").unwrap(); - cleanup_sidecar_records_for_pid(¬_a_directory, 42); -} +fn foreign_and_incompatible_listeners_are_never_adopted() { + for (body, expected) in [ + ("{}", "not a compatible"), + ( + "{\"status\":\"ok\",\"service\":\"nemo-relay\",\"version\":\"other\",\"bootstrap_protocol\":2,\"instance_id\":\"other\"}", + "different version", + ), + ] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let body = body.to_string(); + let connections = if expected == "not a compatible" { 2 } else { 1 }; + let server = std::thread::spawn(move || { + for _ in 0..connections { + let (mut stream, _) = listener.accept().unwrap(); + let _ = read_headers(&mut stream); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + } + }); -fn exited_command() -> Command { - #[cfg(windows)] - { - let mut command = Command::new("cmd"); - command.args(["/C", "exit 7"]); - command - } - #[cfg(not(windows))] - { - let mut command = Command::new("sh"); - command.args(["-c", "exit 7"]); - command + let error = GatewaySpec::new(address).acquire().unwrap_err(); + server.join().unwrap(); + assert!(error.contains(expected), "{error}"); } } #[test] -fn an_already_exited_unready_sidecar_reports_its_status() { - let dir = tempfile::tempdir().unwrap(); - let pid_path = dir.path().join("startup.pid"); - let mut child = exited_command().spawn().unwrap(); - let pid = child.id(); - assert!(!child.wait().unwrap().success()); - std::fs::write(&pid_path, pid.to_string()).unwrap(); - - let error = terminate_unready_sidecar(child, &pid_path, DEFAULT_URL).unwrap_err(); +fn readiness_file_requires_the_existing_server_identity() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("ready.json"); + assert!(read_ready_file(&path).unwrap().is_none()); - assert!(error.contains("exited before becoming ready"), "{error}"); - assert!(!pid_path.exists()); -} - -#[test] -fn reaper_cleanup_treats_an_unopenable_lock_as_terminal() { - let dir = tempfile::tempdir().unwrap(); - let mut child = exited_command().spawn().unwrap(); - assert!(!child.wait().unwrap().success()); - let request = SidecarReapRequest { - process: DetachedSidecarProcess::new( - child, - #[cfg(windows)] - None, + std::fs::write( + &path, + format!( + "{{\"service\":\"nemo-relay\",\"version\":\"{}\",\"bootstrap_protocol\":{},\"address\":\"127.0.0.1:47632\",\"instance_id\":\"ready\"}}", + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION ), - exited: true, - owner_path: dir.path().join("owner.json"), - pid_path: dir.path().join("owner.pid"), - lock_path: dir.path().join("missing-parent").join("owner.lock"), - }; + ) + .unwrap(); + let endpoint = read_ready_file(&path).unwrap().unwrap(); + assert_eq!(endpoint.url, DEFAULT_URL); + assert_eq!(endpoint.instance_id, "ready"); - assert!(cleanup_reaped_sidecar(&request)); + std::fs::write(&path, "{}").unwrap(); + let error = read_ready_file(&path).unwrap_err(); + assert!(error.contains("failed to parse"), "{error}"); } #[test] -fn zero_idle_timeout_is_rejected_by_timeout_and_heartbeat_resolution() { - let _lock = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); - let key = crate::config::PLUGIN_IDLE_TIMEOUT_ENV; - let _environment = EnvVarRestore::set(key, "0"); - - let timeout_error = plugin_idle_timeout().unwrap_err(); - let heartbeat_error = plugin_heartbeat_interval().unwrap_err(); - - assert!(timeout_error.contains("must be greater than 0")); - assert!(heartbeat_error.contains("must be greater than 0")); +fn persistent_gateway_resolution_keeps_server_configuration_in_one_spec() { + let temp = tempfile::tempdir().unwrap(); + let _environment = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(temp.path().as_os_str())), + ("HOME", Some(temp.path().as_os_str())), + ("USERPROFILE", None), + ]); + let bind = DEFAULT_BIND.parse().unwrap(); + let resolved = resolve_plugin_gateway(&ServerArgs::default(), bind).unwrap(); + + assert_eq!(resolved.gateway.bind(), bind); + assert_eq!( + resolved.max_hook_payload_bytes, + crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES + ); + assert!(resolved.gateway.bootstrap_fingerprint.is_some()); + assert!(resolved.gateway.user_config_scope); + assert!( + resolved + .gateway + .launch_args + .iter() + .any(|arg| arg == "--max-hook-payload-bytes") + ); } #[test] -fn gateway_start_reports_an_uncreatable_runtime_directory() { - let dir = tempfile::tempdir().unwrap(); - let runtime_base = dir.path().join("runtime"); - let runtime = runtime_base.join("nemo-relay-plugin"); - std::fs::create_dir_all(&runtime_base).unwrap(); - std::fs::write(&runtime, "file").unwrap(); - let _lock = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); - let _runtime = EnvVarRestore::set("XDG_RUNTIME_DIR", runtime_base.to_str().unwrap()); - let _config = EnvVarRestore::set("XDG_CONFIG_HOME", dir.path().to_str().unwrap()); - let bind = unused_loopback_address(); - - let error = GatewaySpec::new(bind) - .acquire() - .err() - .expect("gateway unexpectedly acquired with an invalid runtime directory"); - - assert!(error.contains("failed to create"), "{error}"); - assert!(error.contains(&runtime.display().to_string()), "{error}"); - assert!(error.contains("inspect"), "{error}"); +fn idle_timeout_drives_heartbeat_and_rejects_invalid_values() { + let _environment = EnvScope::set(&[( + crate::config::PLUGIN_IDLE_TIMEOUT_ENV, + Some(OsStr::new("9")), + )]); + assert_eq!(plugin_idle_timeout().unwrap(), Duration::from_secs(9)); + assert_eq!(plugin_heartbeat_interval().unwrap(), Duration::from_secs(3)); + drop(_environment); + + let _environment = EnvScope::set(&[( + crate::config::PLUGIN_IDLE_TIMEOUT_ENV, + Some(OsStr::new("0")), + )]); + assert!( + plugin_idle_timeout() + .unwrap_err() + .contains("greater than 0") + ); } #[test] -fn gateway_start_reports_an_uncreatable_state_directory() { - let dir = tempfile::tempdir().unwrap(); - let runtime_base = dir.path().join("runtime"); - let config_base = dir.path().join("config"); - let _lock = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); - let _runtime = EnvVarRestore::set("XDG_RUNTIME_DIR", runtime_base.to_str().unwrap()); - let _config = EnvVarRestore::set("XDG_CONFIG_HOME", config_base.to_str().unwrap()); - let state = sidecar_state_dir().unwrap(); - std::fs::create_dir_all(state.parent().unwrap()).unwrap(); - std::fs::write(&state, "file").unwrap(); - let bind = unused_loopback_address(); - - let error = GatewaySpec::new(bind) - .acquire() - .err() - .expect("gateway unexpectedly acquired with an invalid state directory"); - - assert!(error.contains("failed to create"), "{error}"); - assert!(error.contains(&state.display().to_string()), "{error}"); +fn binary_override_is_explicit_and_validated() { + let temp = tempfile::tempdir().unwrap(); + let binary = temp.path().join("nemo-relay"); + std::fs::write(&binary, "").unwrap(); + let _environment = EnvScope::set(&[("NEMO_RELAY_PLUGIN_BINARY", Some(binary.as_os_str()))]); + assert_eq!(relay_binary().unwrap(), binary); + drop(_environment); + + let missing = temp.path().join("missing"); + let _environment = EnvScope::set(&[("NEMO_RELAY_PLUGIN_BINARY", Some(missing.as_os_str()))]); + assert!(relay_binary().unwrap_err().contains("does not exist")); } #[test] -fn gateway_start_reports_an_unopenable_endpoint_lock() { - let dir = tempfile::tempdir().unwrap(); - let runtime_base = dir.path().join("runtime"); - let config_base = dir.path().join("config"); - let _lock = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); - let _runtime = EnvVarRestore::set("XDG_RUNTIME_DIR", runtime_base.to_str().unwrap()); - let _config = EnvVarRestore::set("XDG_CONFIG_HOME", config_base.to_str().unwrap()); - let state = sidecar_state_dir().unwrap(); - let bind = unused_loopback_address(); - let url = format!("http://{bind}"); - let endpoint_lock = sidecar_lock_path(&state, &url); - std::fs::create_dir_all(&endpoint_lock).unwrap(); - - let error = GatewaySpec::new(bind) - .acquire() - .err() - .expect("gateway unexpectedly acquired with an invalid endpoint lock"); - - assert!(error.contains("failed to open sidecar lock"), "{error}"); - assert!( - error.contains(&endpoint_lock.display().to_string()), - "{error}" +fn windows_detachment_requests_only_supported_breakaway_flags() { + let base = WINDOWS_CREATE_NEW_PROCESS_GROUP | WINDOWS_CREATE_NO_WINDOW; + assert_eq!(windows_sidecar_creation_flags(false, None), (base, false)); + assert_eq!( + windows_sidecar_creation_flags(true, Some(WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK)), + (base | WINDOWS_CREATE_BREAKAWAY_FROM_JOB, false) ); -} - -#[cfg(windows)] -#[test] -fn windows_handle_inheritance_suppression_is_scoped() { - use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Foundation::{ - GetHandleInformation, HANDLE_FLAG_INHERIT, SetHandleInformation, - }; - - let dir = tempfile::tempdir().unwrap(); - let file = std::fs::File::create(dir.path().join("captured-output.log")).unwrap(); - let handle = file.as_raw_handle().cast(); - // SAFETY: `handle` is a live file handle uniquely owned by this test. - assert_ne!( - unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) }, - 0 + assert_eq!( + windows_sidecar_creation_flags(true, Some(WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK)), + (base, false) ); - { - let _guard = super::process::HandleInheritanceGuard::suppress([handle]).unwrap(); - let mut flags = 0; - // SAFETY: `handle` remains live and `flags` is writable storage. - assert_ne!(unsafe { GetHandleInformation(handle, &mut flags) }, 0); - assert_eq!(flags & HANDLE_FLAG_INHERIT, 0); - } - let mut flags = 0; - // SAFETY: Dropping the guard restored the live handle before this query. - assert_ne!(unsafe { GetHandleInformation(handle, &mut flags) }, 0); - assert_ne!(flags & HANDLE_FLAG_INHERIT, 0); + assert_eq!(windows_sidecar_creation_flags(true, Some(0)), (base, true)); } diff --git a/docs/about-nemo-relay/release-notes/known-issues.mdx b/docs/about-nemo-relay/release-notes/known-issues.mdx index bd6f68cac..b2a139f2a 100644 --- a/docs/about-nemo-relay/release-notes/known-issues.mdx +++ b/docs/about-nemo-relay/release-notes/known-issues.mdx @@ -16,11 +16,9 @@ documentation set. the captured turn and `/responses` traffic wait for verified Relay gateway readiness. - On Windows, a restrictive host Job Object can prevent a coding-agent plugin's - Relay sidecar from breaking away. When nested job assignment is supported, - the sidecar remains scoped to the host job and the shared gateway might not - remain available for the full 300-second idle reuse window. If Relay cannot - create or configure its cleanup job, bootstrap fails instead of starting - without descendant-cleanup guarantees. + Relay gateway from breaking away. In that case the gateway remains scoped to + the host job and might not remain available for the full 300-second idle reuse + window. - The Codex 0.143 plugin hook schema does not expose `SessionEnd`. Relay writes a cumulative trajectory snapshot for every delivered `Stop`, so the final turn snapshot is also the final session snapshot. diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index b894cc412..4ce8cbf23 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -348,9 +348,9 @@ as transparent so it cannot recover the fixed gateway. Persistent Claude Code, Codex, and Hermes hooks also use `nemo-relay hook-forward `. A persistent generated command identifies -the fixed gateway and private install-generation fence explicitly, preflights -its Relay identity, and joins the shared endpoint recovery cohort before -forwarding the unchanged payload. Hermes +the fixed gateway and private install-generation fence explicitly, waits for +and verifies the MCP-owned Relay gateway, then forwards the unchanged payload +once. Hermes setup stores the canonical absolute command and trusts its exact event pairs; it does not enable global hook auto-acceptance. diff --git a/docs/nemo-relay-cli/claude-code.mdx b/docs/nemo-relay-cli/claude-code.mdx index c21a548d1..64cf10b78 100644 --- a/docs/nemo-relay-cli/claude-code.mdx +++ b/docs/nemo-relay-cli/claude-code.mdx @@ -73,10 +73,9 @@ Code, Codex, and Hermes MCP clients share a compatible gateway, and the gateway exits after the final client's idle timeout. The MCP server advertises no tools. The generated entry sets `alwaysLoad: true`, so Claude Code 2.1.121 or newer -waits for the MCP connection before session startup. The installed command -hook carries the same install-generation fence, joins the shared endpoint -recovery cohort while it delivers a payload, and retains the same-gateway retry -path for an unexpected outage after startup. MCP owns the normal session-long +waits for the MCP connection before session startup. The installed command hook +carries the same install-generation fence, waits for the MCP-owned gateway, and +forwards the canonical payload once. MCP owns the session-long gateway lifecycle. Persistent plugin mode loads system and user Relay configuration only and uses diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index cd26cc081..1043d27f2 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -114,14 +114,9 @@ without activity by default. No wrapper, launch agent, system user service, scheduled task, login item, or persistent supervisor is installed. -On Windows, Relay requests Job Object breakaway only when the host job permits -it. If the host uses a restrictive Job Object but permits nested jobs, Relay -keeps the sidecar scoped to that host job and retains a nested cleanup job for -the sidecar process tree. The sidecar cannot outlive the host job, so the usual -300-second idle reuse window can end early. If Relay cannot create or configure -the cleanup job, or the host rejects nested job assignment, persistent bootstrap -fails with an actionable process-tree cleanup error instead of starting without -that guarantee. +On Windows, Relay requests Job Object breakaway when the host job permits it. +When breakaway is unavailable, the gateway remains scoped to the host job and +the usual 300-second idle reuse window can end early. Persistent plugin mode loads system and user Relay configuration only. It does not load a project's `.nemo-relay` files. The sidecar starts in the user Relay @@ -143,10 +138,10 @@ middleware, observability, or upstream forwarding. Installer-owned hook commands pin `http://127.0.0.1:47632` and the exact -install-generation fence explicitly. Each delivery temporarily joins the same -endpoint recovery cohort as overlapping MCP clients, so a hook cannot create an -unaccounted second replacement. An ambient `NEMO_RELAY_GATEWAY_URL` cannot -split hook traffic from the required MCP-managed gateway. +install-generation fence explicitly. Hooks wait for the MCP-owned gateway and +forward each canonical payload once; they never launch or recover Relay. An +ambient `NEMO_RELAY_GATEWAY_URL` cannot split hook traffic from the required +MCP-managed gateway. If user configuration, forwarded credentials, or the Relay version changes while an old sidecar is still running, the new MCP client reports an actionable diff --git a/docs/nemo-relay-cli/hermes.mdx b/docs/nemo-relay-cli/hermes.mdx index 2406a3798..f0ba50176 100644 --- a/docs/nemo-relay-cli/hermes.mdx +++ b/docs/nemo-relay-cli/hermes.mdx @@ -85,17 +85,12 @@ configuration are verified. Hermes starts MCP discovery in the background, so an early lifecycle hook can arrive before the MCP process finishes acquiring the gateway. The installed -hook command handles that race: it starts or reuses the same gateway under the -same startup lock before delivery, guarded by the same install generation as -the MCP entry. Its temporary endpoint lease makes any replacement spend the -same recovery allowance as overlapping MCP clients. If delivery then fails -before connecting, the command retries the original payload once. The payload -is not reconstructed or replaced. +hook waits for that MCP-owned gateway, verifies it, and forwards the original +payload once. It never launches or recovers Relay. Concurrent Hermes, Claude Code, and Codex MCP clients can share one compatible -gateway. Every open MCP client heartbeats the gateway. An endpoint-scoped -recovery cohort permits one coordinated restart across all overlapping clients -and hooks, even when one client misses the replacement between heartbeat ticks. +gateway. Every open MCP client heartbeats the gateway. A startup lock and +recovery record permit one coordinated restart across overlapping MCP clients. A second failure exits those MCP clients with an error. After the last client closes, the gateway exits after 300 idle seconds by default. Set `NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS` before setup and launch to choose another diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index 827fcc590..d3d8538bb 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -150,15 +150,11 @@ exits after 300 idle seconds by default. If the gateway exits while MCP stdio is open, the client performs one coordinated restart and fails if recovery does not succeed. A foreign process on the configured port is rejected with an actionable conflict instead of being accepted because it returned HTTP 200. -Health and readiness include a per-process instance ID. Process-held endpoint -leases and a private recovery cohort make the one-restart allowance global to -all overlapping MCP clients and in-flight persistent hooks, including a client -whose heartbeat misses the first replacement. Every heartbeat verifies that -its cohort is still current, so an integration replacement retires old clients -deterministically instead of allowing them to adopt the replacement gateway. -After the last participant closes, the next acquisition starts a fresh recovery -epoch. A serialized integration replacement explicitly retires the current -cohort and creates a fresh one even while old leases drain. +Health and readiness include a per-process instance ID. A per-user startup lock +serializes launch and recovery, and a small recovery record ensures overlapping +MCP clients share the same single restart. The gateway server itself owns +activity tracking, idle shutdown, and cleanup of its authenticated ownership +record. Codex marks the MCP server as required, so the captured turn waits for verified gateway readiness. Claude Code 2.1.121 or newer uses `alwaysLoad`, which blocks @@ -167,11 +163,10 @@ asynchronously, so its first lifecycle hook can still run first. Every installed MCP entry and `hook-forward` command carry both the generation-file path and the immutable identity expected at that path. A host process that cached an older plugin configuration therefore cannot adopt a replacement -installation after `--force`. Each hook temporarily joins the same endpoint -recovery cohort and closes that race by starting or reusing the same gateway -under the same startup lock. A hook that -cannot establish the authenticated delivery connection may recover the gateway -and retry once. Relay does not retry after payload transmission begins. +installation after `--force`. Persistent hooks wait for the MCP-owned gateway, +verify it on the same connection used for delivery, and send the canonical +payload once. They never start or recover the gateway. Relay does not retry +after payload transmission begins. The MCP server advertises no tools in any host. A fixed-endpoint `hook-forward` command without a valid installer-owned @@ -188,27 +183,22 @@ payload. Authentication and delivery use the same TCP connection, so a process that replaces the listener after a health probe cannot receive lifecycle data. The mode never launches or recovers Relay. -Hook recovery retries only connection failures that are known to precede an -HTTP response. Relay does not claim crash-consistent exactly-once delivery +Relay does not claim crash-consistent exactly-once delivery across a gateway failure after request acceptance: there is no durable hook outbox or delivery-deduplication journal. The opt-in E2E assertions below verify exactly-once lifecycle output for the exercised healthy and cold-start paths, not arbitrary process crashes between request acceptance and response delivery. -On Windows, Relay requests Job Object breakaway only when the host job permits -it. If the host applies a restrictive job but permits nested jobs, Relay keeps -the sidecar scoped to that host job and retains a nested cleanup job for its -process tree; the sidecar cannot outlive the host job. If Relay cannot create or -configure the cleanup job, or the host rejects nested assignment, persistent -bootstrap fails actionably instead of starting without process-tree cleanup -guarantees. +On Windows, Relay requests Job Object breakaway when the host job permits it. +When breakaway is unavailable, the gateway remains scoped to the host job and +can exit before the normal idle reuse window. The compatibility fingerprint covers resolved provider, exporter, dynamic plugin, and relevant environment settings without exposing their values in the health response. For Claude Code and Codex, `nemo-relay install --force` rotates the immutable MCP and hook generation -identity, then rotates the endpoint recovery cohort and uses a private ownership -token to stop the shared fixed-endpoint sidecar as one serialized operation. +identity, then uses a private ownership token to stop the shared fixed-endpoint +gateway as one serialized operation. Ownership is keyed by endpoint, not host, so an upgrade from any installed host can retire a gateway originally started by another. Custom managed endpoints are left untouched. Relay never sends its shutdown From 29e121627369a7876e2a10f5a6a76c51869e271d Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 16:08:19 -0400 Subject: [PATCH 014/102] fix(cli): serialize failed gateway recovery Signed-off-by: Will Killian --- crates/cli/src/sidecar.rs | 18 ++++++++++++++-- crates/cli/src/sidecar/state.rs | 2 +- .../cli/tests/coverage/plugin_host_tests.rs | 1 + .../cli/tests/coverage/sidecar_state_tests.rs | 21 +++++++++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs index 0821f8e77..7f1fd66d2 100644 --- a/crates/cli/src/sidecar.rs +++ b/crates/cli/src/sidecar.rs @@ -197,8 +197,10 @@ fn recover_gateway(spec: &GatewaySpec, expected_instance: &str) -> Result Result, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] pub(super) struct RecoveryRecord { pub(super) from_instance: String, pub(super) endpoint_url: String, diff --git a/crates/cli/tests/coverage/plugin_host_tests.rs b/crates/cli/tests/coverage/plugin_host_tests.rs index bc6b6558e..3700edfcc 100644 --- a/crates/cli/tests/coverage/plugin_host_tests.rs +++ b/crates/cli/tests/coverage/plugin_host_tests.rs @@ -441,6 +441,7 @@ impl EnvVarGuard { Self { key, previous } } + #[cfg(unix)] fn set_value(key: &'static str, value: &str) -> Self { let previous = std::env::var_os(key); // SAFETY: Callers hold the process-wide environment mutex through HomeScope. diff --git a/crates/cli/tests/coverage/sidecar_state_tests.rs b/crates/cli/tests/coverage/sidecar_state_tests.rs index 1eec74690..bee23d624 100644 --- a/crates/cli/tests/coverage/sidecar_state_tests.rs +++ b/crates/cli/tests/coverage/sidecar_state_tests.rs @@ -88,6 +88,27 @@ fn owner_records_are_versioned_endpoint_scoped_and_round_trip() { assert_eq!(lock_name("not a url/with spaces"), "not_a_url_with_spaces"); } +#[test] +fn recovery_records_preserve_pending_and_ready_attempts() { + let dir = tempfile::tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + let pending = RecoveryRecord { + from_instance: "first".into(), + endpoint_url: String::new(), + to_instance: String::new(), + }; + write_recovery(dir.path(), url, &pending).unwrap(); + assert_eq!(read_recovery(dir.path(), url).unwrap(), Some(pending)); + + let ready = RecoveryRecord { + from_instance: "first".into(), + endpoint_url: url.into(), + to_instance: "second".into(), + }; + write_recovery(dir.path(), url, &ready).unwrap(); + assert_eq!(read_recovery(dir.path(), url).unwrap(), Some(ready)); +} + #[test] fn startup_lock_serializes_competing_mcp_processes() { let dir = tempfile::tempdir().unwrap(); From 55f2ccb60e809ad3ed225f66cb7480a81ef17e69 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 18:03:31 -0400 Subject: [PATCH 015/102] fix(cli): preserve user-owned Hermes configuration Signed-off-by: Will Killian --- crates/cli/src/hermes.rs | 130 +++++----- crates/cli/src/hermes/config.rs | 152 +++++++----- crates/cli/src/hermes/trust.rs | 24 +- crates/cli/src/installer.rs | 5 + crates/cli/src/mcp.rs | 29 --- crates/cli/src/sidecar.rs | 25 +- crates/cli/tests/cli_tests.rs | 35 ++- crates/cli/tests/coverage/hermes_tests.rs | 256 +++++++++++++------- crates/cli/tests/coverage/launcher_tests.rs | 7 +- crates/cli/tests/coverage/mcp_tests.rs | 26 -- docs/nemo-relay-cli/hermes.mdx | 13 +- 11 files changed, 380 insertions(+), 322 deletions(-) diff --git a/crates/cli/src/hermes.rs b/crates/cli/src/hermes.rs index 754c3a9cd..0c6b17158 100644 --- a/crates/cli/src/hermes.rs +++ b/crates/cli/src/hermes.rs @@ -17,9 +17,9 @@ use serde_json::{Map, Value, json}; #[cfg(test)] use self::config::persistent_hook_command_for_platform; use self::config::{ - MCP_SERVER_NAME, expected_mcp_server, forwarded_environment_names, is_managed_hook_command, - is_managed_mcp_server, parse_yaml_object, persistent_config, relay_is_executable, - remove_managed_mcp, strip_managed_hooks, user_config_path_with_override, yaml_bytes, + MCP_SERVER_NAME, expected_mcp_server, forwarded_environment_names, owned_install_command, + parse_yaml_object, persistent_config, relay_is_executable, remove_owned_mcp, strip_owned_hooks, + user_config_path_with_override, yaml_bytes, }; use self::files::{ FileSnapshot, INSTALL_LOCK_TIMEOUT, PersistentPaths, acquire_allowlist_lock, @@ -159,7 +159,7 @@ fn persistent_paths_have_managed_state(paths: &PersistentPaths) -> Result Result bool { - config - .get("mcp_servers") - .and_then(|servers| servers.get(MCP_SERVER_NAME)) - .is_some_and(is_managed_mcp_server) - || config - .get("hooks") - .and_then(Value::as_object) + owned_command_from_config(config, None).is_some() +} + +fn allowlist_has_owned_command(allowlist: &Value, command: Option<&str>) -> bool { + command.is_some_and(|command| { + allowlist + .get("approvals") + .and_then(Value::as_array) .into_iter() - .flat_map(Map::values) - .filter_map(Value::as_array) .flatten() - .filter_map(|entry| entry.get("command").and_then(Value::as_str)) - .any(is_managed_hook_command) + .any(|entry| entry.get("command").and_then(Value::as_str) == Some(command)) + }) } -fn allowlist_has_managed_state(allowlist: &Value) -> bool { - allowlist - .get("approvals") - .and_then(Value::as_array) - .into_iter() +fn owned_command_from_config(config: &Value, generation: Option<&Path>) -> Option { + let relay = config + .pointer(&format!("/mcp_servers/{MCP_SERVER_NAME}/command")) + .and_then(Value::as_str) + .map(PathBuf::from)?; + owned_install_command(config, &relay, generation) + .ok() .flatten() - .filter_map(|entry| entry.get("command").and_then(Value::as_str)) - .any(is_managed_hook_command) } pub(crate) fn diagnose_persistent(config_path: &Path) -> Result { @@ -278,17 +277,21 @@ fn relay_executable_from_config(config: &Value) -> Result { .get("mcp_servers") .and_then(|servers| servers.get(MCP_SERVER_NAME)) .ok_or_else(|| format!("Hermes MCP server `{MCP_SERVER_NAME}` is missing"))?; - if !is_managed_mcp_server(server) { + let relay = PathBuf::from( + server + .get("command") + .and_then(Value::as_str) + .ok_or_else(|| "Hermes Relay MCP command is missing".to_string())?, + ); + if owned_install_command(config, &relay, None) + .map_err(|error| error.to_string())? + .is_none() + { return Err(format!( "Hermes MCP server `{MCP_SERVER_NAME}` is not a managed Relay MCP client" )); } - Ok(PathBuf::from( - server - .get("command") - .and_then(Value::as_str) - .expect("managed MCP server has a string command"), - )) + Ok(relay) } #[cfg(test)] @@ -325,6 +328,13 @@ where .collect::, _>>()?; let existing_config = read_optional_utf8(&paths.config)?; let existing_allowlist = read_optional_utf8(&paths.allowlist)?; + let previous_command = match existing_config.as_deref() { + Some(raw) => { + let root = parse_yaml_object(Some(raw), "Hermes config")?; + owned_install_command(&root, relay, Some(&paths.generation))? + } + None => None, + }; let environment = forwarded_environment_names(environment, plugin_config); let token = uuid::Uuid::now_v7().to_string(); let command = @@ -337,7 +347,13 @@ where &token, &environment, )?; - let allowlist = trusted_hooks(existing_allowlist.as_deref(), &command, relay, now)?; + let allowlist = trusted_hooks( + existing_allowlist.as_deref(), + previous_command.as_deref(), + &command, + relay, + now, + )?; let config = yaml_bytes(&config)?; let allowlist = json_bytes(&allowlist)?; let generation = format!("{token}\n").into_bytes(); @@ -383,8 +399,9 @@ where let config = read_optional_utf8(&paths.config)? .map(|raw| { let mut root = parse_yaml_object(Some(&raw), "Hermes config")?; - strip_managed_hooks(&mut root)?; - remove_managed_mcp(&mut root)?; + let owned = owned_command_from_config(&root, Some(&paths.generation)); + strip_owned_hooks(&mut root, owned.as_deref())?; + remove_owned_mcp(&mut root, owned.is_some())?; if root.as_object().is_some_and(Map::is_empty) { Ok(None) } else { @@ -393,6 +410,9 @@ where }) .transpose()? .flatten(); + let owned = read_optional_utf8(&paths.config)? + .and_then(|raw| parse_yaml_object(Some(&raw), "Hermes config").ok()) + .and_then(|root| owned_command_from_config(&root, Some(&paths.generation))); let allowlist = read_optional_utf8(&paths.allowlist)? .map(|raw| { let mut root = parse_json_object(Some(&raw), "Hermes shell-hook allowlist")?; @@ -409,7 +429,7 @@ where entry .get("command") .and_then(Value::as_str) - .is_none_or(|command| !is_managed_hook_command(command)) + .is_none_or(|command| Some(command) != owned.as_deref()) }); if approvals.is_empty() { object.remove("approvals"); @@ -428,7 +448,7 @@ where remove_optional_file(&paths.generation)?; replace_optional_file(&paths.allowlist, allowlist.as_deref(), &mut write)?; replace_optional_file(&paths.config, config.as_deref(), &mut write)?; - verify_uninstall(&paths) + verify_uninstall(&paths, owned.as_deref()) })(); if let Err(error) = result { return rollback_error("uninstall", error, &snapshots, &mut write); @@ -500,49 +520,33 @@ fn verify_hook_definitions(config: &Value, command: &str) -> Result<(), String> .iter() .filter(|group| group.get("command").and_then(Value::as_str) == Some(command)) .count(); - let managed = groups - .iter() - .filter_map(|group| group.get("command").and_then(Value::as_str)) - .filter(|candidate| is_managed_hook_command(candidate)) - .count(); - if matching != 1 || managed != 1 { + if matching != 1 { return Err(format!( "Hermes hook {event} expected exactly one trusted Relay handler" )); } } - let hooks = config + for (event, groups) in config .get("hooks") .and_then(Value::as_object) - .ok_or_else(|| "Hermes hooks are missing".to_string())?; - let mut managed = Vec::new(); - for (event, groups) in hooks { + .into_iter() + .flat_map(Map::iter) + { let groups = groups .as_array() .ok_or_else(|| format!("Hermes {event} hooks must be an array"))?; - for group in groups { - let Some(candidate) = group.get("command").and_then(Value::as_str) else { - continue; - }; - if is_managed_hook_command(candidate) { - managed.push((event.as_str(), candidate)); - } + if !CodingAgent::Hermes.hook_events().contains(&event.as_str()) + && groups + .iter() + .any(|group| group.get("command").and_then(Value::as_str) == Some(command)) + { + return Err("Hermes config contains an unexpected Relay hook handler".into()); } } - managed.sort_unstable(); - let mut expected = CodingAgent::Hermes - .hook_events() - .iter() - .map(|event| (*event, command)) - .collect::>(); - expected.sort_unstable(); - if managed != expected { - return Err("Hermes config contains an unexpected Relay hook handler".into()); - } Ok(()) } -fn verify_uninstall(paths: &PersistentPaths) -> Result<(), String> { +fn verify_uninstall(paths: &PersistentPaths, owned_command: Option<&str>) -> Result<(), String> { if paths.generation.exists() { return Err("Hermes MCP generation fence still exists".into()); } @@ -555,7 +559,7 @@ fn verify_uninstall(paths: &PersistentPaths) -> Result<(), String> { if let Some(raw) = read_optional_utf8(&paths.allowlist).map_err(|error| error.to_string())? { let allowlist = parse_json_object(Some(&raw), "Hermes shell-hook allowlist") .map_err(|e| e.to_string())?; - if allowlist_has_managed_state(&allowlist) { + if allowlist_has_owned_command(&allowlist, owned_command) { return Err("managed Hermes Relay trust approval still exists".into()); } } diff --git a/crates/cli/src/hermes/config.rs b/crates/cli/src/hermes/config.rs index 8228b913a..8a89d07e1 100644 --- a/crates/cli/src/hermes/config.rs +++ b/crates/cli/src/hermes/config.rs @@ -31,8 +31,9 @@ pub(crate) fn transparent_config( gateway_url: &str, ) -> Result { let mut root = parse_yaml_object(Some(existing), "Hermes config")?; - strip_managed_hooks(&mut root)?; - remove_managed_mcp(&mut root)?; + let owned = owned_install_command(&root, relay, None)?; + strip_owned_hooks(&mut root, owned.as_deref())?; + remove_owned_mcp(&mut root, owned.is_some())?; let command = crate::installer::transparent_hook_forward_command( relay, crate::config::CodingAgent::Hermes, @@ -84,14 +85,17 @@ pub(super) fn persistent_config( environment: &[String], ) -> Result { let mut root = parse_yaml_object(existing, "Hermes config")?; - if let Some(server) = root.pointer(&format!("/mcp_servers/{MCP_SERVER_NAME}")) - && !is_managed_mcp_server(server) + let owned = owned_install_command(&root, relay, Some(generation))?; + if root + .pointer(&format!("/mcp_servers/{MCP_SERVER_NAME}")) + .is_some() + && owned.is_none() { return Err(CliError::Install(format!( "Hermes MCP server `{MCP_SERVER_NAME}` already exists and is not managed by Relay; rename or remove it before installing the Relay integration" ))); } - strip_managed_hooks(&mut root)?; + strip_owned_hooks(&mut root, owned.as_deref())?; root = merge_hooks( root, generated_hooks(crate::config::CodingAgent::Hermes, command), @@ -128,7 +132,10 @@ pub(super) fn forwarded_environment_names( crate::mcp_environment::forwarded_names(environment.iter().cloned(), plugin_config) } -pub(super) fn strip_managed_hooks(root: &mut Value) -> Result<(), CliError> { +pub(super) fn strip_owned_hooks( + root: &mut Value, + owned_command: Option<&str>, +) -> Result<(), CliError> { let Some(hooks) = root.get_mut("hooks") else { return Ok(()); }; @@ -145,7 +152,7 @@ pub(super) fn strip_managed_hooks(root: &mut Value) -> Result<(), CliError> { group .get("command") .and_then(Value::as_str) - .is_none_or(|command| !is_managed_hook_command(command)) + .is_none_or(|command| Some(command) != owned_command) }); if groups.is_empty() { empty.push(event.clone()); @@ -164,17 +171,14 @@ pub(super) fn strip_managed_hooks(root: &mut Value) -> Result<(), CliError> { Ok(()) } -pub(super) fn remove_managed_mcp(root: &mut Value) -> Result<(), CliError> { +pub(super) fn remove_owned_mcp(root: &mut Value, owned: bool) -> Result<(), CliError> { let Some(servers) = root.get_mut("mcp_servers") else { return Ok(()); }; let servers = servers .as_object_mut() .ok_or_else(|| CliError::Install("Hermes mcp_servers must be an object".into()))?; - if servers - .get(MCP_SERVER_NAME) - .is_some_and(is_managed_mcp_server) - { + if owned { servers.remove(MCP_SERVER_NAME); } if servers.is_empty() { @@ -185,57 +189,95 @@ pub(super) fn remove_managed_mcp(root: &mut Value) -> Result<(), CliError> { Ok(()) } -pub(super) fn is_managed_mcp_server(server: &Value) -> bool { - crate::mcp::is_managed_server(server, is_relay_executable) -} - -pub(crate) fn is_managed_hook_command(command: &str) -> bool { - if let Some(arguments) = crate::installer::decode_windows_hook_command(command) { - if arguments.len() < 3 - || !is_relay_executable(&arguments[0]) - || arguments[1] != "hook-forward" - || arguments[2] != "hermes" +pub(super) fn owned_install_command( + root: &Value, + relay: &Path, + expected_generation: Option<&Path>, +) -> Result, CliError> { + let Some(server) = root.pointer(&format!("/mcp_servers/{MCP_SERVER_NAME}")) else { + return Ok(None); + }; + if server.get("command") != Some(&json!(relay)) { + return Ok(None); + } + let env = server.get("env").and_then(Value::as_object); + if server.get("args") == Some(&json!(["mcp"])) + && env.and_then(|env| env.get("NEMO_RELAY_GATEWAY_BIND")) + == Some(&json!(crate::sidecar::DEFAULT_BIND)) + { + let generation = env + .and_then(|env| env.get(crate::install_generation::GENERATION_FILE_ENV)) + .and_then(Value::as_str); + let token = env + .and_then(|env| env.get(crate::install_generation::GENERATION_TOKEN_ENV)) + .and_then(Value::as_str); + if let (Some(generation), Some(token)) = (generation, token) + && !token.is_empty() + && expected_generation.is_none_or(|expected| Path::new(generation) == expected) { - return false; + let command = persistent_hook_command(relay, Path::new(generation), token) + .map_err(CliError::Install)?; + return Ok(has_complete_hook_set(root, &command).then_some(command)); } - let options = &arguments[3..]; - return options.is_empty() - || (options.len() == 3 - && options[0] == "--gateway-url" - && options[2] == "--transparent-run") - || (options.len() == 6 - && options[0] == "--gateway-url" - && options[2] == "--generation-file" - && options[4] == "--generation-token"); } - [" hook-forward hermes", " plugin-shim hook hermes"] - .into_iter() - .any(|separator| { - command - .trim() - .rsplit_once(separator) - .is_some_and(|(executable, arguments)| { - is_relay_executable(executable) - && (arguments.is_empty() - || arguments.starts_with(" --gateway-url ") - || arguments.starts_with(" --fail-closed")) + legacy_owned_command(root, relay) +} + +fn has_complete_hook_set(root: &Value, command: &str) -> bool { + crate::config::CodingAgent::Hermes + .hook_events() + .iter() + .all(|event| { + root.pointer(&format!("/hooks/{event}")) + .and_then(Value::as_array) + .is_some_and(|groups| { + groups + .iter() + .filter(|entry| { + entry.get("command").and_then(Value::as_str) == Some(command) + }) + .count() + == 1 }) }) } -fn is_relay_executable(raw: &str) -> bool { - let mut candidate = raw.trim().to_string(); - if candidate.starts_with('\'') && candidate.ends_with('\'') && candidate.len() >= 2 { - candidate = candidate[1..candidate.len() - 1].replace("'\\''", "'"); - } else if candidate.starts_with('"') && candidate.ends_with('"') && candidate.len() >= 2 { - candidate = candidate[1..candidate.len() - 1].to_string(); +fn legacy_owned_command(root: &Value, relay: &Path) -> Result, CliError> { + let server = &root["mcp_servers"][MCP_SERVER_NAME]; + if server.get("args") != Some(&json!(["mcp", "--agent", "hermes"])) { + return Ok(None); } - candidate = candidate.replace('^', "").replace("%%", "%"); - let normalized = candidate.replace('\\', "/"); - matches!( - normalized.rsplit('/').next().map(str::to_ascii_lowercase), - Some(name) if name == "nemo-relay" || name == "nemo-relay.exe" - ) + let Some(hooks) = root.get("hooks").and_then(Value::as_object) else { + return Ok(None); + }; + let mut common = None; + for event in crate::config::CodingAgent::Hermes.hook_events() { + let commands = hooks + .get(*event) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|entry| entry.get("command").and_then(Value::as_str)) + .filter(|command| legacy_command_uses_relay(command, relay)) + .collect::>(); + if commands.len() != 1 || common.is_some_and(|value| value != commands[0]) { + return Ok(None); + } + common = Some(commands[0]); + } + Ok(common.map(str::to_owned)) +} + +fn legacy_command_uses_relay(command: &str, relay: &Path) -> bool { + let relay = relay.to_string_lossy(); + let quoted = crate::plugin_host::shell_quote_arg_for_platform(&relay, cfg!(windows)); + [relay.as_ref(), quoted.as_str()].into_iter().any(|prefix| { + command.strip_prefix(prefix).is_some_and(|arguments| { + [" hook-forward hermes", " plugin-shim hook hermes"] + .iter() + .any(|marker| arguments.starts_with(marker)) + }) + }) } pub(super) fn relay_is_executable(path: &Path) -> bool { diff --git a/crates/cli/src/hermes/trust.rs b/crates/cli/src/hermes/trust.rs index ed704c806..2f140596c 100644 --- a/crates/cli/src/hermes/trust.rs +++ b/crates/cli/src/hermes/trust.rs @@ -10,12 +10,12 @@ use std::time::SystemTime; use chrono::{DateTime, SecondsFormat, Utc}; use serde_json::{Value, json}; -use super::config::is_managed_hook_command; use crate::config::CodingAgent; use crate::error::CliError; pub(super) fn trusted_hooks( existing: Option<&str>, + previous_command: Option<&str>, command: &str, relay: &Path, now: SystemTime, @@ -34,7 +34,7 @@ pub(super) fn trusted_hooks( entry .get("command") .and_then(Value::as_str) - .is_none_or(|candidate| !is_managed_hook_command(candidate)) + .is_none_or(|candidate| Some(candidate) != previous_command) }); let approved_at = timestamp(now); let script_mtime_at_approval = fs::metadata(relay) @@ -79,29 +79,17 @@ pub(super) fn verify_trust(allowlist_path: &Path, command: &str) -> Result<(), S )); } } - let mut managed = Vec::new(); for entry in approvals { - let Some(candidate) = entry.get("command").and_then(Value::as_str) else { - continue; - }; - if !is_managed_hook_command(candidate) { + if entry.get("command").and_then(Value::as_str) != Some(command) { continue; } let event = entry .get("event") .and_then(Value::as_str) .ok_or_else(|| "Hermes Relay hook approval is missing its event".to_string())?; - managed.push((event, candidate)); - } - managed.sort_unstable(); - let mut expected = CodingAgent::Hermes - .hook_events() - .iter() - .map(|event| (*event, command)) - .collect::>(); - expected.sort_unstable(); - if managed != expected { - return Err("Hermes allowlist contains an unexpected Relay hook approval".into()); + if !CodingAgent::Hermes.hook_events().contains(&event) { + return Err("Hermes allowlist contains an unexpected Relay hook approval".into()); + } } Ok(()) } diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index e73efea6c..818d6584c 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -5,6 +5,7 @@ use std::io::Read; use std::path::Path; use std::time::{Duration, Instant}; +#[cfg(any(windows, test))] use base64::Engine; use futures_util::StreamExt; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; @@ -559,6 +560,7 @@ fn posix_hook_command(relay: &Path, arguments: &[String]) -> String { // `cmd.exe` accepts at most 8,191 characters. Leave room for `/C` and the executable path added // by the hook host instead of generating a command that will be truncated at runtime. +#[cfg(any(windows, test))] const MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS: usize = 8_000; /// Encode a native Relay invocation so Windows hook hosts can pass it through `cmd.exe /C` as one @@ -640,6 +642,7 @@ fn windows_powershell_path() -> Result { Ok(powershell) } +#[cfg(any(windows, test))] fn safe_windows_launcher_token(launcher: &str) -> bool { !launcher.is_empty() && launcher.chars().all(|character| { @@ -653,6 +656,7 @@ fn safe_windows_launcher_token(launcher: &str) -> bool { /// Decode only the exact PowerShell envelope emitted by [`encoded_windows_hook_command`]. /// /// Hermes uses this to migrate and replace Relay-owned hooks whose generation arguments change. +#[cfg(test)] pub(crate) fn decode_windows_hook_command(command: &str) -> Option> { const COMMAND_SEPARATOR: &str = " -NoLogo -NoProfile -NonInteractive -EncodedCommand "; const SCRIPT_PREFIX: &str = "$ErrorActionPreference='Stop'; & "; @@ -691,6 +695,7 @@ pub(crate) fn decode_windows_hook_command(command: &str) -> Option> parse_powershell_single_quoted_arguments(invocation) } +#[cfg(test)] fn parse_powershell_single_quoted_arguments(mut raw: &str) -> Option> { let mut arguments = Vec::new(); while !raw.is_empty() { diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs index 8225fd2e9..f63add12e 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -72,35 +72,6 @@ pub(crate) fn persistent_server( }) } -/// Returns whether a server entry is owned by Relay's current or legacy MCP launch contract. -/// -/// Legacy `--agent` spellings are accepted only for migration. New configurations always use the -/// single host-neutral `nemo-relay mcp` command. -pub(crate) fn is_managed_server( - server: &Value, - relay_executable: impl FnOnce(&str) -> bool, -) -> bool { - let executable_matches = server - .get("command") - .and_then(Value::as_str) - .is_some_and(relay_executable); - if !executable_matches { - return false; - } - if server.get("args") == Some(&json!(LAUNCH_ARGS)) { - return true; - } - let Some(arguments) = server.get("args").and_then(Value::as_array) else { - return false; - }; - arguments.len() == 3 - && arguments[0] == "mcp" - && arguments[1] == "--agent" - && arguments[2] - .as_str() - .is_some_and(|agent| matches!(agent, "claude" | "codex" | "hermes")) -} - fn transparent_run_active() -> bool { std::env::var(crate::config::TRANSPARENT_RUN_ENV) .ok() diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs index 7f1fd66d2..731605ad9 100644 --- a/crates/cli/src/sidecar.rs +++ b/crates/cli/src/sidecar.rs @@ -9,7 +9,7 @@ mod state; use std::env; use std::ffi::OsString; -use std::fs::{self, OpenOptions}; +use std::fs; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; @@ -260,21 +260,12 @@ fn incompatible_relay_error(url: &str) -> String { fn start_gateway(spec: &GatewaySpec, state: &Path) -> Result { let relay = relay_binary()?; - let log_path = state.join("gateway-sidecar.log"); let ready_path = state.join(format!( "gateway-{}-{}.ready.json", std::process::id(), uuid::Uuid::now_v7() )); let _ = fs::remove_file(&ready_path); - let log = OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - .map_err(|error| format!("failed to open {}: {error}", log_path.display()))?; - let err_log = log - .try_clone() - .map_err(|error| format!("failed to clone gateway log handle: {error}"))?; let shutdown_token = uuid::Uuid::now_v7().to_string(); let mut command = Command::new(relay); command @@ -296,8 +287,8 @@ fn start_gateway(spec: &GatewaySpec, state: &Path) -> Result Result { let _ = fs::remove_file(&ready_path); return Err(format!( - "nemo-relay gateway exited before becoming ready at http://{}: {status}; inspect {}", - spec.bind, - log_path.display() + "nemo-relay gateway exited before becoming ready at http://{}: {status}", + spec.bind )); } Ok(None) => {} @@ -352,9 +342,8 @@ fn start_gateway(spec: &GatewaySpec, state: &Path) -> Result SocketAddr { let deadline = Instant::now() + Duration::from_secs(5); loop { - let log_path = find_runtime_file(temp, "gateway-sidecar.log"); - if let Some(log_path) = log_path.as_ref() - && let Ok(log) = std::fs::read_to_string(log_path) - && let Some(address) = log.lines().find_map(|line| { - line.split("Gateway http://") - .nth(1) - .and_then(|value| value.split_whitespace().next()) - .and_then(|value| value.parse().ok()) - }) - { - return address; + for path in find_runtime_files_matching(temp, "sidecar-", ".owner.json") { + if let Ok(raw) = std::fs::read(path) + && let Ok(owner) = serde_json::from_slice::(&raw) + && let Some(address) = owner["url"] + .as_str() + .and_then(|url| url.strip_prefix("http://")) + .and_then(|address| address.parse().ok()) + { + return address; + } } assert!( Instant::now() < deadline, - "sidecar address was not logged under {}; log: {:?}", - temp.display(), - log_path.and_then(|path| std::fs::read_to_string(path).ok()) + "sidecar ownership was not published under {}", + temp.display() ); thread::sleep(Duration::from_millis(20)); } diff --git a/crates/cli/tests/coverage/hermes_tests.rs b/crates/cli/tests/coverage/hermes_tests.rs index abe2cdf97..f0be937e0 100644 --- a/crates/cli/tests/coverage/hermes_tests.rs +++ b/crates/cli/tests/coverage/hermes_tests.rs @@ -114,7 +114,7 @@ fn install_uses_the_native_hermes_allowlist_lock() { } #[test] -fn hook_command_round_trips_paths_and_recognizes_owned_legacy_spellings() { +fn hook_command_round_trips_paths_and_platform_metacharacters() { let relay = Path::new("/tmp/NeMo $Relay`test'/bin/nemo-relay"); let generation = Path::new("/tmp/generation"); assert_eq!( @@ -163,28 +163,7 @@ fn hook_command_round_trips_paths_and_recognizes_owned_legacy_spellings() { TEST_GENERATION_TOKEN, true, ); - for command in [ - "nemo-relay hook-forward hermes", - "/old/path/nemo-relay plugin-shim hook hermes", - "'/old/plugin-shim hook hermes/nemo-relay' plugin-shim hook hermes", - "'/old install/nemo-relay' plugin-shim hook hermes --gateway-url http://127.0.0.1:47632", - r#""C:\Program Files\NeMo 100%%\nemo-relay.exe" plugin-shim hook hermes"#, - &encoded, - ] { - assert!( - is_managed_hook_command(command), - "not recognized: {command}" - ); - } - for command in [ - "relay-helper hook-forward hermes", - "echo nemo-relay hook-forward hermes", - "nemo-relay plugin-shim hook codex", - "nemo-relay-safe plugin-shim hook hermes", - &encoded_codex, - ] { - assert!(!is_managed_hook_command(command), "overmatched: {command}"); - } + assert_ne!(encoded, encoded_codex); } #[test] @@ -220,27 +199,30 @@ fn persistent_config_migrates_owned_state_and_preserves_unrelated_config() { let relay = relay_binary(temp.path()); let generation = temp.path().join(GENERATION_FILE_NAME); let command = persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); - let existing = r#" -model: keep-me -mcp_servers: - filesystem: - command: fs-mcp - nemo-relay: - command: /old/bin/nemo-relay - args: [mcp, --agent, hermes] -hooks: - on_session_start: - - command: custom-hook - timeout: 9 - - command: nemo-relay hook-forward hermes - timeout: 30 - legacy_event: - - command: /old/bin/nemo-relay plugin-shim hook hermes - custom_event: - - command: keep-custom -"#; + let legacy_command = format!("{} hook-forward hermes", relay.display()); + let mut legacy_hooks = serde_json::Map::new(); + for event in CodingAgent::Hermes.hook_events() { + legacy_hooks.insert(event.to_string(), json!([{"command": legacy_command}])); + } + legacy_hooks.insert( + "on_session_start".into(), + json!([ + {"command": "custom-hook", "timeout": 9}, + {"command": legacy_command, "timeout": 30} + ]), + ); + legacy_hooks.insert("custom_event".into(), json!([{"command": "keep-custom"}])); + let existing = serde_yaml::to_string(&json!({ + "model": "keep-me", + "mcp_servers": { + "filesystem": {"command": "fs-mcp"}, + MCP_SERVER_NAME: {"command": relay, "args": ["mcp", "--agent", "hermes"]} + }, + "hooks": legacy_hooks + })) + .unwrap(); let merged = persistent_config( - Some(existing), + Some(&existing), &relay, &command, &generation, @@ -282,7 +264,6 @@ hooks: merged["hooks"]["on_session_start"][1]["command"], json!(command) ); - assert!(merged["hooks"].get("legacy_event").is_none()); assert_eq!( merged["hooks"]["custom_event"][0]["command"], json!("keep-custom") @@ -329,6 +310,87 @@ mcp_servers: assert!(error.contains("rename or remove"), "{error}"); } +#[test] +fn manual_same_named_mcp_and_hooks_are_never_claimed() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let generation = temp.path().join(GENERATION_FILE_NAME); + let command = persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); + let manual = serde_yaml::to_string(&json!({ + "mcp_servers": { + MCP_SERVER_NAME: {"command": relay, "args": ["mcp"], "env": {"CUSTOM": "keep"}} + }, + "hooks": { + "on_session_start": [{"command": format!("{} hook-forward hermes", relay.display())}] + } + })) + .unwrap(); + + let error = persistent_config( + Some(&manual), + &relay, + &command, + &generation, + TEST_GENERATION_TOKEN, + &[], + ) + .unwrap_err() + .to_string(); + assert!(error.contains("not managed by Relay"), "{error}"); + + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + std::fs::write(&paths.config, &manual).unwrap(); + std::fs::write( + &paths.allowlist, + serde_json::to_vec(&json!({"approvals": [{ + "event": "on_session_start", + "command": format!("{} hook-forward hermes", relay.display()) + }]})) + .unwrap(), + ) + .unwrap(); + std::fs::write(&paths.generation, "orphaned-relay-state\n").unwrap(); + + uninstall_persistent_with(paths.clone(), atomic_write).unwrap(); + assert_eq!(std::fs::read_to_string(&paths.config).unwrap(), manual); + assert_eq!( + json_file(&paths.allowlist)["approvals"] + .as_array() + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn mismatched_generation_hook_does_not_prove_ownership() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let generation = temp.path().join(GENERATION_FILE_NAME); + let mut root = persistent_config( + None, + &relay, + &persistent_hook_command(&relay, &generation, "hook-token").unwrap(), + &generation, + "mcp-token", + &[], + ) + .unwrap(); + assert!( + owned_install_command(&root, &relay, Some(&generation)) + .unwrap() + .is_none() + ); + + root["mcp_servers"][MCP_SERVER_NAME]["command"] = json!(temp.path().join("other/nemo-relay")); + assert!( + owned_install_command(&root, &relay, Some(&generation)) + .unwrap() + .is_none() + ); +} + #[test] fn foreign_reserved_server_aborts_install_before_any_file_changes() { let temp = tempfile::tempdir().unwrap(); @@ -368,6 +430,7 @@ fn trusted_hooks_migrates_only_relay_approvals_and_records_every_event() { let now = UNIX_EPOCH + Duration::from_secs(1_700_000_000); let merged = trusted_hooks( Some(&serde_json::to_string(&existing).unwrap()), + Some("nemo-relay hook-forward hermes"), &command, &relay, now, @@ -381,7 +444,7 @@ fn trusted_hooks_migrates_only_relay_approvals_and_records_every_event() { .iter() .any(|entry| entry["command"] == json!("custom-hook")) ); - assert_eq!(approvals.len(), CodingAgent::Hermes.hook_events().len() + 1); + assert_eq!(approvals.len(), CodingAgent::Hermes.hook_events().len() + 2); for event in CodingAgent::Hermes.hook_events() { let entries = approvals .iter() @@ -427,7 +490,7 @@ fn verification_rejects_relay_handlers_and_approvals_on_unexpected_events() { let error = verify_hook_definitions(&malformed, &command).unwrap_err(); assert!(error.contains("must be an array")); - let mut allowlist = trusted_hooks(None, &command, &relay, UNIX_EPOCH).unwrap(); + let mut allowlist = trusted_hooks(None, None, &command, &relay, UNIX_EPOCH).unwrap(); allowlist["approvals"].as_array_mut().unwrap().push(json!({ "event": "unexpected_event", "command": command, @@ -438,7 +501,7 @@ fn verification_rejects_relay_handlers_and_approvals_on_unexpected_events() { let error = verify_trust(&path, &command).unwrap_err(); assert!(error.contains("unexpected Relay hook approval")); - let mut missing_event = trusted_hooks(None, &command, &relay, UNIX_EPOCH).unwrap(); + let mut missing_event = trusted_hooks(None, None, &command, &relay, UNIX_EPOCH).unwrap(); missing_event["approvals"] .as_array_mut() .unwrap() @@ -458,9 +521,15 @@ fn hermes_structure_and_trust_validation_cover_exact_failure_shapes() { let generation = temp.path().join(GENERATION_FILE_NAME); let command = persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); - let error = trusted_hooks(Some(r#"{"approvals": {}}"#), &command, &relay, UNIX_EPOCH) - .unwrap_err() - .to_string(); + let error = trusted_hooks( + Some(r#"{"approvals": {}}"#), + None, + &command, + &relay, + UNIX_EPOCH, + ) + .unwrap_err() + .to_string(); assert!(error.contains("approvals must be an array"), "{error}"); let error = parse_json_object(Some("[]"), "test allowlist") @@ -469,7 +538,7 @@ fn hermes_structure_and_trust_validation_cover_exact_failure_shapes() { assert!(error.contains("must contain a JSON object"), "{error}"); let mut malformed_hooks = json!({"hooks": {"on_session_start": {}}}); - let error = strip_managed_hooks(&mut malformed_hooks) + let error = strip_owned_hooks(&mut malformed_hooks, Some(&command)) .unwrap_err() .to_string(); assert!( @@ -481,12 +550,8 @@ fn hermes_structure_and_trust_validation_cover_exact_failure_shapes() { .unwrap_err() .to_string(); assert!(error.contains("must contain an object"), "{error}"); - assert!(is_managed_hook_command( - "nemo-relay hook-forward hermes --fail-closed" - )); - let path = temp.path().join("shell-hooks-allowlist.json"); - let mut missing = trusted_hooks(None, &command, &relay, UNIX_EPOCH).unwrap(); + let mut missing = trusted_hooks(None, None, &command, &relay, UNIX_EPOCH).unwrap(); missing["approvals"].as_array_mut().unwrap().remove(0); std::fs::write(&path, serde_json::to_vec(&missing).unwrap()).unwrap(); let error = verify_trust(&path, &command).unwrap_err(); @@ -495,7 +560,7 @@ fn hermes_structure_and_trust_validation_cover_exact_failure_shapes() { "{error}" ); - let mut with_opaque_entry = trusted_hooks(None, &command, &relay, UNIX_EPOCH).unwrap(); + let mut with_opaque_entry = trusted_hooks(None, None, &command, &relay, UNIX_EPOCH).unwrap(); with_opaque_entry["approvals"] .as_array_mut() .unwrap() @@ -544,12 +609,14 @@ fn install_is_verified_idempotent_and_rotates_the_generation() { assert_ne!(first_generation, second_generation); let config = yaml(&paths.config); + let second_command = + persistent_hook_command(&relay, &paths.generation, &second_generation).unwrap(); assert_eq!( config["hooks"]["on_session_start"] .as_array() .unwrap() .iter() - .filter(|group| is_managed_hook_command(group["command"].as_str().unwrap())) + .filter(|group| group["command"] == json!(second_command)) .count(), 1 ); @@ -632,10 +699,7 @@ fn diagnosis_rejects_a_stale_mcp_generation_identity() { let error = diagnose_persistent(&paths.config).unwrap_err(); - assert!( - error.contains("expected generation identity is stale"), - "{error}" - ); + assert!(error.contains("not a managed Relay MCP client"), "{error}"); } #[test] @@ -1025,13 +1089,20 @@ fn persistent_state_detection_recognizes_each_relay_owned_surface() { ) .unwrap(); - for paths in roots { + for paths in &roots[..1] { assert!( persistent_state_exists(&paths.config), "managed state at {} was not detected", paths.config.display() ); } + for paths in &roots[1..] { + assert!( + !persistent_state_exists(&paths.config), + "ambiguous state at {} was claimed as managed", + paths.config.display() + ); + } } #[test] @@ -1044,21 +1115,24 @@ fn transparent_config_suppresses_only_the_managed_mcp_and_uses_one_relay_hook() "http://127.0.0.1:1234", ) .unwrap(); - let existing = format!( - r#" -mcp_servers: - nemo-relay: - command: {relay} - args: [mcp, --agent, hermes] - filesystem: - command: fs-mcp -hooks: - on_session_start: - - command: nemo-relay hook-forward hermes - - command: custom-hook -"#, - relay = relay.display() - ); + let generation = temp.path().join(GENERATION_FILE_NAME); + let persistent_command = + persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); + let mut existing = persistent_config( + None, + &relay, + &persistent_command, + &generation, + TEST_GENERATION_TOKEN, + &[], + ) + .unwrap(); + existing["mcp_servers"]["filesystem"] = json!({"command": "fs-mcp"}); + existing["hooks"]["on_session_start"] + .as_array_mut() + .unwrap() + .push(json!({"command": "custom-hook"})); + let existing = serde_yaml::to_string(&existing).unwrap(); let patched: Value = serde_yaml::from_str( &transparent_config(&existing, &relay, "http://127.0.0.1:1234").unwrap(), ) @@ -1075,7 +1149,7 @@ hooks: groups .iter() .filter_map(|group| group.get("command").and_then(Value::as_str)) - .filter(|candidate| is_managed_hook_command(candidate)) + .filter(|candidate| **candidate == command) .count(), 1, "event {event}" @@ -1136,7 +1210,7 @@ fn hermes_entrypoints_reject_missing_or_foreign_relay_binaries() { ) .unwrap(); let error = configured_relay_executable(&config_path).unwrap_err(); - assert!(error.contains("missing or not executable"), "{error}"); + assert!(error.contains("not a managed Relay MCP client"), "{error}"); let foreign = json!({ "mcp_servers": { @@ -1169,7 +1243,7 @@ fn hermes_diagnosis_validates_binary_bind_generation_and_environment() { json!("127.0.0.1:1"); std::fs::write(&paths.config, serde_yaml::to_string(&wrong_bind).unwrap()).unwrap(); let error = diagnose_persistent(&paths.config).unwrap_err(); - assert!(error.contains("shared gateway bind"), "{error}"); + assert!(error.contains("not a managed Relay MCP client"), "{error}"); let mut wrong_generation = original.clone(); wrong_generation["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_FILE_ENV] = @@ -1180,7 +1254,7 @@ fn hermes_diagnosis_validates_binary_bind_generation_and_environment() { ) .unwrap(); let error = diagnose_persistent(&paths.config).unwrap_err(); - assert!(error.contains("points at the wrong file"), "{error}"); + assert!(error.contains("not a managed Relay MCP client"), "{error}"); let mut missing_environment = original; assert!( @@ -1332,18 +1406,19 @@ fn hermes_uninstall_verifier_identifies_each_residual_owned_surface() { let paths = paths(&temp.path().join("hermes")); install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); - let error = verify_uninstall(&paths).unwrap_err(); + let command = owned_command_from_config(&yaml(&paths.config), Some(&paths.generation)); + let error = verify_uninstall(&paths, command.as_deref()).unwrap_err(); assert!(error.contains("generation fence still exists"), "{error}"); std::fs::remove_file(&paths.generation).unwrap(); - let error = verify_uninstall(&paths).unwrap_err(); + let error = verify_uninstall(&paths, command.as_deref()).unwrap_err(); assert!( error.contains("managed Hermes Relay config still exists"), "{error}" ); std::fs::remove_file(&paths.config).unwrap(); - let error = verify_uninstall(&paths).unwrap_err(); + let error = verify_uninstall(&paths, command.as_deref()).unwrap_err(); assert!( error.contains("managed Hermes Relay trust approval still exists"), "{error}" @@ -1432,7 +1507,7 @@ fn hermes_rollback_reports_both_primary_and_snapshot_restore_errors() { } #[test] -fn hermes_uninstall_removes_an_allowlist_containing_only_managed_approvals() { +fn hermes_uninstall_preserves_an_ambiguous_manual_allowlist() { let temp = tempfile::tempdir().unwrap(); let paths = paths(&temp.path().join("hermes")); std::fs::create_dir_all(paths.allowlist.parent().unwrap()).unwrap(); @@ -1451,5 +1526,12 @@ fn hermes_uninstall_removes_an_allowlist_containing_only_managed_approvals() { let affected = uninstall_persistent_with(paths.clone(), atomic_write).unwrap(); assert_eq!(affected, vec![paths.allowlist.clone()]); - assert!(!paths.allowlist.exists()); + assert!(paths.allowlist.exists()); + assert_eq!( + json_file(&paths.allowlist)["approvals"] + .as_array() + .unwrap() + .len(), + 1 + ); } diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index bc694e4e4..0c6f1290b 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -1017,7 +1017,7 @@ fn hermes_hooks_path_prefers_configured_then_env_then_home() { } #[test] -fn hermes_overlay_preserves_original_file() { +fn hermes_overlay_preserves_ambiguous_manual_mcp_and_original_file() { let _guard = current_dir_lock().lock().unwrap(); let temp = tempfile::tempdir().unwrap(); let hooks_path = temp.path().join("hermes-home/config.yaml"); @@ -1070,7 +1070,10 @@ hooks: "--transparent-run", ], )); - assert!(patched_yaml["mcp_servers"].get("nemo-relay").is_none()); + assert_eq!( + patched_yaml["mcp_servers"]["nemo-relay"]["args"], + json!(["mcp", "--agent", "hermes"]) + ); assert_eq!( patched_yaml["mcp_servers"]["filesystem"]["command"], json!("fs-mcp") diff --git a/crates/cli/tests/coverage/mcp_tests.rs b/crates/cli/tests/coverage/mcp_tests.rs index 49194a075..036b76519 100644 --- a/crates/cli/tests/coverage/mcp_tests.rs +++ b/crates/cli/tests/coverage/mcp_tests.rs @@ -772,29 +772,3 @@ fn persistent_mcp_server_contract_is_host_neutral_and_generation_fenced() { "generation-token" ); } - -#[test] -fn managed_mcp_server_recognizes_one_current_contract_and_legacy_migrations() { - let relay = |command: &str| command.ends_with("nemo-relay"); - assert!(is_managed_server( - &json!({"command": "/bin/nemo-relay", "args": ["mcp"]}), - relay - )); - for agent in ["claude", "codex", "hermes"] { - assert!(is_managed_server( - &json!({ - "command": "/bin/nemo-relay", - "args": ["mcp", "--agent", agent] - }), - relay - )); - } - for foreign in [ - json!({"command": "/bin/other", "args": ["mcp"]}), - json!({"command": "/bin/nemo-relay", "args": ["mcp", "--agent", "unknown"]}), - json!({"command": "/bin/nemo-relay", "args": ["mcp", "--extra"]}), - json!({"command": "/bin/nemo-relay"}), - ] { - assert!(!is_managed_server(&foreign, relay), "{foreign}"); - } -} diff --git a/docs/nemo-relay-cli/hermes.mdx b/docs/nemo-relay-cli/hermes.mdx index f0ba50176..dc393a2ad 100644 --- a/docs/nemo-relay-cli/hermes.mdx +++ b/docs/nemo-relay-cli/hermes.mdx @@ -100,6 +100,13 @@ Persistent MCP mode reads only system and user Relay configuration. It does not load a project's `.nemo-relay` layer. Use transparent run mode for project-specific Relay configuration. +Relay removes or replaces Hermes MCP entries, hooks, and approvals only when +the canonical executable, generation fence, and complete required hook set form +one coherent installed identity. Manual or partial lookalike entries are +preserved. Rename or remove a conflicting manual `mcp_servers.nemo-relay` entry +before installation. Legacy entries are migrated only when their MCP entry and +complete hook set use the same canonical Relay executable. + ## Route Model Traffic Hermes API hooks can provide sanitized model request and response data. Route @@ -195,9 +202,9 @@ It is intentionally outside mandatory Rust CI. ## Troubleshoot If Hermes reports that the MCP server failed to start, run -`nemo-relay doctor --plugin hermes` and inspect the sidecar log path named in -the error. A foreign listener on `127.0.0.1:47632` is rejected rather than -adopted. +`nemo-relay doctor --plugin hermes` and inspect the reported configuration or +endpoint error. Detached gateways do not create persistent log files. A foreign +listener on `127.0.0.1:47632` is rejected rather than adopted. If lifecycle events appear but direct model spans do not, check whether the Hermes provider base URL points at Relay. If model spans attach to the parent From 0e888e9f45dd058e2910c91f270fe9f66be9f2bf Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 19:24:04 -0400 Subject: [PATCH 016/102] fix: constrain plugin cleanup ownership Signed-off-by: Will Killian --- crates/cli/src/install_generation.rs | 24 ++++++ crates/cli/src/plugin_install/mod.rs | 68 ++++++++++------ crates/cli/src/plugin_install/state.rs | 38 +++++++++ crates/cli/src/sidecar.rs | 45 +++++++++-- .../coverage/install_generation_tests.rs | 17 ++++ .../tests/coverage/plugin_install_tests.rs | 78 +++++++------------ crates/cli/tests/coverage/sidecar_tests.rs | 25 ++++++ 7 files changed, 216 insertions(+), 79 deletions(-) diff --git a/crates/cli/src/install_generation.rs b/crates/cli/src/install_generation.rs index c44187cc4..1e55a54bf 100644 --- a/crates/cli/src/install_generation.rs +++ b/crates/cli/src/install_generation.rs @@ -368,9 +368,24 @@ impl GenerationRetirement { Self::acquire_with_timeout(path, DEFAULT_GENERATION_LOCK_TIMEOUT) } + pub(crate) fn acquire_for_plugin( + path: &Path, + external_lock: &Path, + ) -> Result, String> { + Self::acquire_impl(path, DEFAULT_GENERATION_LOCK_TIMEOUT, Some(external_lock)) + } + pub(crate) fn acquire_with_timeout( path: &Path, timeout: Duration, + ) -> Result, String> { + Self::acquire_impl(path, timeout, None) + } + + fn acquire_impl( + path: &Path, + timeout: Duration, + allowed_external_lock: Option<&Path>, ) -> Result, String> { if !inspected_path_exists(path, "MCP install generation")? { return Ok(None); @@ -380,6 +395,15 @@ impl GenerationRetirement { let marker = open_generation(path)?; let observed = read_generation_marker(&marker, path)?; let lock_path = observed.lock_path().to_owned(); + if let Some(allowed) = allowed_external_lock + && !is_legacy_sibling_lock(path, &lock_path)? + && absolute_lock_path(allowed)? != absolute_lock_path(&lock_path)? + { + return Err(format!( + "MCP install generation {} references an external lock outside its plugin layout", + path.display() + )); + } let file = open_marker_generation_lock(path, &lock_path)?; lock_exclusive_with_timeout(&file, path, timeout)?; let lock_id = if is_legacy_sibling_lock(path, &lock_path)? { diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/plugin_install/mod.rs index 5d6874bf3..f0e4c436c 100644 --- a/crates/cli/src/plugin_install/mod.rs +++ b/crates/cli/src/plugin_install/mod.rs @@ -667,6 +667,7 @@ fn install_host_locked( replacement_generation_lock = match acquire_replacement_generation_lock( host, &staged.layout.generation_fence, + &layout.generation_lock, staged.generation_lock_created, ) { Ok(lock) => Some(lock), @@ -760,6 +761,7 @@ fn install_host_locked( replacement_generation_lock = match acquire_replacement_generation_lock( host, &layout.generation_fence, + &layout.generation_lock, generation_lock_created, ) { Ok(lock) => Some(lock), @@ -986,13 +988,22 @@ fn uninstall_host_locked( ) -> Result<(), String> { let state = read_state(host, &options.install_dir); let layout = PluginLayout::new(host, &options.install_dir); + if let Some(state) = state.as_ref() { + layout.validate_persisted_state(state)?; + } let plugin_root = state .as_ref() .map(|state| state.plugin_root.as_path()) .unwrap_or(&layout.plugin_root); let local_install_exists = state.is_some() || layout.marketplace_root.exists(); - let mut generation_retirement = - retire_installed_generation(host, plugin_root, local_install_exists, options, runner)?; + let mut generation_retirement = retire_installed_generation( + host, + plugin_root, + &layout.generation_lock, + local_install_exists, + options, + runner, + )?; if let Some(retirement) = generation_retirement.as_mut() { retirement.invalidate_for_replacement().map_err(|error| { format!( @@ -1033,6 +1044,7 @@ fn uninstall_host_locked( fn retire_installed_generation( host: IntegrationHost, plugin_root: &Path, + expected_generation_lock: &Path, local_install_exists: bool, options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -1050,8 +1062,9 @@ fn retire_installed_generation( return Err(missing_generation_fence_error(host, &generation_fence)); } } - let retirement = GenerationRetirement::acquire(&generation_fence) - .map_err(|cause| invalid_generation_fence_error(host, &generation_fence, &cause))?; + let retirement = + GenerationRetirement::acquire_for_plugin(&generation_fence, expected_generation_lock) + .map_err(|cause| invalid_generation_fence_error(host, &generation_fence, &cause))?; if retirement.is_none() && !existing_install { let registration = host_registration_report(host, options, runner)?; existing_install = @@ -1092,9 +1105,12 @@ fn retire_replacement_before_rollback( } return Ok(None); } - let mut retirement = GenerationRetirement::acquire(&layout.generation_fence) - .map_err(|cause| invalid_generation_fence_error(host, &layout.generation_fence, &cause))? - .ok_or_else(|| missing_generation_fence_error(host, &layout.generation_fence))?; + let mut retirement = + GenerationRetirement::acquire_for_plugin(&layout.generation_fence, &layout.generation_lock) + .map_err(|cause| { + invalid_generation_fence_error(host, &layout.generation_fence, &cause) + })? + .ok_or_else(|| missing_generation_fence_error(host, &layout.generation_fence))?; retirement.invalidate_for_replacement().map_err(|error| { format!( "failed to retire replacement MCP generation {} before rollback: {error}", @@ -1187,16 +1203,15 @@ fn uninstall_host_with_setup_override( setup_runner: &dyn PluginSetupRunner, force_plugin_setup_uninstall: bool, ) -> Result<(), String> { - let state = read_state(host, &options.install_dir).unwrap_or_else(|| { - let layout = PluginLayout::new(host, &options.install_dir); - PluginState { - marketplace_root: layout.marketplace_root, - plugin_root: layout.plugin_root, - host_plugin_removed: false, - host_marketplace_removed: false, - plugin_setup_installed: true, - } + let layout = PluginLayout::new(host, &options.install_dir); + let state = read_state(host, &options.install_dir).unwrap_or_else(|| PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: layout.plugin_root.clone(), + host_plugin_removed: false, + host_marketplace_removed: false, + plugin_setup_installed: true, }); + layout.validate_persisted_state(&state)?; if let Err(error) = require_relay(options, runner) .and_then(|relay| validate_relay_hook_forward(&relay, options, runner)) { @@ -1658,9 +1673,10 @@ struct ReplacementGenerationLock { fn acquire_replacement_generation_lock( host: IntegrationHost, marker_path: &Path, + expected_generation_lock: &Path, remove_lock_if_unreferenced: bool, ) -> Result { - match GenerationRetirement::acquire(marker_path) { + match GenerationRetirement::acquire_for_plugin(marker_path, expected_generation_lock) { Ok(Some(retirement)) => Ok(ReplacementGenerationLock::new( retirement, remove_lock_if_unreferenced, @@ -1765,6 +1781,9 @@ fn prepare_plugin_install( runner: &dyn CommandRunner, ) -> Result { let persisted = read_state(host, &options.install_dir); + if let Some(state) = persisted.as_ref() { + layout.validate_persisted_state(state)?; + } let registration = host_registration_report(host, options, runner)?; let plugin_registered = registration.host_plugin_registered; let marketplace_registered = registration.host_marketplace_registered; @@ -1818,13 +1837,14 @@ fn prepare_plugin_install( } } else { Some( - GenerationRetirement::acquire(&previous_generation_fence) - .map_err(|cause| { - invalid_generation_fence_error(host, &previous_generation_fence, &cause) - })? - .ok_or_else(|| { - missing_generation_fence_error(host, &previous_generation_fence) - })?, + GenerationRetirement::acquire_for_plugin( + &previous_generation_fence, + &layout.generation_lock, + ) + .map_err(|cause| { + invalid_generation_fence_error(host, &previous_generation_fence, &cause) + })? + .ok_or_else(|| missing_generation_fence_error(host, &previous_generation_fence))?, ) } } else { diff --git a/crates/cli/src/plugin_install/state.rs b/crates/cli/src/plugin_install/state.rs index f1155501c..a3a075816 100644 --- a/crates/cli/src/plugin_install/state.rs +++ b/crates/cli/src/plugin_install/state.rs @@ -98,6 +98,44 @@ impl PluginLayout { state_path, } } + + pub(super) fn validate_persisted_state(&self, state: &PluginState) -> Result<(), String> { + if state.marketplace_root != self.marketplace_root || state.plugin_root != self.plugin_root + { + return Err(format!( + "refusing persisted {} plugin state outside the selected install layout {}", + self.host.label(), + self.state_path.display() + )); + } + if self.marketplace_root.exists() { + let install_dir = self.state_path.parent().ok_or_else(|| { + format!( + "plugin state path {} has no parent", + self.state_path.display() + ) + })?; + let canonical_install = install_dir.canonicalize().map_err(|error| { + format!( + "failed to validate install directory {}: {error}", + install_dir.display() + ) + })?; + let canonical_marketplace = self.marketplace_root.canonicalize().map_err(|error| { + format!( + "failed to validate marketplace root {}: {error}", + self.marketplace_root.display() + ) + })?; + if !canonical_marketplace.starts_with(&canonical_install) { + return Err(format!( + "refusing persisted {} marketplace root outside the selected install directory", + self.host.label() + )); + } + } + Ok(()) + } } #[derive(Debug, Clone)] diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs index 731605ad9..e8df67855 100644 --- a/crates/cli/src/sidecar.rs +++ b/crates/cli/src/sidecar.rs @@ -13,6 +13,7 @@ use std::fs; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -313,12 +314,10 @@ fn start_gateway(spec: &GatewaySpec, state: &Path) -> Result Result Result<(), String> { + hand_off_to_reaper_with( + child, + |slot| { + thread::Builder::new() + .name("nemo-relay-gateway-wait".into()) + .spawn(move || { + let mut slot = slot.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(mut child) = slot.take() { + let _ = child.wait(); + } + }) + .map(|_| ()) + }, + process::terminate_tree, + ) +} + +fn hand_off_to_reaper_with( + child: Child, + spawn: impl FnOnce(Arc>>) -> std::io::Result<()>, + terminate: impl FnOnce(&mut Child), +) -> Result<(), String> { + let slot = Arc::new(Mutex::new(Some(child))); + if let Err(error) = spawn(Arc::clone(&slot)) { + let mut slot = slot.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(mut child) = slot.take() { + terminate(&mut child); + } + return Err(format!("failed to start gateway reaper thread: {error}")); + } + Ok(()) +} + struct ArmedChild(Option); impl ArmedChild { diff --git a/crates/cli/tests/coverage/install_generation_tests.rs b/crates/cli/tests/coverage/install_generation_tests.rs index 3d736982b..4fd3c328d 100644 --- a/crates/cli/tests/coverage/install_generation_tests.rs +++ b/crates/cli/tests/coverage/install_generation_tests.rs @@ -8,6 +8,23 @@ use tempfile::tempdir; use super::*; +#[test] +fn plugin_retirement_rejects_an_external_lock_outside_its_layout() { + let dir = tempdir().unwrap(); + let marker = dir.path().join("plugin").join(GENERATION_FILE_NAME); + let expected_lock = dir.path().join("expected.lock"); + let unrelated_lock = dir.path().join("unrelated.lock"); + write_new_generation_with_token_at(&marker, &unrelated_lock).unwrap(); + + let error = match GenerationRetirement::acquire_for_plugin(&marker, &expected_lock) { + Err(error) => error, + Ok(_) => panic!("out-of-layout lock was accepted"), + }; + + assert!(error.contains("outside its plugin layout"), "{error}"); + assert!(unrelated_lock.exists()); +} + #[test] fn generation_markers_have_one_canonical_encoding() { let lock_path = PathBuf::from("generation.lock"); diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index f74d43626..d6de709fa 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -199,7 +199,8 @@ fn replacement_generation_guard_removes_an_owned_lock_after_marker_removal() { let marker = dir.path().join("generation-marker"); let lock = dir.path().join("generation.lock"); crate::install_generation::write_new_generation_with_token_at(&marker, &lock).unwrap(); - let guard = acquire_replacement_generation_lock(IntegrationHost::Codex, &marker, true).unwrap(); + let guard = + acquire_replacement_generation_lock(IntegrationHost::Codex, &marker, &lock, true).unwrap(); std::fs::remove_file(marker).unwrap(); drop(guard); @@ -213,7 +214,8 @@ fn replacement_generation_guard_retains_its_lock_when_marker_state_is_uncertain( let marker = dir.path().join("generation-marker"); let lock = dir.path().join("generation.lock"); crate::install_generation::write_new_generation_with_token_at(&marker, &lock).unwrap(); - let guard = acquire_replacement_generation_lock(IntegrationHost::Codex, &marker, true).unwrap(); + let guard = + acquire_replacement_generation_lock(IntegrationHost::Codex, &marker, &lock, true).unwrap(); std::fs::remove_file(&marker).unwrap(); std::fs::create_dir(&marker).unwrap(); @@ -2869,14 +2871,13 @@ fn ordinary_claude_reinstall_requires_force_for_a_fenced_plugin() { } #[test] -fn force_install_replaces_a_relocated_fenced_install_without_old_residue() { +fn force_install_rejects_persisted_roots_outside_selected_layout() { let dir = tempdir().unwrap(); let selected_dir = dir.path().join("selected"); let relocated_dir = dir.path().join("relocated"); let relocated = write_relocated_codex_install(&selected_dir, &relocated_dir); let sentinel = relocated.plugin_root.join("relocated-install"); std::fs::write(&sentinel, "preserve-until-commit").unwrap(); - let previous = InstallGeneration::capture(relocated.generation_fence.clone()).unwrap(); let runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex") @@ -2887,30 +2888,32 @@ fn force_install_replaces_a_relocated_fenced_install_without_old_residue() { ..options(&selected_dir) }; - install_host( + let error = install_host( IntegrationHost::Codex, &install_options, &runner, &setup_runner, ) - .unwrap(); + .unwrap_err(); let current = PluginLayout::new(IntegrationHost::Codex, &selected_dir); - assert!(current.marketplace_root.exists()); - assert!(current.generation_lock.exists()); - assert!(!relocated.marketplace_root.exists()); - assert!(!relocated.generation_lock.exists()); - assert!(!sentinel.exists()); - assert!(previous.verify_current().unwrap_err().contains("retired")); - let state = read_state(IntegrationHost::Codex, &selected_dir).unwrap(); - assert_eq!(state.marketplace_root, current.marketplace_root); - assert_eq!(state.plugin_root, current.plugin_root); + assert!( + error.contains("outside the selected install layout"), + "{error}" + ); + assert!(!current.marketplace_root.exists()); + assert!(relocated.marketplace_root.exists()); + assert!(relocated.generation_lock.exists()); + assert_eq!( + std::fs::read_to_string(&sentinel).unwrap(), + "preserve-until-commit" + ); assert_no_force_replacement_residue(&selected_dir); assert_no_force_replacement_residue(&relocated_dir); } #[test] -fn force_install_rollback_restores_a_relocated_fenced_install_and_registration() { +fn uninstall_rejects_persisted_roots_outside_selected_layout() { let dir = tempdir().unwrap(); let selected_dir = dir.path().join("selected"); let relocated_dir = dir.path().join("relocated"); @@ -2918,30 +2921,24 @@ fn force_install_rollback_restores_a_relocated_fenced_install_and_registration() let sentinel = relocated.plugin_root.join("relocated-install"); std::fs::write(&sentinel, "restore-exactly").unwrap(); let original_state = std::fs::read(state_path(IntegrationHost::Codex, &selected_dir)).unwrap(); - let previous = InstallGeneration::capture(relocated.generation_fence.clone()).unwrap(); let runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex") - .with_codex_registration_sequence(&[(true, true), (false, false), (false, false)]); - let setup_runner = MockSetupRunner { - failing_call: Some(format!("doctor codex {DEFAULT_GATEWAY_URL}")), - ..MockSetupRunner::default() - }; - let install_options = PluginInstallOptions { - force: true, - skip_doctor: false, - ..options(&selected_dir) - }; + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); - let error = install_host( + let error = uninstall_host( IntegrationHost::Codex, - &install_options, + &options(&selected_dir), &runner, &setup_runner, ) .unwrap_err(); - assert!(error.contains("doctor codex"), "{error}"); + assert!( + error.contains("outside the selected install layout"), + "{error}" + ); let current = PluginLayout::new(IntegrationHost::Codex, &selected_dir); assert!(!current.marketplace_root.exists()); assert!(!current.generation_lock.exists()); @@ -2951,29 +2948,12 @@ fn force_install_rollback_restores_a_relocated_fenced_install_and_registration() std::fs::read_to_string(&sentinel).unwrap(), "restore-exactly" ); - previous.verify_current().unwrap(); assert_eq!( std::fs::read(state_path(IntegrationHost::Codex, &selected_dir)).unwrap(), original_state ); - let marketplace_adds = runner - .commands() - .into_iter() - .filter(|command| command.contains("plugin marketplace add")) - .collect::>(); - assert_eq!( - marketplace_adds.last(), - Some(&format!( - "/bin/codex plugin marketplace add {}", - relocated.marketplace_root.display() - )) - ); - assert!( - setup_runner - .calls() - .iter() - .any(|call| call == "restore snapshot") - ); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); assert_no_force_replacement_residue(&selected_dir); assert_no_force_replacement_residue(&relocated_dir); } diff --git a/crates/cli/tests/coverage/sidecar_tests.rs b/crates/cli/tests/coverage/sidecar_tests.rs index c20b88ef0..74fcdca09 100644 --- a/crates/cli/tests/coverage/sidecar_tests.rs +++ b/crates/cli/tests/coverage/sidecar_tests.rs @@ -5,12 +5,37 @@ use super::*; use std::ffi::{OsStr, OsString}; use std::io::{Read, Write}; use std::net::TcpListener; +use std::process::Command; struct EnvScope { _guard: std::sync::MutexGuard<'static, ()>, previous: Vec<(&'static str, Option)>, } +#[test] +fn failed_reaper_spawn_terminates_and_reaps_the_retained_child() { + let child = Command::new(std::env::current_exe().unwrap()) + .arg("--list") + .stdout(Stdio::null()) + .spawn() + .unwrap(); + let terminated = std::sync::atomic::AtomicBool::new(false); + + let error = hand_off_to_reaper_with( + child, + |_| Err(std::io::Error::other("thread limit")), + |child| { + terminated.store(true, std::sync::atomic::Ordering::SeqCst); + let _ = child.kill(); + child.wait().unwrap(); + }, + ) + .unwrap_err(); + + assert!(terminated.load(std::sync::atomic::Ordering::SeqCst)); + assert!(error.contains("failed to start gateway reaper thread")); +} + impl EnvScope { fn set(values: &[(&'static str, Option<&OsStr>)]) -> Self { let guard = crate::test_support::ENV_TEST_LOCK From 6c3b25d77ba9061e9958c2f37e9341a0b151174c Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 19:51:27 -0400 Subject: [PATCH 017/102] refactor(cli): organize commands and agents Signed-off-by: Will Killian --- crates/cli/src/agent_process.rs | 2 +- .../{adapters/mod.rs => agents/adapters.rs} | 5 +- .../{alignment/mod.rs => agents/alignment.rs} | 5 +- .../claude/adapter.rs} | 2 +- .../claude/alignment.rs} | 4 +- .../claude.rs => agents/claude/host.rs} | 0 .../codex.rs => agents/codex/adapter.rs} | 4 +- .../codex.rs => agents/codex/alignment.rs} | 4 +- .../codex/app_server.rs} | 0 .../codex.rs => agents/codex/host.rs} | 0 .../hermes.rs => agents/hermes/adapter.rs} | 2 +- .../hermes.rs => agents/hermes/alignment.rs} | 2 +- crates/cli/src/{ => agents}/hermes/config.rs | 2 +- crates/cli/src/{ => agents}/hermes/files.rs | 0 .../hermes/integration.rs} | 20 +- crates/cli/src/agents/hermes/mod.rs | 9 + crates/cli/src/{ => agents}/hermes/trust.rs | 0 .../{plugin_host/mod.rs => agents/host.rs} | 6 +- .../install}/host.rs | 4 +- .../install}/marketplace.rs | 0 .../{plugin_install => agents/install}/mod.rs | 20 +- .../install}/operation_lock.rs | 0 .../install}/setup.rs | 34 +-- .../install}/state.rs | 0 .../src/{coding_agent.rs => agents/mod.rs} | 8 +- .../shared.rs => agents/shared/host.rs} | 0 crates/cli/src/banner.rs | 2 +- crates/cli/src/commands/completions.rs | 33 +++ crates/cli/src/commands/configuration.rs | 16 ++ crates/cli/src/commands/diagnostics.rs | 15 + crates/cli/src/commands/hook_forward.rs | 9 + crates/cli/src/commands/install.rs | 15 + crates/cli/src/commands/mcp.rs | 11 + crates/cli/src/commands/mod.rs | 192 +++++++++++++ crates/cli/src/commands/model_pricing.rs | 17 ++ crates/cli/src/commands/plugins.rs | 46 +++ crates/cli/src/commands/run.rs | 23 ++ crates/cli/src/completions_install.rs | 2 +- crates/cli/src/config.rs | 4 +- crates/cli/src/doctor.rs | 8 +- crates/cli/src/file_io.rs | 2 +- crates/cli/src/gateway.rs | 4 +- crates/cli/src/install_generation.rs | 2 +- crates/cli/src/installer.rs | 4 +- crates/cli/src/launcher.rs | 8 +- crates/cli/src/main.rs | 271 +----------------- crates/cli/src/mcp.rs | 2 +- crates/cli/src/mcp/gateway.rs | 2 +- crates/cli/src/model_pricing.rs | 2 +- crates/cli/src/plugins.rs | 2 +- crates/cli/src/plugins/lifecycle.rs | 2 +- crates/cli/src/plugins/lifecycle/target.rs | 2 +- crates/cli/src/plugins/schema.rs | 2 +- crates/cli/src/server.rs | 4 +- crates/cli/src/session.rs | 6 +- crates/cli/src/setup.rs | 2 +- crates/cli/src/sidecar.rs | 2 +- crates/cli/src/sidecar/health.rs | 2 +- crates/cli/src/sidecar/state.rs | 2 +- crates/cli/tests/architecture_tests.rs | 84 ++++++ .../coverage/{ => agents}/adapters_tests.rs | 2 +- .../alignment_claude_code_tests.rs | 0 .../{ => agents}/alignment_codex_tests.rs | 0 .../coverage/{ => agents}/alignment_tests.rs | 0 .../{ => agents}/coding_agent_tests.rs | 0 .../coverage/{ => agents}/hermes_tests.rs | 0 .../coverage/{ => agents}/launcher_tests.rs | 0 .../{ => agents}/plugin_host_tests.rs | 0 .../plugin_install_setup_tests.rs | 0 .../{ => agents}/plugin_install_tests.rs | 8 +- .../coverage/{ => commands}/main_tests.rs | 0 .../{ => commands}/model_pricing_tests.rs | 0 .../{ => shared}/agent_process_tests.rs | 0 .../coverage/{ => shared}/banner_tests.rs | 0 .../{ => shared}/completions_install_tests.rs | 0 .../coverage/{ => shared}/config_tests.rs | 0 .../coverage/{ => shared}/doctor_tests.rs | 4 +- .../coverage/{ => shared}/file_io_tests.rs | 0 .../coverage/{ => shared}/gateway_tests.rs | 2 +- .../coverage/{ => shared}/hook_assertions.rs | 0 .../{ => shared}/install_generation_tests.rs | 0 .../coverage/{ => shared}/installer_tests.rs | 0 .../{ => shared}/mcp_gateway_tests.rs | 0 .../tests/coverage/{ => shared}/mcp_tests.rs | 0 .../plugins_lifecycle_target_tests.rs | 0 .../{ => shared}/plugins_lifecycle_tests.rs | 0 .../{ => shared}/plugins_schema_tests.rs | 0 .../coverage/{ => shared}/plugins_tests.rs | 0 .../coverage/{ => shared}/server_tests.rs | 0 .../coverage/{ => shared}/session_tests.rs | 26 +- .../coverage/{ => shared}/setup_tests.rs | 0 .../{ => shared}/sidecar_health_tests.rs | 0 .../{ => shared}/sidecar_state_tests.rs | 0 .../coverage/{ => shared}/sidecar_tests.rs | 0 94 files changed, 602 insertions(+), 373 deletions(-) rename crates/cli/src/{adapters/mod.rs => agents/adapters.rs} (99%) rename crates/cli/src/{alignment/mod.rs => agents/alignment.rs} (99%) rename crates/cli/src/{adapters/claude_code.rs => agents/claude/adapter.rs} (98%) rename crates/cli/src/{alignment/claude_code.rs => agents/claude/alignment.rs} (97%) rename crates/cli/src/{plugin_host/claude.rs => agents/claude/host.rs} (100%) rename crates/cli/src/{adapters/codex.rs => agents/codex/adapter.rs} (92%) rename crates/cli/src/{alignment/codex.rs => agents/codex/alignment.rs} (99%) rename crates/cli/src/{plugin_host/codex_app_server.rs => agents/codex/app_server.rs} (100%) rename crates/cli/src/{plugin_host/codex.rs => agents/codex/host.rs} (100%) rename crates/cli/src/{adapters/hermes.rs => agents/hermes/adapter.rs} (99%) rename crates/cli/src/{alignment/hermes.rs => agents/hermes/alignment.rs} (98%) rename crates/cli/src/{ => agents}/hermes/config.rs (99%) rename crates/cli/src/{ => agents}/hermes/files.rs (100%) rename crates/cli/src/{hermes.rs => agents/hermes/integration.rs} (97%) create mode 100644 crates/cli/src/agents/hermes/mod.rs rename crates/cli/src/{ => agents}/hermes/trust.rs (100%) rename crates/cli/src/{plugin_host/mod.rs => agents/host.rs} (97%) rename crates/cli/src/{plugin_install => agents/install}/host.rs (99%) rename crates/cli/src/{plugin_install => agents/install}/marketplace.rs (100%) rename crates/cli/src/{plugin_install => agents/install}/mod.rs (99%) rename crates/cli/src/{plugin_install => agents/install}/operation_lock.rs (100%) rename crates/cli/src/{plugin_install => agents/install}/setup.rs (86%) rename crates/cli/src/{plugin_install => agents/install}/state.rs (100%) rename crates/cli/src/{coding_agent.rs => agents/mod.rs} (97%) rename crates/cli/src/{plugin_host/shared.rs => agents/shared/host.rs} (100%) create mode 100644 crates/cli/src/commands/completions.rs create mode 100644 crates/cli/src/commands/configuration.rs create mode 100644 crates/cli/src/commands/diagnostics.rs create mode 100644 crates/cli/src/commands/hook_forward.rs create mode 100644 crates/cli/src/commands/install.rs create mode 100644 crates/cli/src/commands/mcp.rs create mode 100644 crates/cli/src/commands/mod.rs create mode 100644 crates/cli/src/commands/model_pricing.rs create mode 100644 crates/cli/src/commands/plugins.rs create mode 100644 crates/cli/src/commands/run.rs create mode 100644 crates/cli/tests/architecture_tests.rs rename crates/cli/tests/coverage/{ => agents}/adapters_tests.rs (99%) rename crates/cli/tests/coverage/{ => agents}/alignment_claude_code_tests.rs (100%) rename crates/cli/tests/coverage/{ => agents}/alignment_codex_tests.rs (100%) rename crates/cli/tests/coverage/{ => agents}/alignment_tests.rs (100%) rename crates/cli/tests/coverage/{ => agents}/coding_agent_tests.rs (100%) rename crates/cli/tests/coverage/{ => agents}/hermes_tests.rs (100%) rename crates/cli/tests/coverage/{ => agents}/launcher_tests.rs (100%) rename crates/cli/tests/coverage/{ => agents}/plugin_host_tests.rs (100%) rename crates/cli/tests/coverage/{ => agents}/plugin_install_setup_tests.rs (100%) rename crates/cli/tests/coverage/{ => agents}/plugin_install_tests.rs (99%) rename crates/cli/tests/coverage/{ => commands}/main_tests.rs (100%) rename crates/cli/tests/coverage/{ => commands}/model_pricing_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/agent_process_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/banner_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/completions_install_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/config_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/doctor_tests.rs (99%) rename crates/cli/tests/coverage/{ => shared}/file_io_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/gateway_tests.rs (99%) rename crates/cli/tests/coverage/{ => shared}/hook_assertions.rs (100%) rename crates/cli/tests/coverage/{ => shared}/install_generation_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/installer_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/mcp_gateway_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/mcp_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/plugins_lifecycle_target_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/plugins_lifecycle_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/plugins_schema_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/plugins_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/server_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/session_tests.rs (99%) rename crates/cli/tests/coverage/{ => shared}/setup_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/sidecar_health_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/sidecar_state_tests.rs (100%) rename crates/cli/tests/coverage/{ => shared}/sidecar_tests.rs (100%) diff --git a/crates/cli/src/agent_process.rs b/crates/cli/src/agent_process.rs index 0c4b35036..27057b971 100644 --- a/crates/cli/src/agent_process.rs +++ b/crates/cli/src/agent_process.rs @@ -126,5 +126,5 @@ mod supervision; pub(crate) use supervision::SupervisedChild; #[cfg(test)] -#[path = "../tests/coverage/agent_process_tests.rs"] +#[path = "../tests/coverage/shared/agent_process_tests.rs"] mod tests; diff --git a/crates/cli/src/adapters/mod.rs b/crates/cli/src/agents/adapters.rs similarity index 99% rename from crates/cli/src/adapters/mod.rs rename to crates/cli/src/agents/adapters.rs index 2f8dcf76f..a4a84c3ce 100644 --- a/crates/cli/src/adapters/mod.rs +++ b/crates/cli/src/agents/adapters.rs @@ -1,8 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +#[path = "claude/adapter.rs"] pub(crate) mod claude_code; +#[path = "codex/adapter.rs"] pub(crate) mod codex; +#[path = "hermes/adapter.rs"] pub(crate) mod hermes; pub(crate) const SKILL_LOAD_SOURCE_KEY: &str = "skill_load_source"; @@ -982,5 +985,5 @@ fn normalize_name(name: &str) -> String { } #[cfg(test)] -#[path = "../../tests/coverage/adapters_tests.rs"] +#[path = "../../tests/coverage/agents/adapters_tests.rs"] mod tests; diff --git a/crates/cli/src/alignment/mod.rs b/crates/cli/src/agents/alignment.rs similarity index 99% rename from crates/cli/src/alignment/mod.rs rename to crates/cli/src/agents/alignment.rs index a12d57db6..19f9164e5 100644 --- a/crates/cli/src/alignment/mod.rs +++ b/crates/cli/src/agents/alignment.rs @@ -17,8 +17,11 @@ use crate::config::header_string; pub(crate) use crate::json_path::{string_at_any as json_string_at, value_at_any as json_value_at}; use crate::model::{AgentKind, LlmEvent, NormalizedEvent, SessionEvent, SubagentEvent, ToolEvent}; +#[path = "claude/alignment.rs"] pub(crate) mod claude_code; +#[path = "codex/alignment.rs"] pub(crate) mod codex; +#[path = "hermes/alignment.rs"] pub(crate) mod hermes; const REQUEST_AFFINITY_KEY_MIN_CHARS: usize = 24; @@ -1165,5 +1168,5 @@ pub(crate) fn merge_metadata(left: Value, right: Value) -> Value { } #[cfg(test)] -#[path = "../../tests/coverage/alignment_tests.rs"] +#[path = "../../tests/coverage/agents/alignment_tests.rs"] mod tests; diff --git a/crates/cli/src/adapters/claude_code.rs b/crates/cli/src/agents/claude/adapter.rs similarity index 98% rename from crates/cli/src/adapters/claude_code.rs rename to crates/cli/src/agents/claude/adapter.rs index 1492b2290..273d5b168 100644 --- a/crates/cli/src/adapters/claude_code.rs +++ b/crates/cli/src/agents/claude/adapter.rs @@ -4,7 +4,7 @@ use axum::http::HeaderMap; use serde_json::{Value, json}; -use crate::adapters::{ +use crate::agents::adapters::{ AdapterOutcome, CLAUDE_CODE_PAYLOAD_EXTRACTOR, ClassificationRules, classify, }; use crate::model::{AgentKind, NormalizedEvent}; diff --git a/crates/cli/src/alignment/claude_code.rs b/crates/cli/src/agents/claude/alignment.rs similarity index 97% rename from crates/cli/src/alignment/claude_code.rs rename to crates/cli/src/agents/claude/alignment.rs index 50880790e..945106318 100644 --- a/crates/cli/src/alignment/claude_code.rs +++ b/crates/cli/src/agents/claude/alignment.rs @@ -11,7 +11,7 @@ use axum::http::HeaderMap; use nemo_relay::api::llm::LlmRequest; use serde_json::Value; -use crate::alignment::json_string_at; +use crate::agents::alignment::json_string_at; use crate::config::header_string; use crate::model::{AgentKind, ToolEvent}; @@ -109,5 +109,5 @@ fn has_terminal_agent_tool_evidence(result: &serde_json::Value) -> bool { } #[cfg(test)] -#[path = "../../tests/coverage/alignment_claude_code_tests.rs"] +#[path = "../../../tests/coverage/agents/alignment_claude_code_tests.rs"] mod tests; diff --git a/crates/cli/src/plugin_host/claude.rs b/crates/cli/src/agents/claude/host.rs similarity index 100% rename from crates/cli/src/plugin_host/claude.rs rename to crates/cli/src/agents/claude/host.rs diff --git a/crates/cli/src/adapters/codex.rs b/crates/cli/src/agents/codex/adapter.rs similarity index 92% rename from crates/cli/src/adapters/codex.rs rename to crates/cli/src/agents/codex/adapter.rs index 4cbde84b4..04454164f 100644 --- a/crates/cli/src/adapters/codex.rs +++ b/crates/cli/src/agents/codex/adapter.rs @@ -4,7 +4,9 @@ use axum::http::HeaderMap; use serde_json::{Value, json}; -use crate::adapters::{AdapterOutcome, CODEX_PAYLOAD_EXTRACTOR, ClassificationRules, classify}; +use crate::agents::adapters::{ + AdapterOutcome, CODEX_PAYLOAD_EXTRACTOR, ClassificationRules, classify, +}; use crate::model::AgentKind; /// Normalizes Codex hook payloads while leaving Codex hook control flow untouched. diff --git a/crates/cli/src/alignment/codex.rs b/crates/cli/src/agents/codex/alignment.rs similarity index 99% rename from crates/cli/src/alignment/codex.rs rename to crates/cli/src/agents/codex/alignment.rs index ee10461da..e1695e0e0 100644 --- a/crates/cli/src/alignment/codex.rs +++ b/crates/cli/src/agents/codex/alignment.rs @@ -13,7 +13,7 @@ use std::io::{BufRead, BufReader}; use axum::http::HeaderMap; use serde_json::{Map, Value, json}; -use crate::alignment::{ +use crate::agents::alignment::{ GatewayRouteKind, SessionAlias, insert_optional, json_string_at, merge_metadata, }; use crate::model::{AgentKind, SessionEvent, SubagentEvent}; @@ -342,5 +342,5 @@ pub(crate) fn llm_owner_metadata(scope_metadata: Option<&Value>) -> Value { } #[cfg(test)] -#[path = "../../tests/coverage/alignment_codex_tests.rs"] +#[path = "../../../tests/coverage/agents/alignment_codex_tests.rs"] mod tests; diff --git a/crates/cli/src/plugin_host/codex_app_server.rs b/crates/cli/src/agents/codex/app_server.rs similarity index 100% rename from crates/cli/src/plugin_host/codex_app_server.rs rename to crates/cli/src/agents/codex/app_server.rs diff --git a/crates/cli/src/plugin_host/codex.rs b/crates/cli/src/agents/codex/host.rs similarity index 100% rename from crates/cli/src/plugin_host/codex.rs rename to crates/cli/src/agents/codex/host.rs diff --git a/crates/cli/src/adapters/hermes.rs b/crates/cli/src/agents/hermes/adapter.rs similarity index 99% rename from crates/cli/src/adapters/hermes.rs rename to crates/cli/src/agents/hermes/adapter.rs index 582107c99..ccc5821fa 100644 --- a/crates/cli/src/adapters/hermes.rs +++ b/crates/cli/src/agents/hermes/adapter.rs @@ -4,7 +4,7 @@ use axum::http::HeaderMap; use serde_json::{Map, Value, json}; -use crate::adapters::{ +use crate::agents::adapters::{ AdapterOutcome, ClassificationRules, HERMES_PAYLOAD_EXTRACTOR, classify, common_session_event, event_name, metadata, normalize_name, session_id, }; diff --git a/crates/cli/src/alignment/hermes.rs b/crates/cli/src/agents/hermes/alignment.rs similarity index 98% rename from crates/cli/src/alignment/hermes.rs rename to crates/cli/src/agents/hermes/alignment.rs index 5aecc558c..c61b1d74a 100644 --- a/crates/cli/src/alignment/hermes.rs +++ b/crates/cli/src/agents/hermes/alignment.rs @@ -10,7 +10,7 @@ use serde_json::{Map, Value, json}; -use crate::alignment::{SessionAlias, insert_optional, json_string_at, merge_metadata}; +use crate::agents::alignment::{SessionAlias, insert_optional, json_string_at, merge_metadata}; use crate::model::{AgentKind, SessionEvent, SubagentEvent}; #[derive(Debug, Clone)] diff --git a/crates/cli/src/hermes/config.rs b/crates/cli/src/agents/hermes/config.rs similarity index 99% rename from crates/cli/src/hermes/config.rs rename to crates/cli/src/agents/hermes/config.rs index 8a89d07e1..a91d3847f 100644 --- a/crates/cli/src/hermes/config.rs +++ b/crates/cli/src/agents/hermes/config.rs @@ -270,7 +270,7 @@ fn legacy_owned_command(root: &Value, relay: &Path) -> Result, Cl fn legacy_command_uses_relay(command: &str, relay: &Path) -> bool { let relay = relay.to_string_lossy(); - let quoted = crate::plugin_host::shell_quote_arg_for_platform(&relay, cfg!(windows)); + let quoted = crate::agents::host::shell_quote_arg_for_platform(&relay, cfg!(windows)); [relay.as_ref(), quoted.as_str()].into_iter().any(|prefix| { command.strip_prefix(prefix).is_some_and(|arguments| { [" hook-forward hermes", " plugin-shim hook hermes"] diff --git a/crates/cli/src/hermes/files.rs b/crates/cli/src/agents/hermes/files.rs similarity index 100% rename from crates/cli/src/hermes/files.rs rename to crates/cli/src/agents/hermes/files.rs diff --git a/crates/cli/src/hermes.rs b/crates/cli/src/agents/hermes/integration.rs similarity index 97% rename from crates/cli/src/hermes.rs rename to crates/cli/src/agents/hermes/integration.rs index 0c6b17158..a300ef318 100644 --- a/crates/cli/src/hermes.rs +++ b/crates/cli/src/agents/hermes/integration.rs @@ -3,10 +3,6 @@ //! Hermes-owned MCP and lifecycle-hook configuration. -mod config; -mod files; -mod trust; - use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -15,17 +11,18 @@ use std::time::SystemTime; use serde_json::{Map, Value, json}; #[cfg(test)] -use self::config::persistent_hook_command_for_platform; -use self::config::{ +use super::config::persistent_hook_command_for_platform; +use super::config::{ MCP_SERVER_NAME, expected_mcp_server, forwarded_environment_names, owned_install_command, parse_yaml_object, persistent_config, relay_is_executable, remove_owned_mcp, strip_owned_hooks, user_config_path_with_override, yaml_bytes, }; -use self::files::{ +pub(crate) use super::config::{persistent_hook_command, transparent_config}; +use super::files::{ FileSnapshot, INSTALL_LOCK_TIMEOUT, PersistentPaths, acquire_allowlist_lock, acquire_install_lock, read_optional_utf8, remove_optional_file, replace_optional_file, }; -use self::trust::{json_bytes, parse_json_object, trusted_hooks, verify_trust}; +use super::trust::{json_bytes, parse_json_object, trusted_hooks, verify_trust}; use crate::config::CodingAgent; use crate::error::CliError; use crate::file_io::atomic_write; @@ -35,7 +32,6 @@ use crate::install_generation::{ GENERATION_FILE_ENV, GENERATION_TOKEN_ENV, GenerationRetirement, InstallGeneration, }; use crate::sidecar::DEFAULT_BIND; -pub(crate) use config::{persistent_hook_command, transparent_config}; /// Hermes host configuration is user-owned even when Relay itself uses project configuration. /// Project-specific Relay behavior remains available through transparent `nemo-relay run`. @@ -45,7 +41,7 @@ pub(crate) fn user_config_path(default_home: &Path) -> PathBuf { pub(crate) fn install_persistent(config: &Path, relay: &Path) -> Result, CliError> { let relay = relay.canonicalize().unwrap_or_else(|_| relay.to_path_buf()); - let relay = crate::plugin_host::portable_executable_path(relay); + let relay = crate::agents::host::portable_executable_path(relay); if !relay_is_executable(&relay) { return Err(CliError::Install(format!( "nemo-relay executable is missing or not executable at {}", @@ -108,7 +104,7 @@ fn retire_generation_before_gateway_stop( .invalidate_for_replacement() .map_err(CliError::Install)?; } - if let Err(error) = crate::plugin_host::stop_plugin_gateway() { + if let Err(error) = crate::agents::host::stop_plugin_gateway() { if let Some(retirement) = retirement.as_mut() && let Err(restore_error) = retirement.restore_after_rollback() { @@ -567,5 +563,5 @@ fn verify_uninstall(paths: &PersistentPaths, owned_command: Option<&str>) -> Res } #[cfg(test)] -#[path = "../tests/coverage/hermes_tests.rs"] +#[path = "../../../tests/coverage/agents/hermes_tests.rs"] mod tests; diff --git a/crates/cli/src/agents/hermes/mod.rs b/crates/cli/src/agents/hermes/mod.rs new file mode 100644 index 000000000..527f37b8c --- /dev/null +++ b/crates/cli/src/agents/hermes/mod.rs @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +mod config; +mod files; +mod integration; +mod trust; + +pub(crate) use integration::*; diff --git a/crates/cli/src/hermes/trust.rs b/crates/cli/src/agents/hermes/trust.rs similarity index 100% rename from crates/cli/src/hermes/trust.rs rename to crates/cli/src/agents/hermes/trust.rs diff --git a/crates/cli/src/plugin_host/mod.rs b/crates/cli/src/agents/host.rs similarity index 97% rename from crates/cli/src/plugin_host/mod.rs rename to crates/cli/src/agents/host.rs index f196c37f8..c6ba3d994 100644 --- a/crates/cli/src/plugin_host/mod.rs +++ b/crates/cli/src/agents/host.rs @@ -3,9 +3,13 @@ //! Transactional host configuration for packaged coding-agent plugins. +#[path = "claude/host.rs"] mod claude; +#[path = "codex/host.rs"] mod codex; +#[path = "codex/app_server.rs"] mod codex_app_server; +#[path = "shared/host.rs"] mod shared; pub(crate) use claude::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; @@ -218,5 +222,5 @@ use codex_app_server::*; use shared::*; #[cfg(test)] -#[path = "../../tests/coverage/plugin_host_tests.rs"] +#[path = "../../tests/coverage/agents/plugin_host_tests.rs"] mod tests; diff --git a/crates/cli/src/plugin_install/host.rs b/crates/cli/src/agents/install/host.rs similarity index 99% rename from crates/cli/src/plugin_install/host.rs rename to crates/cli/src/agents/install/host.rs index 1b527e994..10c3f4bcd 100644 --- a/crates/cli/src/plugin_install/host.rs +++ b/crates/cli/src/agents/install/host.rs @@ -298,7 +298,7 @@ pub(super) fn require_relay( runner .current_executable() .map(|path| path.canonicalize().unwrap_or(path)) - .map(crate::plugin_host::portable_executable_path) + .map(crate::agents::host::portable_executable_path) } pub(super) fn validate_relay_hook_forward( @@ -449,7 +449,7 @@ pub(super) fn format_command(program: &str, args: &[String]) -> String { "$ {}", parts .iter() - .map(|part| { crate::plugin_host::shell_quote_arg_for_platform(part, cfg!(windows)) }) + .map(|part| { crate::agents::host::shell_quote_arg_for_platform(part, cfg!(windows)) }) .collect::>() .join(" ") ) diff --git a/crates/cli/src/plugin_install/marketplace.rs b/crates/cli/src/agents/install/marketplace.rs similarity index 100% rename from crates/cli/src/plugin_install/marketplace.rs rename to crates/cli/src/agents/install/marketplace.rs diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/agents/install/mod.rs similarity index 99% rename from crates/cli/src/plugin_install/mod.rs rename to crates/cli/src/agents/install/mod.rs index f0e4c436c..35476caa2 100644 --- a/crates/cli/src/plugin_install/mod.rs +++ b/crates/cli/src/agents/install/mod.rs @@ -131,7 +131,8 @@ pub(crate) fn collect_default_host_plugin_readiness() -> Vec>(); - if hermes_config_path().is_ok_and(|path| crate::hermes::persistent_state_exists(&path)) { + if hermes_config_path().is_ok_and(|path| crate::agents::hermes::persistent_state_exists(&path)) + { hosts.push(IntegrationHost::Hermes); } let pending = hosts @@ -418,7 +419,7 @@ fn select_hosts( .is_some(), HostSelectionMode::InstalledState => match candidate { IntegrationHost::Hermes => hermes_config_path() - .is_ok_and(|path| crate::hermes::persistent_state_exists(&path)), + .is_ok_and(|path| crate::agents::hermes::persistent_state_exists(&path)), _ => state_path(candidate, &options.install_dir).exists(), }, }; @@ -433,7 +434,7 @@ fn hermes_config_path() -> Result { std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .map(PathBuf::from) - .map(|home| crate::hermes::user_config_path(&home)) + .map(|home| crate::agents::hermes::user_config_path(&home)) .ok_or_else(|| "cannot determine home directory (set HOME or USERPROFILE)".into()) } @@ -451,9 +452,10 @@ fn install_hermes_host( println!("configure Hermes MCP and hooks at {}", config.display()); return Ok(()); } - crate::hermes::install_persistent(&config, &relay).map_err(|error| error.to_string())?; + crate::agents::hermes::install_persistent(&config, &relay) + .map_err(|error| error.to_string())?; if !options.skip_doctor { - crate::hermes::diagnose_persistent(&config)?; + crate::agents::hermes::diagnose_persistent(&config)?; } println!("installed Hermes integration"); Ok(()) @@ -468,7 +470,7 @@ fn uninstall_hermes_host(options: &PluginInstallOptions) -> Result<(), String> { ); return Ok(()); } - crate::hermes::uninstall_persistent(&config).map_err(|error| error.to_string())?; + crate::agents::hermes::uninstall_persistent(&config).map_err(|error| error.to_string())?; println!("uninstalled Hermes integration"); Ok(()) } @@ -558,7 +560,7 @@ fn collect_hermes_host_readiness( version.map(|_| format!("{} is installed", CodingAgent::Hermes.version_requirement())), ); - let relay = crate::hermes::configured_relay_executable(config); + let relay = crate::agents::hermes::configured_relay_executable(config); readiness.push( "Configured Relay binary", relay @@ -589,7 +591,7 @@ fn collect_hermes_host_readiness( readiness.push( "Hermes MCP, hooks, and trust", - crate::hermes::diagnose_persistent(config), + crate::agents::hermes::diagnose_persistent(config), ); readiness } @@ -2381,5 +2383,5 @@ use setup::setup_action_description; use state::*; #[cfg(test)] -#[path = "../../tests/coverage/plugin_install_tests.rs"] +#[path = "../../../tests/coverage/agents/plugin_install_tests.rs"] mod tests; diff --git a/crates/cli/src/plugin_install/operation_lock.rs b/crates/cli/src/agents/install/operation_lock.rs similarity index 100% rename from crates/cli/src/plugin_install/operation_lock.rs rename to crates/cli/src/agents/install/operation_lock.rs diff --git a/crates/cli/src/plugin_install/setup.rs b/crates/cli/src/agents/install/setup.rs similarity index 86% rename from crates/cli/src/plugin_install/setup.rs rename to crates/cli/src/agents/install/setup.rs index 8e5544396..461686f27 100644 --- a/crates/cli/src/plugin_install/setup.rs +++ b/crates/cli/src/agents/install/setup.rs @@ -3,8 +3,8 @@ //! Host setup, restore, and doctor delegation. +use crate::agents::host; use crate::config::{CodingAgent, IntegrationHost}; -use crate::plugin_host; use serde_json::Value; use std::path::Path; @@ -165,8 +165,8 @@ pub(super) trait PluginSetupRunner { pub(super) struct RealPluginSetupRunner; pub(super) enum PluginSetupSnapshot { - Codex(plugin_host::CodexSetupSnapshot), - Claude(plugin_host::ClaudeSetupSnapshot), + Codex(host::CodexSetupSnapshot), + Claude(host::ClaudeSetupSnapshot), #[cfg(test)] Mock, } @@ -174,10 +174,10 @@ pub(super) enum PluginSetupSnapshot { impl PluginSetupRunner for RealPluginSetupRunner { fn snapshot(&self, host: IntegrationHost) -> Result, String> { match host { - IntegrationHost::Codex => plugin_host::snapshot_codex_setup() + IntegrationHost::Codex => host::snapshot_codex_setup() .map(PluginSetupSnapshot::Codex) .map(Some), - IntegrationHost::ClaudeCode => plugin_host::snapshot_claude_setup() + IntegrationHost::ClaudeCode => host::snapshot_claude_setup() .map(PluginSetupSnapshot::Claude) .map(Some), IntegrationHost::Hermes | IntegrationHost::All => { @@ -188,15 +188,15 @@ impl PluginSetupRunner for RealPluginSetupRunner { fn restore_snapshot(&self, snapshot: &PluginSetupSnapshot) -> Result<(), String> { match snapshot { - PluginSetupSnapshot::Codex(snapshot) => plugin_host::restore_codex_setup(snapshot), - PluginSetupSnapshot::Claude(snapshot) => plugin_host::restore_claude_setup(snapshot), + PluginSetupSnapshot::Codex(snapshot) => host::restore_codex_setup(snapshot), + PluginSetupSnapshot::Claude(snapshot) => host::restore_claude_setup(snapshot), #[cfg(test)] PluginSetupSnapshot::Mock => Ok(()), } } fn refresh_gateway(&self) -> Result<(), String> { - plugin_host::stop_plugin_gateway() + host::stop_plugin_gateway() } fn setup( @@ -216,12 +216,12 @@ impl PluginSetupRunner for RealPluginSetupRunner { generation_token: Option<&str>, ) -> Result<(), String> { match host { - IntegrationHost::Codex => plugin_host::install_codex_plugin_with_generation( + IntegrationHost::Codex => host::install_codex_plugin_with_generation( gateway_url, plugin_root, generation_token, ), - IntegrationHost::ClaudeCode => plugin_host::enable_claude_provider(gateway_url), + IntegrationHost::ClaudeCode => host::enable_claude_provider(gateway_url), IntegrationHost::Hermes | IntegrationHost::All => { unreachable!("all is expanded before plugin setup") } @@ -235,8 +235,8 @@ impl PluginSetupRunner for RealPluginSetupRunner { plugin_root: &Path, ) -> Result<(), String> { match host { - IntegrationHost::Codex => plugin_host::uninstall_codex_plugin(gateway_url, plugin_root), - IntegrationHost::ClaudeCode => plugin_host::restore_claude_provider(gateway_url), + IntegrationHost::Codex => host::uninstall_codex_plugin(gateway_url, plugin_root), + IntegrationHost::ClaudeCode => host::restore_claude_provider(gateway_url), IntegrationHost::Hermes | IntegrationHost::All => { unreachable!("all is expanded before plugin uninstall") } @@ -260,14 +260,14 @@ impl PluginSetupRunner for RealPluginSetupRunner { generation_token: Option<&str>, ) -> Result<(), String> { match host { - IntegrationHost::Codex => plugin_host::doctor_plugin_with_generation( + IntegrationHost::Codex => host::doctor_plugin_with_generation( CodingAgent::Codex, gateway_url, plugin_root, generation_token, ), IntegrationHost::ClaudeCode => { - plugin_host::doctor_plugin(CodingAgent::ClaudeCode, gateway_url, plugin_root) + host::doctor_plugin(CodingAgent::ClaudeCode, gateway_url, plugin_root) } IntegrationHost::Hermes | IntegrationHost::All => { unreachable!("all is expanded before plugin doctor") @@ -283,10 +283,10 @@ impl PluginSetupRunner for RealPluginSetupRunner { ) -> Result { match host { IntegrationHost::Codex => { - plugin_host::doctor_plugin_json(CodingAgent::Codex, gateway_url, plugin_root) + host::doctor_plugin_json(CodingAgent::Codex, gateway_url, plugin_root) } IntegrationHost::ClaudeCode => { - plugin_host::doctor_plugin_json(CodingAgent::ClaudeCode, gateway_url, plugin_root) + host::doctor_plugin_json(CodingAgent::ClaudeCode, gateway_url, plugin_root) } IntegrationHost::Hermes | IntegrationHost::All => { unreachable!("all is expanded before plugin doctor") @@ -296,5 +296,5 @@ impl PluginSetupRunner for RealPluginSetupRunner { } #[cfg(test)] -#[path = "../../tests/coverage/plugin_install_setup_tests.rs"] +#[path = "../../../tests/coverage/agents/plugin_install_setup_tests.rs"] mod tests; diff --git a/crates/cli/src/plugin_install/state.rs b/crates/cli/src/agents/install/state.rs similarity index 100% rename from crates/cli/src/plugin_install/state.rs rename to crates/cli/src/agents/install/state.rs diff --git a/crates/cli/src/coding_agent.rs b/crates/cli/src/agents/mod.rs similarity index 97% rename from crates/cli/src/coding_agent.rs rename to crates/cli/src/agents/mod.rs index 90fdc473b..96b26f68e 100644 --- a/crates/cli/src/coding_agent.rs +++ b/crates/cli/src/agents/mod.rs @@ -3,6 +3,12 @@ //! Canonical coding-agent identity and compatibility policy. +pub(crate) mod adapters; +pub(crate) mod alignment; +pub(crate) mod hermes; +pub(crate) mod host; +pub(crate) mod install; + use clap::ValueEnum; use semver::Version; @@ -245,5 +251,5 @@ impl CodingAgent { } #[cfg(test)] -#[path = "../tests/coverage/coding_agent_tests.rs"] +#[path = "../../tests/coverage/agents/coding_agent_tests.rs"] mod tests; diff --git a/crates/cli/src/plugin_host/shared.rs b/crates/cli/src/agents/shared/host.rs similarity index 100% rename from crates/cli/src/plugin_host/shared.rs rename to crates/cli/src/agents/shared/host.rs diff --git a/crates/cli/src/banner.rs b/crates/cli/src/banner.rs index 7b290df5d..5d26350b6 100644 --- a/crates/cli/src/banner.rs +++ b/crates/cli/src/banner.rs @@ -312,5 +312,5 @@ fn print_plain_header() { } #[cfg(test)] -#[path = "../tests/coverage/banner_tests.rs"] +#[path = "../tests/coverage/shared/banner_tests.rs"] mod tests; diff --git a/crates/cli/src/commands/completions.rs b/crates/cli/src/commands/completions.rs new file mode 100644 index 000000000..2806ce756 --- /dev/null +++ b/crates/cli/src/commands/completions.rs @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +use clap::CommandFactory; + +use crate::config::{Cli, CompletionsCommand}; +use crate::error::CliError; + +pub(super) fn execute(command: CompletionsCommand) -> Result { + if command.install { + let path = crate::completions_install::install(command.shell)?; + println!("✓ Installed completions: {}", path.display()); + } else { + generate_to(command.shell, &mut std::io::stdout())?; + } + Ok(ExitCode::SUCCESS) +} + +pub(super) fn generate_to( + shell: Option, + writer: &mut dyn std::io::Write, +) -> Result<(), CliError> { + let shell = shell.ok_or_else(|| { + CliError::Config( + "missing shell argument; pass a shell name (bash, zsh, fish, ...) or use `--install` to auto-detect from $SHELL".into(), + ) + })?; + let mut command = Cli::command(); + clap_complete::generate(shell, &mut command, "nemo-relay", writer); + Ok(()) +} diff --git a/crates/cli/src/commands/configuration.rs b/crates/cli/src/commands/configuration.rs new file mode 100644 index 000000000..37e3a6890 --- /dev/null +++ b/crates/cli/src/commands/configuration.rs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +use crate::config::ConfigCommand; +use crate::error::CliError; + +pub(super) async fn execute(command: ConfigCommand) -> Result { + if command.reset { + crate::setup::reset(command.agent)?; + } else { + crate::setup::run(command.agent).await?; + } + Ok(ExitCode::SUCCESS) +} diff --git a/crates/cli/src/commands/diagnostics.rs b/crates/cli/src/commands/diagnostics.rs new file mode 100644 index 000000000..d722998f5 --- /dev/null +++ b/crates/cli/src/commands/diagnostics.rs @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +use crate::config::DoctorCommand; +use crate::error::CliError; + +pub(super) async fn execute(command: DoctorCommand) -> Result { + if let Some(plugin) = command.plugin { + crate::agents::install::doctor(plugin, command.install_dir, command.json) + } else { + crate::doctor::run_doctor(command.agent, command.json).await + } +} diff --git a/crates/cli/src/commands/hook_forward.rs b/crates/cli/src/commands/hook_forward.rs new file mode 100644 index 000000000..6c70fd305 --- /dev/null +++ b/crates/cli/src/commands/hook_forward.rs @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use crate::config::HookForwardCommand; +use crate::error::CliError; + +pub(super) async fn execute(command: HookForwardCommand) -> Result<(), CliError> { + crate::installer::hook_forward(command).await +} diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs new file mode 100644 index 000000000..c71306e5d --- /dev/null +++ b/crates/cli/src/commands/install.rs @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +use crate::config::{InstallCommand, UninstallCommand}; +use crate::error::CliError; + +pub(super) fn install(command: InstallCommand) -> Result { + crate::agents::install::install(command) +} + +pub(super) fn uninstall(command: UninstallCommand) -> Result { + crate::agents::install::uninstall(command) +} diff --git a/crates/cli/src/commands/mcp.rs b/crates/cli/src/commands/mcp.rs new file mode 100644 index 000000000..3ea88792d --- /dev/null +++ b/crates/cli/src/commands/mcp.rs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +use crate::config::ServerArgs; +use crate::error::CliError; + +pub(super) async fn execute(server: &ServerArgs) -> Result { + crate::mcp::run(server).await +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs new file mode 100644 index 000000000..4dbca28f4 --- /dev/null +++ b/crates/cli/src/commands/mod.rs @@ -0,0 +1,192 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Command parsing, dispatch, rendering, and exit-code ownership. + +mod completions; +mod configuration; +mod diagnostics; +mod hook_forward; +mod install; +mod mcp; +mod model_pricing; +mod plugins; +mod run; + +use std::process::ExitCode; + +use clap::Parser; + +use crate::config::{Cli, CodingAgent, Command, ServerArgs}; +#[cfg(test)] +use crate::config::{CompletionsCommand, PluginsCommand, PricingCommand}; +use crate::{config, doctor, error, server, setup}; + +// Runs the async CLI entrypoint and converts any surfaced gateway error into a non-zero process +// exit. Errors are printed once here so subcommands can return structured errors without also +// owning process-level reporting. +pub(crate) async fn run() -> ExitCode { + match dispatch().await { + Ok(code) => code, + Err(error) => { + let exit_code = if error.guardrail_rejection_reason().is_some() { + ExitCode::from(2) + } else { + ExitCode::FAILURE + }; + eprintln!("{error}"); + exit_code + } + } +} + +// Dispatches CLI subcommands while keeping the no-subcommand path as server mode. `run` inherits +// top-level server flags so transparent launch can share config parsing with daemon startup. +async fn dispatch() -> Result { + let cli = Cli::parse(); + match cli.command { + Some(command) => run_command(command, &cli.server).await, + None => run_default(&cli.server).await, + } +} + +async fn run_command(command: Command, server: &ServerArgs) -> Result { + match command { + Command::HookForward(command) => { + hook_forward::execute(command).await?; + Ok(ExitCode::SUCCESS) + } + Command::Install(command) => install::install(command), + Command::Uninstall(command) => install::uninstall(command), + Command::Run(command) => run::execute(command, server).await, + Command::Claude(command) => run::easy_path(CodingAgent::ClaudeCode, command, server).await, + Command::Codex(command) => run::easy_path(CodingAgent::Codex, command, server).await, + Command::Hermes(command) => run::easy_path(CodingAgent::Hermes, command, server).await, + Command::Mcp => mcp::execute(server).await, + Command::Config(command) => configuration::execute(command).await, + Command::Plugins(command) => plugins::execute(command, server), + Command::ModelPricing(command) => model_pricing::execute(command), + Command::Doctor(command) => diagnostics::execute(command).await, + Command::Agents(command) => doctor::run_agents(command.json).await, + Command::Completions(command) => completions::execute(command), + } +} + +#[cfg(test)] +fn generate_completions_to( + shell: Option, + writer: &mut dyn std::io::Write, +) -> Result<(), error::CliError> { + completions::generate_to(shell, writer) +} + +async fn run_default(server_args: &ServerArgs) -> Result { + // Bare `nemo-relay` with no subcommand: + // - If the user passed any daemon-specific flag (`--bind`, upstream URLs, ATIF dir, + // OpenInference endpoint), they obviously want the long-running gateway daemon — + // keep that path so existing scripts that explicitly invoke daemon mode stay + // compatible. + // - Otherwise — no flags, no subcommand — use the first-run path only when no config + // exists. Once configured, bare `nemo-relay` becomes a quick health check; explicit + // `nemo-relay config` remains the reconfiguration path. + if server_args.requested_daemon_mode() { + let resolved = config::resolve_server_config(server_args)?; + let dynamic_plugins = crate::plugins::lifecycle::active_dynamic_plugin_components( + server_args.config.as_ref(), + &resolved, + )?; + let managed_bootstrap = + config::managed_bootstrap_identity(server_args, &resolved, &dynamic_plugins)?; + server::serve_with_dynamic( + resolved.gateway, + dynamic_plugins, + managed_bootstrap, + server_args.ready_file.as_deref(), + ) + .await?; + Ok(ExitCode::SUCCESS) + } else if config::any_config_file_exists() { + doctor::run_doctor(None, false).await + } else { + setup::run(None).await?; + Ok(ExitCode::SUCCESS) + } +} + +#[cfg(test)] +fn run_completions(command: CompletionsCommand) -> Result { + completions::execute(command) +} + +#[cfg(test)] +fn run_plugins(command: PluginsCommand, server: &ServerArgs) -> Result { + plugins::execute(command, server) +} + +#[cfg(test)] +fn run_pricing(command: PricingCommand) -> Result { + model_pricing::execute(command) +} + +#[cfg(test)] +pub(crate) mod test_support { + #[must_use] + pub(crate) struct CwdTestScope { + _guard: std::sync::MutexGuard<'static, ()>, + prev: Option, + } + + impl CwdTestScope { + pub(crate) fn locked() -> Self { + Self { + _guard: lock_cwd(), + prev: None, + } + } + + pub(crate) fn enter(path: &std::path::Path) -> Self { + let guard = lock_cwd(); + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(path).unwrap(); + Self { + _guard: guard, + prev: Some(prev), + } + } + } + + impl Drop for CwdTestScope { + fn drop(&mut self) { + if let Some(prev) = &self.prev + && let Err(error) = std::env::set_current_dir(prev) + { + CWD_RESTORE_FAILED.store(true, std::sync::atomic::Ordering::SeqCst); + if std::thread::panicking() { + eprintln!("failed to restore current_dir to {prev:?}: {error}"); + } else { + panic!("failed to restore current_dir to {prev:?}: {error}"); + } + } + } + } + + pub(crate) static CWD_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + static CWD_RESTORE_FAILED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + pub(crate) static PLUGIN_CONFIG_TEST_LOCK: tokio::sync::Mutex<()> = + tokio::sync::Mutex::const_new(()); + + fn lock_cwd() -> std::sync::MutexGuard<'static, ()> { + let guard = CWD_TEST_LOCK.lock().expect("CWD_TEST_LOCK poisoned"); + assert!( + !CWD_RESTORE_FAILED.load(std::sync::atomic::Ordering::SeqCst), + "current_dir restore failed in a previous test; aborting to prevent cross-test contamination", + ); + guard + } +} + +#[cfg(test)] +#[path = "../../tests/coverage/commands/main_tests.rs"] +mod tests; diff --git a/crates/cli/src/commands/model_pricing.rs b/crates/cli/src/commands/model_pricing.rs new file mode 100644 index 000000000..1f9a827ec --- /dev/null +++ b/crates/cli/src/commands/model_pricing.rs @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +use crate::config::{PricingCommand, PricingSubcommand}; +use crate::error::CliError; + +pub(super) fn execute(command: PricingCommand) -> Result { + match command.command { + PricingSubcommand::Validate(command) => crate::model_pricing::validate(command)?, + PricingSubcommand::Init(command) => crate::model_pricing::init(command)?, + PricingSubcommand::AddSource(command) => crate::model_pricing::add_source(command)?, + PricingSubcommand::Resolve(command) => crate::model_pricing::resolve(command)?, + } + Ok(ExitCode::SUCCESS) +} diff --git a/crates/cli/src/commands/plugins.rs b/crates/cli/src/commands/plugins.rs new file mode 100644 index 000000000..6436b29bc --- /dev/null +++ b/crates/cli/src/commands/plugins.rs @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +use crate::config::{PluginsCommand, PluginsSubcommand, ServerArgs}; +use crate::error::CliError; + +pub(super) fn execute(command: PluginsCommand, server: &ServerArgs) -> Result { + let json_context = command + .command + .json_context() + .map(|context| (context.command, context.target.map(str::to_owned))); + let json = json_context.is_some(); + let result = match command.command { + PluginsSubcommand::Edit(command) => crate::plugins::edit(command), + PluginsSubcommand::Add(command) => crate::plugins::lifecycle::add(command, server), + PluginsSubcommand::Validate(command) => { + crate::plugins::lifecycle::validate(command, server) + } + PluginsSubcommand::List(command) => crate::plugins::lifecycle::list(command, server), + PluginsSubcommand::Inspect(command) => crate::plugins::lifecycle::inspect(command, server), + PluginsSubcommand::Enable(command) => crate::plugins::lifecycle::enable(command, server), + PluginsSubcommand::Disable(command) => crate::plugins::lifecycle::disable(command, server), + PluginsSubcommand::Remove(command) => crate::plugins::lifecycle::remove(command, server), + }; + match result { + Ok(()) => Ok(ExitCode::SUCCESS), + Err(error) => { + if let Some(exit_code) = crate::plugins::lifecycle::render_plugin_error(&error, json)? { + Ok(exit_code) + } else if json { + let (command, target) = json_context + .as_ref() + .expect("json plugin command context should exist when enabled"); + crate::plugins::lifecycle::render_generic_plugin_json_error( + command, + target.as_deref(), + &error.to_string(), + ) + } else { + Err(error) + } + } + } +} diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs new file mode 100644 index 000000000..03c9db0c0 --- /dev/null +++ b/crates/cli/src/commands/run.rs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +use crate::agents::CodingAgent; +use crate::config::{EasyPathCommand, RunCommand, ServerArgs}; +use crate::error::CliError; + +pub(super) async fn execute( + command: RunCommand, + server: &ServerArgs, +) -> Result { + crate::launcher::run(command, Some(server)).await +} + +pub(super) async fn easy_path( + agent: CodingAgent, + command: EasyPathCommand, + server: &ServerArgs, +) -> Result { + crate::launcher::easy_path(agent, command, Some(server)).await +} diff --git a/crates/cli/src/completions_install.rs b/crates/cli/src/completions_install.rs index 8c0ef63de..296f9d1a9 100644 --- a/crates/cli/src/completions_install.rs +++ b/crates/cli/src/completions_install.rs @@ -112,5 +112,5 @@ fn write_atomic(target: &Path, bytes: &[u8]) -> Result<(), CliError> { } #[cfg(test)] -#[path = "../tests/coverage/completions_install_tests.rs"] +#[path = "../tests/coverage/shared/completions_install_tests.rs"] mod tests; diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 3dfb6cc42..2f2d080e8 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use strum::{Display, IntoStaticStr}; -pub(crate) use crate::coding_agent::CodingAgent; +pub(crate) use crate::agents::CodingAgent; use crate::error::CliError; use crate::file_io::{LockAttempt, try_lock_exclusive, try_lock_shared}; #[cfg(test)] @@ -2285,5 +2285,5 @@ impl GatewayMode { } #[cfg(test)] -#[path = "../tests/coverage/config_tests.rs"] +#[path = "../tests/coverage/shared/config_tests.rs"] mod tests; diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index 5455d83be..a629dc9db 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -65,7 +65,7 @@ pub(crate) struct DoctorReport { pub environment: EnvironmentInfo, pub configuration: ConfigurationInfo, pub agents: Vec, - pub host_plugins: Vec, + pub host_plugins: Vec, pub observability: Vec, pub completions: Vec, } @@ -177,7 +177,7 @@ pub(crate) async fn collect_report( &plugin_diagnostics, ), agents: collect_agents(target_agent, &resolved).await, - host_plugins: crate::plugin_install::collect_default_host_plugin_readiness(), + host_plugins: crate::agents::install::collect_default_host_plugin_readiness(), observability: collect_observability(&resolved.gateway).await, completions: collect_completions(home.as_deref()), }) @@ -520,7 +520,7 @@ fn hook_status(agent: CodingAgent, agents: &AgentConfigs) -> (Status, String) { (Status::Pass, "hooks: injected during run".into()) } CodingAgent::Hermes => match agents.hermes.hooks_path.as_deref() { - Some(path) => match crate::hermes::diagnose_persistent(path) { + Some(path) => match crate::agents::hermes::diagnose_persistent(path) { Ok(details) => (Status::Pass, details), Err(error) => ( Status::Fail, @@ -1592,5 +1592,5 @@ const _: fn() = || { }; #[cfg(test)] -#[path = "../tests/coverage/doctor_tests.rs"] +#[path = "../tests/coverage/shared/doctor_tests.rs"] mod tests; diff --git a/crates/cli/src/file_io.rs b/crates/cli/src/file_io.rs index c4b9b6733..922acf428 100644 --- a/crates/cli/src/file_io.rs +++ b/crates/cli/src/file_io.rs @@ -586,5 +586,5 @@ fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { } #[cfg(test)] -#[path = "../tests/coverage/file_io_tests.rs"] +#[path = "../tests/coverage/shared/file_io_tests.rs"] mod tests; diff --git a/crates/cli/src/gateway.rs b/crates/cli/src/gateway.rs index f315ecb82..ca1436f24 100644 --- a/crates/cli/src/gateway.rs +++ b/crates/cli/src/gateway.rs @@ -26,7 +26,7 @@ use nemo_relay::codec::traits::LlmResponseCodec; use nemo_relay::error::FlowError; use serde_json::{Map, Value, json}; -use crate::alignment::{self, GatewayRouteKind}; +use crate::agents::alignment::{self, GatewayRouteKind}; use crate::config::{BOOTSTRAP_CLIENT_TOKEN_HEADER, header_string}; use crate::error::CliError; use crate::server::AppState; @@ -1282,5 +1282,5 @@ fn is_hop_by_hop(name: &HeaderName) -> bool { } #[cfg(test)] -#[path = "../tests/coverage/gateway_tests.rs"] +#[path = "../tests/coverage/shared/gateway_tests.rs"] mod tests; diff --git a/crates/cli/src/install_generation.rs b/crates/cli/src/install_generation.rs index 1e55a54bf..56c1ec612 100644 --- a/crates/cli/src/install_generation.rs +++ b/crates/cli/src/install_generation.rs @@ -1205,5 +1205,5 @@ fn retired_generation_error(path: &Path) -> String { } #[cfg(test)] -#[path = "../tests/coverage/install_generation_tests.rs"] +#[path = "../tests/coverage/shared/install_generation_tests.rs"] mod tests; diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 818d6584c..270ebe4a9 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -553,7 +553,7 @@ fn hook_command_for_platform(relay: &Path, arguments: &[String], windows: bool) fn posix_hook_command(relay: &Path, arguments: &[String]) -> String { std::iter::once(relay.display().to_string()) .chain(arguments.iter().cloned()) - .map(|argument| crate::plugin_host::shell_quote_arg_for_platform(&argument, false)) + .map(|argument| crate::agents::host::shell_quote_arg_for_platform(&argument, false)) .collect::>() .join(" ") } @@ -896,5 +896,5 @@ fn insert_header( } #[cfg(test)] -#[path = "../tests/coverage/installer_tests.rs"] +#[path = "../tests/coverage/shared/installer_tests.rs"] mod tests; diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index 3c33356b2..41e4d2cc7 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -1183,7 +1183,7 @@ fn push_status_border( fn transparent_hook_executable() -> PathBuf { std::env::current_exe() .map(|path| path.canonicalize().unwrap_or(path)) - .map(crate::plugin_host::portable_executable_path) + .map(crate::agents::host::portable_executable_path) .unwrap_or_else(|_| PathBuf::from("nemo-relay")) } @@ -1290,9 +1290,9 @@ fn populate_hermes_overlay( }; let relay = std::env::current_exe() .map(|path| path.canonicalize().unwrap_or(path)) - .map(crate::plugin_host::portable_executable_path) + .map(crate::agents::host::portable_executable_path) .unwrap_or_else(|_| PathBuf::from("nemo-relay")); - let contents = crate::hermes::transparent_config(&existing, &relay, gateway_url)?; + let contents = crate::agents::hermes::transparent_config(&existing, &relay, gateway_url)?; std::fs::write(overlay.join("config.yaml"), contents)?; Ok(()) } @@ -1420,5 +1420,5 @@ fn private_temp_dir(parent: &Path, prefix: &str) -> Result { } #[cfg(test)] -#[path = "../tests/coverage/launcher_tests.rs"] +#[path = "../tests/coverage/agents/launcher_tests.rs"] mod tests; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 342026a4c..a9b001abe 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -3,18 +3,16 @@ //! NeMo Relay coding-agent gateway CLI. -mod adapters; mod agent_process; -mod alignment; +mod agents; mod banner; -mod coding_agent; +mod commands; mod completions_install; mod config; mod doctor; mod error; mod file_io; mod gateway; -mod hermes; mod install_generation; mod installer; mod json_path; @@ -23,8 +21,6 @@ mod mcp; mod mcp_environment; mod model; mod model_pricing; -mod plugin_host; -mod plugin_install; mod plugins; mod server; mod session; @@ -32,22 +28,15 @@ mod setup; mod sidecar; #[cfg(test)] -#[path = "../tests/coverage/hook_assertions.rs"] +#[path = "../tests/coverage/shared/hook_assertions.rs"] mod hook_assertions; -use std::process::ExitCode; - -use clap::Parser; +#[cfg(test)] +pub(crate) use commands::test_support; -use crate::config::{ - Cli, CodingAgent, Command, CompletionsCommand, ConfigCommand, DoctorCommand, PluginsCommand, - PluginsSubcommand, PricingCommand, PricingSubcommand, ServerArgs, -}; +use std::process::ExitCode; fn main() -> ExitCode { - // Managed MCP hosts can preserve an unresolved `${NAME}` placeholder. Remove those values - // before clap reads environment-backed numeric and socket options, and before Tokio creates - // worker threads that would make process-environment mutation unsafe. mcp_environment::remove_unresolved_mcp_placeholders(); let runtime = match tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -59,251 +48,5 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - runtime.block_on(async_main()) -} - -// Runs the async CLI entrypoint and converts any surfaced gateway error into a non-zero process -// exit. Errors are printed once here so subcommands can return structured errors without also -// owning process-level reporting. -async fn async_main() -> ExitCode { - match run().await { - Ok(code) => code, - Err(error) => { - let exit_code = if error.guardrail_rejection_reason().is_some() { - ExitCode::from(2) - } else { - ExitCode::FAILURE - }; - eprintln!("{error}"); - exit_code - } - } -} - -// Dispatches CLI subcommands while keeping the no-subcommand path as server mode. `run` inherits -// top-level server flags so transparent launch can share config parsing with daemon startup. -async fn run() -> Result { - let cli = Cli::parse(); - match cli.command { - Some(command) => run_command(command, &cli.server).await, - None => run_default(&cli.server).await, - } -} - -async fn run_command(command: Command, server: &ServerArgs) -> Result { - match command { - Command::HookForward(command) => { - installer::hook_forward(command).await?; - Ok(ExitCode::SUCCESS) - } - Command::Install(command) => plugin_install::install(command), - Command::Uninstall(command) => plugin_install::uninstall(command), - Command::Run(command) => launcher::run(command, Some(server)).await, - Command::Claude(command) => { - launcher::easy_path(CodingAgent::ClaudeCode, command, Some(server)).await - } - Command::Codex(command) => { - launcher::easy_path(CodingAgent::Codex, command, Some(server)).await - } - Command::Hermes(command) => { - launcher::easy_path(CodingAgent::Hermes, command, Some(server)).await - } - Command::Mcp => mcp::run(server).await, - Command::Config(command) => run_config(command).await, - Command::Plugins(command) => run_plugins(command, server), - Command::ModelPricing(command) => run_pricing(command), - Command::Doctor(command) => run_doctor(command).await, - Command::Agents(command) => doctor::run_agents(command.json).await, - Command::Completions(command) => run_completions(command), - } -} - -async fn run_config(command: ConfigCommand) -> Result { - if command.reset { - setup::reset(command.agent)?; - } else { - setup::run(command.agent).await?; - } - Ok(ExitCode::SUCCESS) -} - -fn run_plugins(command: PluginsCommand, server: &ServerArgs) -> Result { - let json_context = command - .command - .json_context() - .map(|context| (context.command, context.target.map(str::to_owned))); - let json = json_context.is_some(); - let result = match command.command { - PluginsSubcommand::Edit(command) => plugins::edit(command), - PluginsSubcommand::Add(command) => plugins::lifecycle::add(command, server), - PluginsSubcommand::Validate(command) => plugins::lifecycle::validate(command, server), - PluginsSubcommand::List(command) => plugins::lifecycle::list(command, server), - PluginsSubcommand::Inspect(command) => plugins::lifecycle::inspect(command, server), - PluginsSubcommand::Enable(command) => plugins::lifecycle::enable(command, server), - PluginsSubcommand::Disable(command) => plugins::lifecycle::disable(command, server), - PluginsSubcommand::Remove(command) => plugins::lifecycle::remove(command, server), - }; - match result { - Ok(()) => Ok(ExitCode::SUCCESS), - Err(error) => { - if let Some(exit_code) = plugins::lifecycle::render_plugin_error(&error, json)? { - Ok(exit_code) - } else if json { - let (json_command, json_target) = json_context - .as_ref() - .expect("json plugin command context should exist when json output is enabled"); - plugins::lifecycle::render_generic_plugin_json_error( - json_command, - json_target.as_deref(), - &error.to_string(), - ) - } else { - Err(error) - } - } - } -} - -fn run_pricing(command: PricingCommand) -> Result { - match command.command { - PricingSubcommand::Validate(command) => model_pricing::validate(command)?, - PricingSubcommand::Init(command) => model_pricing::init(command)?, - PricingSubcommand::AddSource(command) => model_pricing::add_source(command)?, - PricingSubcommand::Resolve(command) => model_pricing::resolve(command)?, - } - Ok(ExitCode::SUCCESS) -} - -async fn run_doctor(command: DoctorCommand) -> Result { - if let Some(plugin) = command.plugin { - plugin_install::doctor(plugin, command.install_dir, command.json) - } else { - doctor::run_doctor(command.agent, command.json).await - } -} - -fn run_completions(command: CompletionsCommand) -> Result { - if command.install { - let path = completions_install::install(command.shell)?; - println!("✓ Installed completions: {}", path.display()); - } else { - generate_completions(command.shell)?; - } - Ok(ExitCode::SUCCESS) -} - -fn generate_completions(shell: Option) -> Result<(), error::CliError> { - generate_completions_to(shell, &mut std::io::stdout()) -} - -fn generate_completions_to( - shell: Option, - writer: &mut dyn std::io::Write, -) -> Result<(), error::CliError> { - let shell = shell.ok_or_else(|| { - error::CliError::Config( - "missing shell argument; pass a shell name (bash, zsh, fish, ...) or \ - use `--install` to auto-detect from $SHELL" - .into(), - ) - })?; - let mut clap_command = ::command(); - clap_complete::generate(shell, &mut clap_command, "nemo-relay", writer); - Ok(()) -} - -async fn run_default(server_args: &ServerArgs) -> Result { - // Bare `nemo-relay` with no subcommand: - // - If the user passed any daemon-specific flag (`--bind`, upstream URLs, ATIF dir, - // OpenInference endpoint), they obviously want the long-running gateway daemon — - // keep that path so existing scripts that explicitly invoke daemon mode stay - // compatible. - // - Otherwise — no flags, no subcommand — use the first-run path only when no config - // exists. Once configured, bare `nemo-relay` becomes a quick health check; explicit - // `nemo-relay config` remains the reconfiguration path. - if server_args.requested_daemon_mode() { - let resolved = config::resolve_server_config(server_args)?; - let dynamic_plugins = plugins::lifecycle::active_dynamic_plugin_components( - server_args.config.as_ref(), - &resolved, - )?; - let managed_bootstrap = - config::managed_bootstrap_identity(server_args, &resolved, &dynamic_plugins)?; - server::serve_with_dynamic( - resolved.gateway, - dynamic_plugins, - managed_bootstrap, - server_args.ready_file.as_deref(), - ) - .await?; - Ok(ExitCode::SUCCESS) - } else if config::any_config_file_exists() { - doctor::run_doctor(None, false).await - } else { - setup::run(None).await?; - Ok(ExitCode::SUCCESS) - } -} - -#[cfg(test)] -mod test_support { - #[must_use] - pub(crate) struct CwdTestScope { - _guard: std::sync::MutexGuard<'static, ()>, - prev: Option, - } - - impl CwdTestScope { - pub(crate) fn locked() -> Self { - Self { - _guard: lock_cwd(), - prev: None, - } - } - - pub(crate) fn enter(path: &std::path::Path) -> Self { - let guard = lock_cwd(); - let prev = std::env::current_dir().unwrap(); - std::env::set_current_dir(path).unwrap(); - Self { - _guard: guard, - prev: Some(prev), - } - } - } - - impl Drop for CwdTestScope { - fn drop(&mut self) { - if let Some(prev) = &self.prev - && let Err(error) = std::env::set_current_dir(prev) - { - CWD_RESTORE_FAILED.store(true, std::sync::atomic::Ordering::SeqCst); - if std::thread::panicking() { - eprintln!("failed to restore current_dir to {prev:?}: {error}"); - } else { - panic!("failed to restore current_dir to {prev:?}: {error}"); - } - } - } - } - - pub(crate) static CWD_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - static CWD_RESTORE_FAILED: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - pub(crate) static PLUGIN_CONFIG_TEST_LOCK: tokio::sync::Mutex<()> = - tokio::sync::Mutex::const_new(()); - - fn lock_cwd() -> std::sync::MutexGuard<'static, ()> { - let guard = CWD_TEST_LOCK.lock().expect("CWD_TEST_LOCK poisoned"); - assert!( - !CWD_RESTORE_FAILED.load(std::sync::atomic::Ordering::SeqCst), - "current_dir restore failed in a previous test; aborting to prevent cross-test contamination", - ); - guard - } + runtime.block_on(commands::run()) } - -#[cfg(test)] -#[path = "../tests/coverage/main_tests.rs"] -mod tests; diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs index f63add12e..1fba5447a 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -105,5 +105,5 @@ use session::serve_stdio; use transport::{MAX_MCP_FRAME_BYTES, read_bounded_frame}; #[cfg(test)] -#[path = "../tests/coverage/mcp_tests.rs"] +#[path = "../tests/coverage/shared/mcp_tests.rs"] mod tests; diff --git a/crates/cli/src/mcp/gateway.rs b/crates/cli/src/mcp/gateway.rs index d95073978..654e7dc26 100644 --- a/crates/cli/src/mcp/gateway.rs +++ b/crates/cli/src/mcp/gateway.rs @@ -389,5 +389,5 @@ impl RecoveryState { } #[cfg(test)] -#[path = "../../tests/coverage/mcp_gateway_tests.rs"] +#[path = "../../tests/coverage/shared/mcp_gateway_tests.rs"] mod tests; diff --git a/crates/cli/src/model_pricing.rs b/crates/cli/src/model_pricing.rs index d088337a5..87ea8a9d6 100644 --- a/crates/cli/src/model_pricing.rs +++ b/crates/cli/src/model_pricing.rs @@ -285,5 +285,5 @@ fn plural<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str { } #[cfg(test)] -#[path = "../tests/coverage/model_pricing_tests.rs"] +#[path = "../tests/coverage/commands/model_pricing_tests.rs"] mod tests; diff --git a/crates/cli/src/plugins.rs b/crates/cli/src/plugins.rs index be3676c5c..d7c25a088 100644 --- a/crates/cli/src/plugins.rs +++ b/crates/cli/src/plugins.rs @@ -1378,5 +1378,5 @@ fn editor_error(err: dialoguer::Error) -> CliError { } #[cfg(test)] -#[path = "../tests/coverage/plugins_tests.rs"] +#[path = "../tests/coverage/shared/plugins_tests.rs"] mod tests; diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index fb554f69e..27240f8f8 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -2621,5 +2621,5 @@ pub(super) fn inspect_compat_data(record: &DynamicPluginRecord) -> Value { } #[cfg(test)] -#[path = "../../tests/coverage/plugins_lifecycle_tests.rs"] +#[path = "../../tests/coverage/shared/plugins_lifecycle_tests.rs"] mod tests; diff --git a/crates/cli/src/plugins/lifecycle/target.rs b/crates/cli/src/plugins/lifecycle/target.rs index a423bb328..c0f865c06 100644 --- a/crates/cli/src/plugins/lifecycle/target.rs +++ b/crates/cli/src/plugins/lifecycle/target.rs @@ -51,5 +51,5 @@ fn should_treat_target_as_path(target: &str) -> bool { || target.contains('\\') } #[cfg(test)] -#[path = "../../../tests/coverage/plugins_lifecycle_target_tests.rs"] +#[path = "../../../tests/coverage/shared/plugins_lifecycle_target_tests.rs"] mod tests; diff --git a/crates/cli/src/plugins/schema.rs b/crates/cli/src/plugins/schema.rs index 865ebcc84..faaad0171 100644 --- a/crates/cli/src/plugins/schema.rs +++ b/crates/cli/src/plugins/schema.rs @@ -1948,5 +1948,5 @@ fn escape_pointer(segment: &str) -> String { } #[cfg(test)] -#[path = "../../tests/coverage/plugins_schema_tests.rs"] +#[path = "../../tests/coverage/shared/plugins_schema_tests.rs"] mod tests; diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index abbb051e6..e33a7a46d 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -29,7 +29,7 @@ use subtle::ConstantTimeEq; use tokio::net::TcpListener; use tokio::sync::oneshot; -use crate::adapters::{claude_code, codex, hermes}; +use crate::agents::adapters::{claude_code, codex, hermes}; use crate::config::{ BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey, GatewayConfig, ManagedBootstrapIdentity, }; @@ -829,5 +829,5 @@ fn hook_payload_rejection(rejection: JsonRejection) -> CliError { } #[cfg(test)] -#[path = "../tests/coverage/server_tests.rs"] +#[path = "../tests/coverage/shared/server_tests.rs"] mod tests; diff --git a/crates/cli/src/session.rs b/crates/cli/src/session.rs index ea58c7064..35909df3b 100644 --- a/crates/cli/src/session.rs +++ b/crates/cli/src/session.rs @@ -23,8 +23,8 @@ use nemo_relay::api::tool::{ use serde_json::{Map, Value, json}; use tokio::sync::Mutex; -use crate::adapters::{SKILL_LOAD_SOURCE_KEY, SKILL_LOAD_SOURCE_PROMPT_EXPANSION}; -use crate::alignment::{ +use crate::agents::adapters::{SKILL_LOAD_SOURCE_KEY, SKILL_LOAD_SOURCE_PROMPT_EXPANSION}; +use crate::agents::alignment::{ self, GatewayManagementPolicy, PendingSubagentStart, SessionAlias, SessionAlignmentState, insert_optional, json_string_at, json_value_at, merge_metadata, }; @@ -2603,5 +2603,5 @@ fn gateway_session_for_call( } #[cfg(test)] -#[path = "../tests/coverage/session_tests.rs"] +#[path = "../tests/coverage/shared/session_tests.rs"] mod tests; diff --git a/crates/cli/src/setup.rs b/crates/cli/src/setup.rs index 6d734cd2a..410b01920 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/setup.rs @@ -326,5 +326,5 @@ fn setup_error(err: dialoguer::Error) -> CliError { } #[cfg(test)] -#[path = "../tests/coverage/setup_tests.rs"] +#[path = "../tests/coverage/shared/setup_tests.rs"] mod tests; diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs index e8df67855..4ce0c303b 100644 --- a/crates/cli/src/sidecar.rs +++ b/crates/cli/src/sidecar.rs @@ -531,5 +531,5 @@ pub(crate) fn plugin_heartbeat_interval() -> Result { } #[cfg(test)] -#[path = "../tests/coverage/sidecar_tests.rs"] +#[path = "../tests/coverage/shared/sidecar_tests.rs"] mod tests; diff --git a/crates/cli/src/sidecar/health.rs b/crates/cli/src/sidecar/health.rs index 7e96303ac..87bbc2b44 100644 --- a/crates/cli/src/sidecar/health.rs +++ b/crates/cli/src/sidecar/health.rs @@ -517,5 +517,5 @@ pub(crate) fn loopback_authority(host: &str, port: u16) -> String { } #[cfg(test)] -#[path = "../../tests/coverage/sidecar_health_tests.rs"] +#[path = "../../tests/coverage/shared/sidecar_health_tests.rs"] mod tests; diff --git a/crates/cli/src/sidecar/state.rs b/crates/cli/src/sidecar/state.rs index 6bb2f652c..e781a4530 100644 --- a/crates/cli/src/sidecar/state.rs +++ b/crates/cli/src/sidecar/state.rs @@ -323,5 +323,5 @@ pub(crate) fn lock_name(url: &str) -> String { } #[cfg(test)] -#[path = "../../tests/coverage/sidecar_state_tests.rs"] +#[path = "../../tests/coverage/shared/sidecar_state_tests.rs"] mod tests; diff --git a/crates/cli/tests/architecture_tests.rs b/crates/cli/tests/architecture_tests.rs new file mode 100644 index 000000000..641f8e179 --- /dev/null +++ b/crates/cli/tests/architecture_tests.rs @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Architectural dependency and source-layout regression tests. + +use std::fs; +use std::path::{Path, PathBuf}; + +fn source_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("src") +} + +fn rust_files(root: &Path) -> Vec { + let mut files = Vec::new(); + let mut pending = vec![root.to_owned()]; + while let Some(path) = pending.pop() { + for entry in fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + pending.push(path); + } else if path.extension().and_then(|value| value.to_str()) == Some("rs") { + files.push(path); + } + } + } + files +} + +#[test] +fn retired_top_level_agent_modules_do_not_return() { + let src = source_root(); + for path in [ + "adapters", + "alignment", + "plugin_host", + "plugin_install", + "hermes.rs", + "coding_agent.rs", + ] { + assert!(!src.join(path).exists(), "retired module returned: {path}"); + } +} + +#[test] +fn shared_services_do_not_depend_on_commands() { + let src = source_root(); + for path in rust_files(&src) { + if path.starts_with(src.join("commands")) || path == src.join("main.rs") { + continue; + } + let source = fs::read_to_string(&path).unwrap(); + assert!( + !source.contains("crate::commands"), + "shared module depends on command layer: {}", + path.display() + ); + } +} + +#[test] +fn agent_directories_do_not_import_one_another_or_commands() { + let agents = source_root().join("agents"); + for (agent, forbidden) in [ + ("codex", ["agents::claude", "agents::hermes"]), + ("claude", ["agents::codex", "agents::hermes"]), + ("hermes", ["agents::codex", "agents::claude"]), + ] { + for path in rust_files(&agents.join(agent)) { + let source = fs::read_to_string(&path).unwrap(); + assert!( + !source.contains("crate::commands"), + "{} imports commands", + path.display() + ); + for module in forbidden { + assert!( + !source.contains(module), + "{} imports {module}", + path.display() + ); + } + } + } +} diff --git a/crates/cli/tests/coverage/adapters_tests.rs b/crates/cli/tests/coverage/agents/adapters_tests.rs similarity index 99% rename from crates/cli/tests/coverage/adapters_tests.rs rename to crates/cli/tests/coverage/agents/adapters_tests.rs index 5a1087d7d..f5381d770 100644 --- a/crates/cli/tests/coverage/adapters_tests.rs +++ b/crates/cli/tests/coverage/agents/adapters_tests.rs @@ -5,7 +5,7 @@ use axum::http::HeaderMap; use serde_json::json; use super::*; -use crate::adapters::{claude_code, codex, hermes}; +use crate::agents::adapters::{claude_code, codex, hermes}; #[test] fn maps_claude_canonical_tool_payload() { diff --git a/crates/cli/tests/coverage/alignment_claude_code_tests.rs b/crates/cli/tests/coverage/agents/alignment_claude_code_tests.rs similarity index 100% rename from crates/cli/tests/coverage/alignment_claude_code_tests.rs rename to crates/cli/tests/coverage/agents/alignment_claude_code_tests.rs diff --git a/crates/cli/tests/coverage/alignment_codex_tests.rs b/crates/cli/tests/coverage/agents/alignment_codex_tests.rs similarity index 100% rename from crates/cli/tests/coverage/alignment_codex_tests.rs rename to crates/cli/tests/coverage/agents/alignment_codex_tests.rs diff --git a/crates/cli/tests/coverage/alignment_tests.rs b/crates/cli/tests/coverage/agents/alignment_tests.rs similarity index 100% rename from crates/cli/tests/coverage/alignment_tests.rs rename to crates/cli/tests/coverage/agents/alignment_tests.rs diff --git a/crates/cli/tests/coverage/coding_agent_tests.rs b/crates/cli/tests/coverage/agents/coding_agent_tests.rs similarity index 100% rename from crates/cli/tests/coverage/coding_agent_tests.rs rename to crates/cli/tests/coverage/agents/coding_agent_tests.rs diff --git a/crates/cli/tests/coverage/hermes_tests.rs b/crates/cli/tests/coverage/agents/hermes_tests.rs similarity index 100% rename from crates/cli/tests/coverage/hermes_tests.rs rename to crates/cli/tests/coverage/agents/hermes_tests.rs diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs similarity index 100% rename from crates/cli/tests/coverage/launcher_tests.rs rename to crates/cli/tests/coverage/agents/launcher_tests.rs diff --git a/crates/cli/tests/coverage/plugin_host_tests.rs b/crates/cli/tests/coverage/agents/plugin_host_tests.rs similarity index 100% rename from crates/cli/tests/coverage/plugin_host_tests.rs rename to crates/cli/tests/coverage/agents/plugin_host_tests.rs diff --git a/crates/cli/tests/coverage/plugin_install_setup_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_setup_tests.rs similarity index 100% rename from crates/cli/tests/coverage/plugin_install_setup_tests.rs rename to crates/cli/tests/coverage/agents/plugin_install_setup_tests.rs diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs similarity index 99% rename from crates/cli/tests/coverage/plugin_install_tests.rs rename to crates/cli/tests/coverage/agents/plugin_install_tests.rs index d6de709fa..6e4a95eea 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -20,8 +20,8 @@ use super::host::{ validate_relay_mcp, }; use super::*; +use crate::agents::host::strip_windows_verbatim_prefix; use crate::config::CodingAgent; -use crate::plugin_host::strip_windows_verbatim_prefix; const OPERATION_LOCK_HELPER_DIR_ENV: &str = "NEMO_RELAY_TEST_OPERATION_LOCK_DIR"; const OPERATION_LOCK_HELPER_GLOBAL_DIR_ENV: &str = "NEMO_RELAY_TEST_OPERATION_LOCK_GLOBAL_DIR"; @@ -1028,7 +1028,7 @@ impl CrossProcessLockHolder { command .args([ "--exact", - "plugin_install::tests::cross_process_lock_holder", + "agents::install::tests::cross_process_lock_holder", "--nocapture", ]) .env(env_name, target) @@ -2133,8 +2133,8 @@ fn hermes_doctor_probes_the_configured_relay_and_top_level_doctor_discovers_it() .join(format!("nemo-relay{}", std::env::consts::EXE_SUFFIX)); std::fs::create_dir_all(relay.parent().unwrap()).unwrap(); std::fs::copy(std::env::current_exe().unwrap(), &relay).unwrap(); - crate::hermes::install_persistent(&config, &relay).unwrap(); - let configured_relay = crate::hermes::configured_relay_executable(&config).unwrap(); + crate::agents::hermes::install_persistent(&config, &relay).unwrap(); + let configured_relay = crate::agents::hermes::configured_relay_executable(&config).unwrap(); let runner = MockRunner::default() .with_executable("hermes", "/bin/hermes") .with_capture_output("/bin/hermes --version", "Hermes Agent v0.18.2 (test)\n"); diff --git a/crates/cli/tests/coverage/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs similarity index 100% rename from crates/cli/tests/coverage/main_tests.rs rename to crates/cli/tests/coverage/commands/main_tests.rs diff --git a/crates/cli/tests/coverage/model_pricing_tests.rs b/crates/cli/tests/coverage/commands/model_pricing_tests.rs similarity index 100% rename from crates/cli/tests/coverage/model_pricing_tests.rs rename to crates/cli/tests/coverage/commands/model_pricing_tests.rs diff --git a/crates/cli/tests/coverage/agent_process_tests.rs b/crates/cli/tests/coverage/shared/agent_process_tests.rs similarity index 100% rename from crates/cli/tests/coverage/agent_process_tests.rs rename to crates/cli/tests/coverage/shared/agent_process_tests.rs diff --git a/crates/cli/tests/coverage/banner_tests.rs b/crates/cli/tests/coverage/shared/banner_tests.rs similarity index 100% rename from crates/cli/tests/coverage/banner_tests.rs rename to crates/cli/tests/coverage/shared/banner_tests.rs diff --git a/crates/cli/tests/coverage/completions_install_tests.rs b/crates/cli/tests/coverage/shared/completions_install_tests.rs similarity index 100% rename from crates/cli/tests/coverage/completions_install_tests.rs rename to crates/cli/tests/coverage/shared/completions_install_tests.rs diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs similarity index 100% rename from crates/cli/tests/coverage/config_tests.rs rename to crates/cli/tests/coverage/shared/config_tests.rs diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/shared/doctor_tests.rs similarity index 99% rename from crates/cli/tests/coverage/doctor_tests.rs rename to crates/cli/tests/coverage/shared/doctor_tests.rs index c41b903ae..161ed0857 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/shared/doctor_tests.rs @@ -201,7 +201,7 @@ fn exit_code_fails_when_an_installed_host_plugin_is_unready() { let mut report = empty_report(); report .host_plugins - .push(crate::plugin_install::HostPluginReadiness { + .push(crate::agents::install::HostPluginReadiness { host: "codex".into(), remediation: "nemo-relay install codex --force".into(), state_path: PathBuf::from("/tmp/codex.json"), @@ -209,7 +209,7 @@ fn exit_code_fails_when_an_installed_host_plugin_is_unready() { plugin: Some(PathBuf::from( "/tmp/codex-marketplace/plugins/nemo-relay-plugin", )), - checks: vec![crate::plugin_install::HostPluginReadinessCheck { + checks: vec![crate::agents::install::HostPluginReadinessCheck { name: "Host CLI".into(), ok: false, details: "required `codex` CLI was not found on PATH".into(), diff --git a/crates/cli/tests/coverage/file_io_tests.rs b/crates/cli/tests/coverage/shared/file_io_tests.rs similarity index 100% rename from crates/cli/tests/coverage/file_io_tests.rs rename to crates/cli/tests/coverage/shared/file_io_tests.rs diff --git a/crates/cli/tests/coverage/gateway_tests.rs b/crates/cli/tests/coverage/shared/gateway_tests.rs similarity index 99% rename from crates/cli/tests/coverage/gateway_tests.rs rename to crates/cli/tests/coverage/shared/gateway_tests.rs index db2bdcd33..0b3b1f4ba 100644 --- a/crates/cli/tests/coverage/gateway_tests.rs +++ b/crates/cli/tests/coverage/shared/gateway_tests.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use crate::alignment::GatewayRouteKind; +use crate::agents::alignment::GatewayRouteKind; use crate::config::GatewayConfig; use crate::server::AppState; use crate::session::{LlmGatewayStart, SessionManager}; diff --git a/crates/cli/tests/coverage/hook_assertions.rs b/crates/cli/tests/coverage/shared/hook_assertions.rs similarity index 100% rename from crates/cli/tests/coverage/hook_assertions.rs rename to crates/cli/tests/coverage/shared/hook_assertions.rs diff --git a/crates/cli/tests/coverage/install_generation_tests.rs b/crates/cli/tests/coverage/shared/install_generation_tests.rs similarity index 100% rename from crates/cli/tests/coverage/install_generation_tests.rs rename to crates/cli/tests/coverage/shared/install_generation_tests.rs diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/shared/installer_tests.rs similarity index 100% rename from crates/cli/tests/coverage/installer_tests.rs rename to crates/cli/tests/coverage/shared/installer_tests.rs diff --git a/crates/cli/tests/coverage/mcp_gateway_tests.rs b/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs similarity index 100% rename from crates/cli/tests/coverage/mcp_gateway_tests.rs rename to crates/cli/tests/coverage/shared/mcp_gateway_tests.rs diff --git a/crates/cli/tests/coverage/mcp_tests.rs b/crates/cli/tests/coverage/shared/mcp_tests.rs similarity index 100% rename from crates/cli/tests/coverage/mcp_tests.rs rename to crates/cli/tests/coverage/shared/mcp_tests.rs diff --git a/crates/cli/tests/coverage/plugins_lifecycle_target_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_target_tests.rs similarity index 100% rename from crates/cli/tests/coverage/plugins_lifecycle_target_tests.rs rename to crates/cli/tests/coverage/shared/plugins_lifecycle_target_tests.rs diff --git a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs similarity index 100% rename from crates/cli/tests/coverage/plugins_lifecycle_tests.rs rename to crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs diff --git a/crates/cli/tests/coverage/plugins_schema_tests.rs b/crates/cli/tests/coverage/shared/plugins_schema_tests.rs similarity index 100% rename from crates/cli/tests/coverage/plugins_schema_tests.rs rename to crates/cli/tests/coverage/shared/plugins_schema_tests.rs diff --git a/crates/cli/tests/coverage/plugins_tests.rs b/crates/cli/tests/coverage/shared/plugins_tests.rs similarity index 100% rename from crates/cli/tests/coverage/plugins_tests.rs rename to crates/cli/tests/coverage/shared/plugins_tests.rs diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/shared/server_tests.rs similarity index 100% rename from crates/cli/tests/coverage/server_tests.rs rename to crates/cli/tests/coverage/shared/server_tests.rs diff --git a/crates/cli/tests/coverage/session_tests.rs b/crates/cli/tests/coverage/shared/session_tests.rs similarity index 99% rename from crates/cli/tests/coverage/session_tests.rs rename to crates/cli/tests/coverage/shared/session_tests.rs index 34d960732..0c692b73a 100644 --- a/crates/cli/tests/coverage/session_tests.rs +++ b/crates/cli/tests/coverage/shared/session_tests.rs @@ -192,7 +192,7 @@ fn register_filtered_session_subscriber( } async fn apply_codex_payload(manager: &SessionManager, headers: &HeaderMap, payload: Value) { - let outcome = crate::adapters::codex::adapt(payload, headers); + let outcome = crate::agents::adapters::codex::adapt(payload, headers); manager.apply_events(headers, outcome.events).await.unwrap(); } @@ -549,7 +549,7 @@ async fn drive_hermes_orphan_subagent_stop( "session_id": session_id }), ] { - let outcome = crate::adapters::hermes::adapt(payload, headers); + let outcome = crate::agents::adapters::hermes::adapt(payload, headers); manager.apply_events(headers, outcome.events).await.unwrap(); } } @@ -637,7 +637,7 @@ async fn drive_hermes_subagent_child_session( "session_id": parent_session_id }), ] { - let outcome = crate::adapters::hermes::adapt(payload, headers); + let outcome = crate::agents::adapters::hermes::adapt(payload, headers); manager.apply_events(headers, outcome.events).await.unwrap(); } } @@ -1552,7 +1552,7 @@ async fn hermes_subagent_start_does_not_reparent_active_child_session() { } }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2455,7 +2455,7 @@ async fn hermes_exact_api_hooks_write_atif_request_response_and_cost() { "session_id": "hermes-exact-atif" }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2559,7 +2559,7 @@ async fn hermes_api_request_error_writes_atif_error_step_and_fidelity() { "session_id": "hermes-error" }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2670,7 +2670,7 @@ async fn hermes_lossy_api_hooks_write_atif_fidelity_markers() { "session_id": "hermes-lossy-atif" }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2737,7 +2737,7 @@ async fn hermes_uncorrelatable_pre_tool_call_does_not_create_shutdown_trajectory "session_id": "hermes-main" }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2817,7 +2817,7 @@ async fn hermes_turn_end_snapshots_atif_without_boundary_system_step() { "session_id": "hermes-clean" }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2872,14 +2872,14 @@ async fn hermes_task_id_tool_hooks_reuse_api_session() { } }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await .unwrap(); } - let pre_tool = crate::adapters::hermes::adapt( + let pre_tool = crate::agents::adapters::hermes::adapt( json!({ "hook_event_name": "pre_tool_call", "session_id": "hermes-main", @@ -2911,7 +2911,7 @@ async fn hermes_task_id_tool_hooks_reuse_api_session() { ); } - let post_tool = crate::adapters::hermes::adapt( + let post_tool = crate::agents::adapters::hermes::adapt( json!({ "hook_event_name": "post_tool_call", "session_id": "hermes-main", @@ -3032,7 +3032,7 @@ async fn hermes_post_tool_call_writes_atif_observation_with_source_call_id() { "session_id": "hermes-tool-result" }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await diff --git a/crates/cli/tests/coverage/setup_tests.rs b/crates/cli/tests/coverage/shared/setup_tests.rs similarity index 100% rename from crates/cli/tests/coverage/setup_tests.rs rename to crates/cli/tests/coverage/shared/setup_tests.rs diff --git a/crates/cli/tests/coverage/sidecar_health_tests.rs b/crates/cli/tests/coverage/shared/sidecar_health_tests.rs similarity index 100% rename from crates/cli/tests/coverage/sidecar_health_tests.rs rename to crates/cli/tests/coverage/shared/sidecar_health_tests.rs diff --git a/crates/cli/tests/coverage/sidecar_state_tests.rs b/crates/cli/tests/coverage/shared/sidecar_state_tests.rs similarity index 100% rename from crates/cli/tests/coverage/sidecar_state_tests.rs rename to crates/cli/tests/coverage/shared/sidecar_state_tests.rs diff --git a/crates/cli/tests/coverage/sidecar_tests.rs b/crates/cli/tests/coverage/shared/sidecar_tests.rs similarity index 100% rename from crates/cli/tests/coverage/sidecar_tests.rs rename to crates/cli/tests/coverage/shared/sidecar_tests.rs From dca181874557d3ace3eaca2a4281b15af17ca8b7 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 20:08:01 -0400 Subject: [PATCH 018/102] refactor(cli): add internal application root Signed-off-by: Will Killian --- crates/cli/src/lib.rs | 56 ++++++++++++++++++++++++++++++++++++++++++ crates/cli/src/main.rs | 46 ++-------------------------------- 2 files changed, 58 insertions(+), 44 deletions(-) create mode 100644 crates/cli/src/lib.rs diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs new file mode 100644 index 000000000..bba26d358 --- /dev/null +++ b/crates/cli/src/lib.rs @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Internal application library for the `nemo-relay` executable. + +mod agent_process; +mod agents; +mod banner; +mod commands; +mod completions_install; +mod config; +mod doctor; +mod error; +mod file_io; +mod gateway; +mod install_generation; +mod installer; +mod json_path; +mod launcher; +mod mcp; +mod mcp_environment; +mod model; +mod model_pricing; +mod plugins; +mod server; +mod session; +mod setup; +mod sidecar; + +#[cfg(test)] +#[path = "../tests/coverage/shared/hook_assertions.rs"] +mod hook_assertions; + +#[cfg(test)] +pub(crate) use commands::test_support; + +use std::process::ExitCode; + +/// Runs the `nemo-relay` process. +/// +/// This is an executable entrypoint, not a supported library API. +#[doc(hidden)] +pub fn run_cli() -> ExitCode { + mcp_environment::remove_unresolved_mcp_placeholders(); + let runtime = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + eprintln!("failed to initialize async runtime: {error}"); + return ExitCode::FAILURE; + } + }; + runtime.block_on(commands::run()) +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index a9b001abe..079955145 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,52 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! NeMo Relay coding-agent gateway CLI. - -mod agent_process; -mod agents; -mod banner; -mod commands; -mod completions_install; -mod config; -mod doctor; -mod error; -mod file_io; -mod gateway; -mod install_generation; -mod installer; -mod json_path; -mod launcher; -mod mcp; -mod mcp_environment; -mod model; -mod model_pricing; -mod plugins; -mod server; -mod session; -mod setup; -mod sidecar; - -#[cfg(test)] -#[path = "../tests/coverage/shared/hook_assertions.rs"] -mod hook_assertions; - -#[cfg(test)] -pub(crate) use commands::test_support; +//! Process entrypoint for the NeMo Relay coding-agent gateway. use std::process::ExitCode; fn main() -> ExitCode { - mcp_environment::remove_unresolved_mcp_placeholders(); - let runtime = match tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - { - Ok(runtime) => runtime, - Err(error) => { - eprintln!("failed to initialize async runtime: {error}"); - return ExitCode::FAILURE; - } - }; - runtime.block_on(commands::run()) + nemo_relay_cli::run_cli() } From 5841f3277ec6f71224cfc08a3e0e8dbf908c7fda Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 20:10:33 -0400 Subject: [PATCH 019/102] refactor(cli): establish subsystem boundaries Signed-off-by: Will Killian --- crates/cli/src/agents/adapters.rs | 6 +- crates/cli/src/agents/alignment.rs | 8 ++- crates/cli/src/agents/claude/adapter.rs | 2 +- crates/cli/src/agents/claude/alignment.rs | 4 +- crates/cli/src/agents/codex/adapter.rs | 2 +- crates/cli/src/agents/codex/alignment.rs | 2 +- crates/cli/src/agents/codex/app_server.rs | 2 +- crates/cli/src/agents/codex/host.rs | 17 ++--- crates/cli/src/agents/hermes/adapter.rs | 16 ++--- crates/cli/src/agents/hermes/alignment.rs | 2 +- crates/cli/src/agents/hermes/config.rs | 26 ++++---- crates/cli/src/agents/hermes/files.rs | 4 +- crates/cli/src/agents/hermes/integration.rs | 13 ++-- crates/cli/src/agents/hermes/trust.rs | 2 +- crates/cli/src/agents/host.rs | 4 +- crates/cli/src/agents/install/host.rs | 10 +-- crates/cli/src/agents/install/marketplace.rs | 11 ++-- crates/cli/src/agents/install/mod.rs | 12 ++-- .../cli/src/agents/install/operation_lock.rs | 4 +- crates/cli/src/agents/install/setup.rs | 2 +- crates/cli/src/agents/install/state.rs | 6 +- crates/cli/src/agents/shared/host.rs | 4 +- crates/cli/src/commands/completions.rs | 2 +- crates/cli/src/commands/configuration.rs | 2 +- crates/cli/src/commands/diagnostics.rs | 4 +- crates/cli/src/commands/hook_forward.rs | 4 +- crates/cli/src/commands/install.rs | 2 +- crates/cli/src/commands/mcp.rs | 2 +- crates/cli/src/commands/mod.rs | 24 ++++--- crates/cli/src/commands/model_pricing.rs | 10 +-- crates/cli/src/commands/plugins.rs | 2 +- crates/cli/src/commands/run.rs | 6 +- crates/cli/src/completions_install.rs | 2 +- .../src/{config.rs => configuration/mod.rs} | 8 +-- .../cli/src/{doctor.rs => diagnostics/mod.rs} | 16 ++--- crates/cli/src/{ => events}/json_path.rs | 0 crates/cli/src/{model.rs => events/mod.rs} | 2 + .../cli/src/{file_io.rs => filesystem/mod.rs} | 2 +- crates/cli/src/{gateway.rs => gateway/mod.rs} | 14 ++-- crates/cli/src/{installer.rs => hooks/mod.rs} | 22 +++---- .../generation.rs} | 6 +- crates/cli/src/installation/mod.rs | 6 ++ crates/cli/src/lib.rs | 19 +++--- crates/cli/src/mcp.rs | 15 +++-- crates/cli/src/mcp/gateway.rs | 6 +- crates/cli/src/mcp_environment.rs | 2 +- crates/cli/src/plugins.rs | 3 +- crates/cli/src/plugins/config_io.rs | 4 +- crates/cli/src/plugins/dynamic_editor.rs | 2 +- crates/cli/src/plugins/lifecycle.rs | 10 +-- .../cli/src/plugins/lifecycle/environment.rs | 14 ++-- crates/cli/src/plugins/lifecycle/responses.rs | 2 +- crates/cli/src/plugins/lifecycle/state.rs | 2 +- crates/cli/src/plugins/lifecycle/trust.rs | 17 +++-- .../{model_pricing.rs => plugins/pricing.rs} | 4 +- crates/cli/src/{ => process}/launcher.rs | 33 +++++----- .../src/{agent_process.rs => process/mod.rs} | 6 +- .../{agent_process => process}/supervision.rs | 0 .../supervision/fallback.rs | 0 .../supervision/unix.rs | 0 .../supervision/windows.rs | 0 crates/cli/src/{server.rs => server/mod.rs} | 10 +-- .../cli/src/{session.rs => sessions/mod.rs} | 6 +- crates/cli/src/setup.rs | 2 +- crates/cli/src/setup/model.rs | 7 +- crates/cli/src/sidecar.rs | 19 +++--- crates/cli/src/sidecar/health.rs | 2 +- crates/cli/src/sidecar/state.rs | 6 +- .../tests/coverage/agents/alignment_tests.rs | 2 +- .../cli/tests/coverage/agents/hermes_tests.rs | 28 ++++---- .../tests/coverage/agents/launcher_tests.rs | 17 ++--- .../coverage/agents/plugin_host_tests.rs | 33 +++++----- .../coverage/agents/plugin_install_tests.rs | 43 ++++++------ .../cli/tests/coverage/commands/main_tests.rs | 2 +- .../cli/tests/coverage/shared/config_tests.rs | 16 ++--- .../cli/tests/coverage/shared/doctor_tests.rs | 10 +-- .../tests/coverage/shared/gateway_tests.rs | 26 ++++---- .../tests/coverage/shared/hook_assertions.rs | 2 +- .../shared/install_generation_tests.rs | 2 +- .../tests/coverage/shared/installer_tests.rs | 6 +- .../coverage/shared/mcp_gateway_tests.rs | 9 +-- crates/cli/tests/coverage/shared/mcp_tests.rs | 41 ++++++------ .../shared/plugins_lifecycle_tests.rs | 42 ++++++------ .../tests/coverage/shared/plugins_tests.rs | 2 +- .../cli/tests/coverage/shared/server_tests.rs | 10 +-- .../tests/coverage/shared/session_tests.rs | 66 +++++++++---------- .../coverage/shared/sidecar_state_tests.rs | 4 +- .../tests/coverage/shared/sidecar_tests.rs | 8 +-- 88 files changed, 446 insertions(+), 409 deletions(-) rename crates/cli/src/{config.rs => configuration/mod.rs} (99%) rename crates/cli/src/{doctor.rs => diagnostics/mod.rs} (99%) rename crates/cli/src/{ => events}/json_path.rs (100%) rename crates/cli/src/{model.rs => events/mod.rs} (99%) rename crates/cli/src/{file_io.rs => filesystem/mod.rs} (99%) rename crates/cli/src/{gateway.rs => gateway/mod.rs} (99%) rename crates/cli/src/{installer.rs => hooks/mod.rs} (98%) rename crates/cli/src/{install_generation.rs => installation/generation.rs} (99%) create mode 100644 crates/cli/src/installation/mod.rs rename crates/cli/src/{model_pricing.rs => plugins/pricing.rs} (99%) rename crates/cli/src/{ => process}/launcher.rs (98%) rename crates/cli/src/{agent_process.rs => process/mod.rs} (97%) rename crates/cli/src/{agent_process => process}/supervision.rs (100%) rename crates/cli/src/{agent_process => process}/supervision/fallback.rs (100%) rename crates/cli/src/{agent_process => process}/supervision/unix.rs (100%) rename crates/cli/src/{agent_process => process}/supervision/windows.rs (100%) rename crates/cli/src/{server.rs => server/mod.rs} (99%) rename crates/cli/src/{session.rs => sessions/mod.rs} (99%) diff --git a/crates/cli/src/agents/adapters.rs b/crates/cli/src/agents/adapters.rs index a4a84c3ce..c0fefd288 100644 --- a/crates/cli/src/agents/adapters.rs +++ b/crates/cli/src/agents/adapters.rs @@ -16,11 +16,11 @@ use nemo_relay::api::scope::COMPACTION_EVENT_NAME; use serde_json::{Map, Value, json}; use uuid::Uuid; -use crate::config::header_string; -use crate::json_path::{ +use crate::configuration::header_string; +use crate::events::json_path::{ string_at, string_at_any as first_string_at, value_at, value_at_any as first_value_at, }; -use crate::model::{ +use crate::events::{ AgentKind, LlmHintEvent, NormalizedEvent, SessionEvent, SubagentEvent, ToolEvent, }; diff --git a/crates/cli/src/agents/alignment.rs b/crates/cli/src/agents/alignment.rs index 19f9164e5..f8eca393c 100644 --- a/crates/cli/src/agents/alignment.rs +++ b/crates/cli/src/agents/alignment.rs @@ -13,9 +13,11 @@ use axum::http::HeaderMap; use nemo_relay::api::llm::LlmRequest; use serde_json::{Map, Value, json}; -use crate::config::header_string; -pub(crate) use crate::json_path::{string_at_any as json_string_at, value_at_any as json_value_at}; -use crate::model::{AgentKind, LlmEvent, NormalizedEvent, SessionEvent, SubagentEvent, ToolEvent}; +use crate::configuration::header_string; +pub(crate) use crate::events::json_path::{ + string_at_any as json_string_at, value_at_any as json_value_at, +}; +use crate::events::{AgentKind, LlmEvent, NormalizedEvent, SessionEvent, SubagentEvent, ToolEvent}; #[path = "claude/alignment.rs"] pub(crate) mod claude_code; diff --git a/crates/cli/src/agents/claude/adapter.rs b/crates/cli/src/agents/claude/adapter.rs index 273d5b168..a3f375883 100644 --- a/crates/cli/src/agents/claude/adapter.rs +++ b/crates/cli/src/agents/claude/adapter.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use crate::agents::adapters::{ AdapterOutcome, CLAUDE_CODE_PAYLOAD_EXTRACTOR, ClassificationRules, classify, }; -use crate::model::{AgentKind, NormalizedEvent}; +use crate::events::{AgentKind, NormalizedEvent}; /// Normalizes Claude Code hook payloads and returns the hook response Claude expects. /// diff --git a/crates/cli/src/agents/claude/alignment.rs b/crates/cli/src/agents/claude/alignment.rs index 945106318..f05ea68bb 100644 --- a/crates/cli/src/agents/claude/alignment.rs +++ b/crates/cli/src/agents/claude/alignment.rs @@ -12,8 +12,8 @@ use nemo_relay::api::llm::LlmRequest; use serde_json::Value; use crate::agents::alignment::json_string_at; -use crate::config::header_string; -use crate::model::{AgentKind, ToolEvent}; +use crate::configuration::header_string; +use crate::events::{AgentKind, ToolEvent}; // Identifies gateway providers that should be labeled as Claude-owned when an Anthropic request // arrives before a SessionStart hook. Other providers are left generic so mixed gateway traffic diff --git a/crates/cli/src/agents/codex/adapter.rs b/crates/cli/src/agents/codex/adapter.rs index 04454164f..9285c81b3 100644 --- a/crates/cli/src/agents/codex/adapter.rs +++ b/crates/cli/src/agents/codex/adapter.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use crate::agents::adapters::{ AdapterOutcome, CODEX_PAYLOAD_EXTRACTOR, ClassificationRules, classify, }; -use crate::model::AgentKind; +use crate::events::AgentKind; /// Normalizes Codex hook payloads while leaving Codex hook control flow untouched. /// diff --git a/crates/cli/src/agents/codex/alignment.rs b/crates/cli/src/agents/codex/alignment.rs index e1695e0e0..6f4389f3c 100644 --- a/crates/cli/src/agents/codex/alignment.rs +++ b/crates/cli/src/agents/codex/alignment.rs @@ -16,7 +16,7 @@ use serde_json::{Map, Value, json}; use crate::agents::alignment::{ GatewayRouteKind, SessionAlias, insert_optional, json_string_at, merge_metadata, }; -use crate::model::{AgentKind, SessionEvent, SubagentEvent}; +use crate::events::{AgentKind, SessionEvent, SubagentEvent}; // ChatGPT backend base URL used by Codex when authenticated with ChatGPT. This mirrors Codex's own // `CHATGPT_CODEX_BASE_URL`; API-key auth continues through the normal OpenAI base. diff --git a/crates/cli/src/agents/codex/app_server.rs b/crates/cli/src/agents/codex/app_server.rs index 5cf91934d..8ff5f59b0 100644 --- a/crates/cli/src/agents/codex/app_server.rs +++ b/crates/cli/src/agents/codex/app_server.rs @@ -226,5 +226,5 @@ pub(super) fn hook_state_key_path(key: &str) -> String { } fn codex_app_server_command() -> Command { - crate::agent_process::std_command(&["codex".into(), "app-server".into()]) + crate::process::std_command(&["codex".into(), "app-server".into()]) } diff --git a/crates/cli/src/agents/codex/host.rs b/crates/cli/src/agents/codex/host.rs index 07b7c6121..57a55e9d5 100644 --- a/crates/cli/src/agents/codex/host.rs +++ b/crates/cli/src/agents/codex/host.rs @@ -12,12 +12,12 @@ use std::process::ExitCode; use serde_json::{Value, json}; use toml_edit::{DocumentMut, InlineTable, Item, Table, Value as TomlValue, value}; -use crate::config::{ +use crate::configuration::{ BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey, CodingAgent, RELAY_PLUGIN_ID, }; -use crate::installer::generated_hooks; +use crate::hooks::generated_hooks; #[cfg(test)] -use crate::installer::merge_hooks; +use crate::hooks::merge_hooks; use super::codex_app_server::{CodexAppServerClient, CodexHookMetadata, CodexHooksClient}; use super::shared::{ @@ -584,8 +584,9 @@ fn expected_plugin_hook_command_with_token( let generation_token = match generation_token { Some(token) => token, None => { - captured = - crate::install_generation::InstallGeneration::capture(generation_path.clone())?; + captured = crate::installation::generation::InstallGeneration::capture( + generation_path.clone(), + )?; captured.token() } }; @@ -596,7 +597,7 @@ fn plugin_generation_file(plugin_hooks_path: &Path) -> Result { let generation = plugin_hooks_path .parent() .and_then(Path::parent) - .map(|root| root.join(crate::install_generation::GENERATION_FILE_NAME)) + .map(|root| root.join(crate::installation::generation::GENERATION_FILE_NAME)) .ok_or_else(|| { format!( "Codex plugin hooks path {} is not inside a plugin hooks directory", @@ -1586,7 +1587,7 @@ pub(super) fn codex_plugin_hook_command( generation: &Path, generation_token: &str, ) -> Result { - crate::installer::persistent_hook_forward_command( + crate::hooks::persistent_hook_forward_command( relay, CodingAgent::Codex, generation, @@ -1601,7 +1602,7 @@ pub(super) fn codex_plugin_hook_command_for_platform( generation_token: &str, windows: bool, ) -> String { - crate::installer::persistent_hook_forward_command_for_platform( + crate::hooks::persistent_hook_forward_command_for_platform( relay, CodingAgent::Codex, generation, diff --git a/crates/cli/src/agents/hermes/adapter.rs b/crates/cli/src/agents/hermes/adapter.rs index ccc5821fa..d3119a594 100644 --- a/crates/cli/src/agents/hermes/adapter.rs +++ b/crates/cli/src/agents/hermes/adapter.rs @@ -8,8 +8,8 @@ use crate::agents::adapters::{ AdapterOutcome, ClassificationRules, HERMES_PAYLOAD_EXTRACTOR, classify, common_session_event, event_name, metadata, normalize_name, session_id, }; -use crate::json_path::value_at; -use crate::model::{AgentKind, LlmEvent, NormalizedEvent}; +use crate::events::json_path::value_at; +use crate::events::{AgentKind, LlmEvent, NormalizedEvent}; /// Normalizes Hermes shell hook payloads without emitting control directives. /// @@ -21,17 +21,15 @@ pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome { let normalized = normalize_name(&event_name); if normalized == "preapirequest" { return AdapterOutcome { - events: vec![crate::model::NormalizedEvent::LlmStarted(hermes_llm_event( - &payload, - headers, - &event_name, - ))], + events: vec![crate::events::NormalizedEvent::LlmStarted( + hermes_llm_event(&payload, headers, &event_name), + )], response: json!({}), }; } if normalized == "postapirequest" { return AdapterOutcome { - events: vec![crate::model::NormalizedEvent::LlmEnded(hermes_llm_event( + events: vec![crate::events::NormalizedEvent::LlmEnded(hermes_llm_event( &payload, headers, &event_name, @@ -41,7 +39,7 @@ pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome { } if normalized == "apirequesterror" { return AdapterOutcome { - events: vec![crate::model::NormalizedEvent::LlmEnded(hermes_llm_event( + events: vec![crate::events::NormalizedEvent::LlmEnded(hermes_llm_event( &payload, headers, &event_name, diff --git a/crates/cli/src/agents/hermes/alignment.rs b/crates/cli/src/agents/hermes/alignment.rs index c61b1d74a..9fcb21f2b 100644 --- a/crates/cli/src/agents/hermes/alignment.rs +++ b/crates/cli/src/agents/hermes/alignment.rs @@ -11,7 +11,7 @@ use serde_json::{Map, Value, json}; use crate::agents::alignment::{SessionAlias, insert_optional, json_string_at, merge_metadata}; -use crate::model::{AgentKind, SessionEvent, SubagentEvent}; +use crate::events::{AgentKind, SessionEvent, SubagentEvent}; #[derive(Debug, Clone)] pub(crate) struct SubagentContext { diff --git a/crates/cli/src/agents/hermes/config.rs b/crates/cli/src/agents/hermes/config.rs index a91d3847f..c2a33b883 100644 --- a/crates/cli/src/agents/hermes/config.rs +++ b/crates/cli/src/agents/hermes/config.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use serde_json::{Map, Value, json}; use crate::error::CliError; -use crate::installer::{generated_hooks, merge_hooks}; +use crate::hooks::{generated_hooks, merge_hooks}; pub(super) use crate::mcp::SERVER_NAME as MCP_SERVER_NAME; @@ -34,15 +34,15 @@ pub(crate) fn transparent_config( let owned = owned_install_command(&root, relay, None)?; strip_owned_hooks(&mut root, owned.as_deref())?; remove_owned_mcp(&mut root, owned.is_some())?; - let command = crate::installer::transparent_hook_forward_command( + let command = crate::hooks::transparent_hook_forward_command( relay, - crate::config::CodingAgent::Hermes, + crate::configuration::CodingAgent::Hermes, gateway_url, ) .map_err(CliError::Install)?; let root = merge_hooks( root, - generated_hooks(crate::config::CodingAgent::Hermes, &command), + generated_hooks(crate::configuration::CodingAgent::Hermes, &command), )?; serde_yaml::to_string(&root).map_err(|error| CliError::Install(error.to_string())) } @@ -52,9 +52,9 @@ pub(crate) fn persistent_hook_command( generation: &Path, generation_token: &str, ) -> Result { - crate::installer::persistent_hook_forward_command( + crate::hooks::persistent_hook_forward_command( relay, - crate::config::CodingAgent::Hermes, + crate::configuration::CodingAgent::Hermes, generation, generation_token, ) @@ -67,9 +67,9 @@ pub(super) fn persistent_hook_command_for_platform( generation_token: &str, windows: bool, ) -> String { - crate::installer::persistent_hook_forward_command_for_platform( + crate::hooks::persistent_hook_forward_command_for_platform( relay, - crate::config::CodingAgent::Hermes, + crate::configuration::CodingAgent::Hermes, generation, generation_token, windows, @@ -98,7 +98,7 @@ pub(super) fn persistent_config( strip_owned_hooks(&mut root, owned.as_deref())?; root = merge_hooks( root, - generated_hooks(crate::config::CodingAgent::Hermes, command), + generated_hooks(crate::configuration::CodingAgent::Hermes, command), )?; let servers = object_field_mut(&mut root, "mcp_servers", "mcp_servers")?; servers.insert( @@ -206,10 +206,10 @@ pub(super) fn owned_install_command( == Some(&json!(crate::sidecar::DEFAULT_BIND)) { let generation = env - .and_then(|env| env.get(crate::install_generation::GENERATION_FILE_ENV)) + .and_then(|env| env.get(crate::installation::generation::GENERATION_FILE_ENV)) .and_then(Value::as_str); let token = env - .and_then(|env| env.get(crate::install_generation::GENERATION_TOKEN_ENV)) + .and_then(|env| env.get(crate::installation::generation::GENERATION_TOKEN_ENV)) .and_then(Value::as_str); if let (Some(generation), Some(token)) = (generation, token) && !token.is_empty() @@ -224,7 +224,7 @@ pub(super) fn owned_install_command( } fn has_complete_hook_set(root: &Value, command: &str) -> bool { - crate::config::CodingAgent::Hermes + crate::configuration::CodingAgent::Hermes .hook_events() .iter() .all(|event| { @@ -251,7 +251,7 @@ fn legacy_owned_command(root: &Value, relay: &Path) -> Result, Cl return Ok(None); }; let mut common = None; - for event in crate::config::CodingAgent::Hermes.hook_events() { + for event in crate::configuration::CodingAgent::Hermes.hook_events() { let commands = hooks .get(*event) .and_then(Value::as_array) diff --git a/crates/cli/src/agents/hermes/files.rs b/crates/cli/src/agents/hermes/files.rs index 6f0dee0cc..7cbdfd2c5 100644 --- a/crates/cli/src/agents/hermes/files.rs +++ b/crates/cli/src/agents/hermes/files.rs @@ -9,8 +9,8 @@ use std::thread; use std::time::{Duration, Instant}; use crate::error::CliError; -use crate::file_io::{LockAttempt, try_lock_exclusive}; -use crate::install_generation::GENERATION_FILE_NAME; +use crate::filesystem::{LockAttempt, try_lock_exclusive}; +use crate::installation::generation::GENERATION_FILE_NAME; const ALLOWLIST_FILE_NAME: &str = "shell-hooks-allowlist.json"; const INSTALL_LOCK_FILE_NAME: &str = ".nemo-relay-operation.lock"; diff --git a/crates/cli/src/agents/hermes/integration.rs b/crates/cli/src/agents/hermes/integration.rs index a300ef318..87bb8b350 100644 --- a/crates/cli/src/agents/hermes/integration.rs +++ b/crates/cli/src/agents/hermes/integration.rs @@ -23,12 +23,12 @@ use super::files::{ acquire_install_lock, read_optional_utf8, remove_optional_file, replace_optional_file, }; use super::trust::{json_bytes, parse_json_object, trusted_hooks, verify_trust}; -use crate::config::CodingAgent; +use crate::configuration::CodingAgent; use crate::error::CliError; -use crate::file_io::atomic_write; +use crate::filesystem::atomic_write; #[cfg(test)] -use crate::install_generation::GENERATION_FILE_NAME; -use crate::install_generation::{ +use crate::installation::generation::GENERATION_FILE_NAME; +use crate::installation::generation::{ GENERATION_FILE_ENV, GENERATION_TOKEN_ENV, GenerationRetirement, InstallGeneration, }; use crate::sidecar::DEFAULT_BIND; @@ -53,7 +53,7 @@ pub(crate) fn install_persistent(config: &Path, relay: &Path) -> Result>(); @@ -229,7 +229,8 @@ pub(crate) fn diagnose_persistent(config_path: &Path) -> Result return Err("Hermes Relay MCP expected generation identity is stale".into()); } - let plugin_config = crate::config::user_plugin_runtime_config().map_err(|e| e.to_string())?; + let plugin_config = + crate::configuration::user_plugin_runtime_config().map_err(|e| e.to_string())?; let environment = env::vars_os() .filter_map(|(name, _)| name.into_string().ok()) .collect::>(); diff --git a/crates/cli/src/agents/hermes/trust.rs b/crates/cli/src/agents/hermes/trust.rs index 2f140596c..8e7f7a8fb 100644 --- a/crates/cli/src/agents/hermes/trust.rs +++ b/crates/cli/src/agents/hermes/trust.rs @@ -10,7 +10,7 @@ use std::time::SystemTime; use chrono::{DateTime, SecondsFormat, Utc}; use serde_json::{Value, json}; -use crate::config::CodingAgent; +use crate::configuration::CodingAgent; use crate::error::CliError; pub(super) fn trusted_hooks( diff --git a/crates/cli/src/agents/host.rs b/crates/cli/src/agents/host.rs index c6ba3d994..95de6e8d4 100644 --- a/crates/cli/src/agents/host.rs +++ b/crates/cli/src/agents/host.rs @@ -31,7 +31,7 @@ use codex::{ }; use shared::{current_exe, healthz, print_check, print_info}; -use crate::config::CodingAgent; +use crate::configuration::CodingAgent; #[cfg(test)] pub(super) use crate::sidecar::DEFAULT_URL; @@ -209,7 +209,7 @@ fn doctor_ok( } #[cfg(test)] -use crate::installer::generated_hooks; +use crate::hooks::generated_hooks; #[cfg(test)] use crate::sidecar::*; #[cfg(test)] diff --git a/crates/cli/src/agents/install/host.rs b/crates/cli/src/agents/install/host.rs index 10c3f4bcd..3f060750b 100644 --- a/crates/cli/src/agents/install/host.rs +++ b/crates/cli/src/agents/install/host.rs @@ -11,7 +11,7 @@ use serde_json::Value; #[cfg(test)] use serde_json::json; -use crate::config::IntegrationHost; +use crate::configuration::IntegrationHost; use super::state::PluginInstallOptions; use super::{MARKETPLACE_NAME, PLUGIN_NAME, RELAY_COMMAND}; @@ -489,18 +489,18 @@ impl CommandRunner for RealCommandRunner { } fn resolve_executable(&self, command: &str) -> Result, String> { - Ok(crate::agent_process::resolve_executable(command)) + Ok(crate::process::resolve_executable(command)) } fn run(&self, program: &Path, args: &[String]) -> Result { - let status = crate::agent_process::std_command(&command_argv(program, args)) + let status = crate::process::std_command(&command_argv(program, args)) .status() .map_err(|error| format!("failed to run {}: {error}", program.display()))?; Ok(status.code().unwrap_or(1)) } fn run_quiet(&self, program: &Path, args: &[String]) -> Result { - let status = crate::agent_process::std_command(&command_argv(program, args)) + let status = crate::process::std_command(&command_argv(program, args)) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -510,7 +510,7 @@ impl CommandRunner for RealCommandRunner { } fn run_capture(&self, program: &Path, args: &[String]) -> Result { - let output = crate::agent_process::std_command(&command_argv(program, args)) + let output = crate::process::std_command(&command_argv(program, args)) .output() .map_err(|error| format!("failed to run {}: {error}", program.display()))?; Ok(command_output(output)) diff --git a/crates/cli/src/agents/install/marketplace.rs b/crates/cli/src/agents/install/marketplace.rs index 6b4609132..c16880867 100644 --- a/crates/cli/src/agents/install/marketplace.rs +++ b/crates/cli/src/agents/install/marketplace.rs @@ -9,11 +9,11 @@ use std::path::Path; use serde_json::{Value, json}; -use crate::config::IntegrationHost; -use crate::install_generation::{ +use crate::configuration::IntegrationHost; +use crate::hooks::generated_hooks; +use crate::installation::generation::{ write_new_generation_with_token_at, write_staged_generation_with_token, }; -use crate::installer::generated_hooks; use super::state::{PluginInstallOptions, PluginLayout, remove_path, write_json}; use super::{MARKETPLACE_NAME, PLUGIN_NAME}; @@ -229,7 +229,7 @@ pub(super) fn plugin_hooks( let generation_fence = absolute_or_self(generation_fence); Ok(generated_hooks( agent, - &crate::installer::persistent_hook_forward_command( + &crate::hooks::persistent_hook_forward_command( relay, agent, &generation_fence, @@ -240,7 +240,8 @@ pub(super) fn plugin_hooks( pub(super) fn plugin_mcp_env_vars() -> Result, String> { let environment = env::vars_os().filter_map(|(name, _)| name.into_string().ok()); - let config = crate::config::user_plugin_runtime_config().map_err(|error| error.to_string())?; + let config = + crate::configuration::user_plugin_runtime_config().map_err(|error| error.to_string())?; Ok(plugin_mcp_env_vars_from(environment, config.as_ref())) } diff --git a/crates/cli/src/agents/install/mod.rs b/crates/cli/src/agents/install/mod.rs index 35476caa2..cf5bdd685 100644 --- a/crates/cli/src/agents/install/mod.rs +++ b/crates/cli/src/agents/install/mod.rs @@ -18,9 +18,11 @@ use std::time::{Duration, Instant}; use serde::Serialize; use serde_json::{Value, json}; -use crate::config::{CodingAgent, InstallCommand, IntegrationHost, UninstallCommand}; +use crate::configuration::{CodingAgent, InstallCommand, IntegrationHost, UninstallCommand}; use crate::error::CliError; -use crate::install_generation::{GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration}; +use crate::installation::generation::{ + GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration, +}; use host::{ CommandRunner, RealCommandRunner, host_registration_report, require_host_cli, require_relay, @@ -1363,7 +1365,8 @@ fn collect_host_plugin_readiness( .map(|_| "hook-forward is supported".into()), ); if let Some(plugin) = readiness.plugin.as_ref() { - let generation_fence = plugin.join(crate::install_generation::GENERATION_FILE_NAME); + let generation_fence = + plugin.join(crate::installation::generation::GENERATION_FILE_NAME); readiness.push( "Generated hooks", InstallGeneration::capture(generation_fence.clone()).and_then(|generation| { @@ -1383,7 +1386,8 @@ fn collect_host_plugin_readiness( .map(|_| "native mcp subcommand is supported".into()), ); if let Some(plugin) = readiness.plugin.as_ref() { - let generation_fence = plugin.join(crate::install_generation::GENERATION_FILE_NAME); + let generation_fence = + plugin.join(crate::installation::generation::GENERATION_FILE_NAME); let mcp_config = plugin_mcp_config_path(plugin); readiness.push( "MCP generation fence", diff --git a/crates/cli/src/agents/install/operation_lock.rs b/crates/cli/src/agents/install/operation_lock.rs index adc2e6f69..1d654f05b 100644 --- a/crates/cli/src/agents/install/operation_lock.rs +++ b/crates/cli/src/agents/install/operation_lock.rs @@ -8,8 +8,8 @@ use std::path::{Path, PathBuf}; use std::thread; use std::time::{Duration, Instant}; -use crate::config::IntegrationHost; -use crate::file_io::{LockAttempt, try_lock_exclusive}; +use crate::configuration::IntegrationHost; +use crate::filesystem::{LockAttempt, try_lock_exclusive}; pub(super) const DEFAULT_OPERATION_LOCK_TIMEOUT: Duration = Duration::from_secs(5); const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25); diff --git a/crates/cli/src/agents/install/setup.rs b/crates/cli/src/agents/install/setup.rs index 461686f27..081c92d15 100644 --- a/crates/cli/src/agents/install/setup.rs +++ b/crates/cli/src/agents/install/setup.rs @@ -4,7 +4,7 @@ //! Host setup, restore, and doctor delegation. use crate::agents::host; -use crate::config::{CodingAgent, IntegrationHost}; +use crate::configuration::{CodingAgent, IntegrationHost}; use serde_json::Value; use std::path::Path; diff --git a/crates/cli/src/agents/install/state.rs b/crates/cli/src/agents/install/state.rs index a3a075816..f57facac3 100644 --- a/crates/cli/src/agents/install/state.rs +++ b/crates/cli/src/agents/install/state.rs @@ -10,8 +10,8 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; -use crate::config::IntegrationHost; -use crate::install_generation::GENERATION_FILE_NAME; +use crate::configuration::IntegrationHost; +use crate::installation::generation::GENERATION_FILE_NAME; use super::PLUGIN_NAME; @@ -295,7 +295,7 @@ pub(super) fn write_json(path: &Path, value: &Value) -> Result<(), String> { } let mut bytes = serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?; bytes.push(b'\n'); - crate::file_io::atomic_write(path, &bytes) + crate::filesystem::atomic_write(path, &bytes) } pub(super) fn remove_path(path: &Path, options: &PluginInstallOptions) -> Result<(), String> { diff --git a/crates/cli/src/agents/shared/host.rs b/crates/cli/src/agents/shared/host.rs index 5e5a470c7..f8a3648e1 100644 --- a/crates/cli/src/agents/shared/host.rs +++ b/crates/cli/src/agents/shared/host.rs @@ -10,11 +10,11 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; use toml_edit::{DocumentMut, Item, Table}; -pub(super) use crate::file_io::{ +pub(super) use crate::filesystem::{ atomic_write, atomic_write_private, atomic_write_with_permissions, }; #[cfg(windows)] -use crate::file_io::{atomic_write_with_windows_dacl, read_windows_dacl}; +use crate::filesystem::{atomic_write_with_windows_dacl, read_windows_dacl}; pub(super) use crate::sidecar::{current_exe, healthz}; pub(crate) fn shell_quote(path: &Path) -> String { diff --git a/crates/cli/src/commands/completions.rs b/crates/cli/src/commands/completions.rs index 2806ce756..6728ed5d1 100644 --- a/crates/cli/src/commands/completions.rs +++ b/crates/cli/src/commands/completions.rs @@ -5,7 +5,7 @@ use std::process::ExitCode; use clap::CommandFactory; -use crate::config::{Cli, CompletionsCommand}; +use crate::configuration::{Cli, CompletionsCommand}; use crate::error::CliError; pub(super) fn execute(command: CompletionsCommand) -> Result { diff --git a/crates/cli/src/commands/configuration.rs b/crates/cli/src/commands/configuration.rs index 37e3a6890..acf79c8e9 100644 --- a/crates/cli/src/commands/configuration.rs +++ b/crates/cli/src/commands/configuration.rs @@ -3,7 +3,7 @@ use std::process::ExitCode; -use crate::config::ConfigCommand; +use crate::configuration::ConfigCommand; use crate::error::CliError; pub(super) async fn execute(command: ConfigCommand) -> Result { diff --git a/crates/cli/src/commands/diagnostics.rs b/crates/cli/src/commands/diagnostics.rs index d722998f5..dcdb0d48c 100644 --- a/crates/cli/src/commands/diagnostics.rs +++ b/crates/cli/src/commands/diagnostics.rs @@ -3,13 +3,13 @@ use std::process::ExitCode; -use crate::config::DoctorCommand; +use crate::configuration::DoctorCommand; use crate::error::CliError; pub(super) async fn execute(command: DoctorCommand) -> Result { if let Some(plugin) = command.plugin { crate::agents::install::doctor(plugin, command.install_dir, command.json) } else { - crate::doctor::run_doctor(command.agent, command.json).await + crate::diagnostics::run_doctor(command.agent, command.json).await } } diff --git a/crates/cli/src/commands/hook_forward.rs b/crates/cli/src/commands/hook_forward.rs index 6c70fd305..0a58df4c6 100644 --- a/crates/cli/src/commands/hook_forward.rs +++ b/crates/cli/src/commands/hook_forward.rs @@ -1,9 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use crate::config::HookForwardCommand; +use crate::configuration::HookForwardCommand; use crate::error::CliError; pub(super) async fn execute(command: HookForwardCommand) -> Result<(), CliError> { - crate::installer::hook_forward(command).await + crate::hooks::hook_forward(command).await } diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index c71306e5d..bc058c04b 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -3,7 +3,7 @@ use std::process::ExitCode; -use crate::config::{InstallCommand, UninstallCommand}; +use crate::configuration::{InstallCommand, UninstallCommand}; use crate::error::CliError; pub(super) fn install(command: InstallCommand) -> Result { diff --git a/crates/cli/src/commands/mcp.rs b/crates/cli/src/commands/mcp.rs index 3ea88792d..dd91813b8 100644 --- a/crates/cli/src/commands/mcp.rs +++ b/crates/cli/src/commands/mcp.rs @@ -3,7 +3,7 @@ use std::process::ExitCode; -use crate::config::ServerArgs; +use crate::configuration::ServerArgs; use crate::error::CliError; pub(super) async fn execute(server: &ServerArgs) -> Result { diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 4dbca28f4..ab9945145 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -17,10 +17,13 @@ use std::process::ExitCode; use clap::Parser; -use crate::config::{Cli, CodingAgent, Command, ServerArgs}; +use crate::configuration::{Cli, CodingAgent, Command, ServerArgs}; #[cfg(test)] -use crate::config::{CompletionsCommand, PluginsCommand, PricingCommand}; -use crate::{config, doctor, error, server, setup}; +use crate::configuration::{CompletionsCommand, PluginsCommand, PricingCommand}; +use crate::{ + configuration as runtime_configuration, diagnostics as runtime_diagnostics, error, server, + setup, +}; // Runs the async CLI entrypoint and converts any surfaced gateway error into a non-zero process // exit. Errors are printed once here so subcommands can return structured errors without also @@ -67,7 +70,7 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result plugins::execute(command, server), Command::ModelPricing(command) => model_pricing::execute(command), Command::Doctor(command) => diagnostics::execute(command).await, - Command::Agents(command) => doctor::run_agents(command.json).await, + Command::Agents(command) => runtime_diagnostics::run_agents(command.json).await, Command::Completions(command) => completions::execute(command), } } @@ -90,13 +93,16 @@ async fn run_default(server_args: &ServerArgs) -> Result Result Result { match command.command { - PricingSubcommand::Validate(command) => crate::model_pricing::validate(command)?, - PricingSubcommand::Init(command) => crate::model_pricing::init(command)?, - PricingSubcommand::AddSource(command) => crate::model_pricing::add_source(command)?, - PricingSubcommand::Resolve(command) => crate::model_pricing::resolve(command)?, + PricingSubcommand::Validate(command) => crate::plugins::pricing::validate(command)?, + PricingSubcommand::Init(command) => crate::plugins::pricing::init(command)?, + PricingSubcommand::AddSource(command) => crate::plugins::pricing::add_source(command)?, + PricingSubcommand::Resolve(command) => crate::plugins::pricing::resolve(command)?, } Ok(ExitCode::SUCCESS) } diff --git a/crates/cli/src/commands/plugins.rs b/crates/cli/src/commands/plugins.rs index 6436b29bc..122601fe7 100644 --- a/crates/cli/src/commands/plugins.rs +++ b/crates/cli/src/commands/plugins.rs @@ -3,7 +3,7 @@ use std::process::ExitCode; -use crate::config::{PluginsCommand, PluginsSubcommand, ServerArgs}; +use crate::configuration::{PluginsCommand, PluginsSubcommand, ServerArgs}; use crate::error::CliError; pub(super) fn execute(command: PluginsCommand, server: &ServerArgs) -> Result { diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index 03c9db0c0..ee19ee8c0 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -4,14 +4,14 @@ use std::process::ExitCode; use crate::agents::CodingAgent; -use crate::config::{EasyPathCommand, RunCommand, ServerArgs}; +use crate::configuration::{EasyPathCommand, RunCommand, ServerArgs}; use crate::error::CliError; pub(super) async fn execute( command: RunCommand, server: &ServerArgs, ) -> Result { - crate::launcher::run(command, Some(server)).await + crate::process::launcher::run(command, Some(server)).await } pub(super) async fn easy_path( @@ -19,5 +19,5 @@ pub(super) async fn easy_path( command: EasyPathCommand, server: &ServerArgs, ) -> Result { - crate::launcher::easy_path(agent, command, Some(server)).await + crate::process::launcher::easy_path(agent, command, Some(server)).await } diff --git a/crates/cli/src/completions_install.rs b/crates/cli/src/completions_install.rs index 296f9d1a9..4714508d3 100644 --- a/crates/cli/src/completions_install.rs +++ b/crates/cli/src/completions_install.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use clap_complete::Shell; -use crate::config::Cli; +use crate::configuration::Cli; use crate::error::CliError; /// Generates the completion script for `$SHELL` and writes it to the matching shell-specific diff --git a/crates/cli/src/config.rs b/crates/cli/src/configuration/mod.rs similarity index 99% rename from crates/cli/src/config.rs rename to crates/cli/src/configuration/mod.rs index 2f2d080e8..5867f16c5 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/configuration/mod.rs @@ -24,7 +24,7 @@ use strum::{Display, IntoStaticStr}; pub(crate) use crate::agents::CodingAgent; use crate::error::CliError; -use crate::file_io::{LockAttempt, try_lock_exclusive, try_lock_shared}; +use crate::filesystem::{LockAttempt, try_lock_exclusive, try_lock_shared}; #[cfg(test)] use crate::plugins::lifecycle::active_dynamic_plugin_components; use crate::plugins::lifecycle::{ @@ -1448,7 +1448,7 @@ fn load_or_create_bootstrap_hmac_key_at_with_timeout( )) })?; #[cfg(windows)] - crate::file_io::protect_private_windows_path(parent).map_err(|error| { + crate::filesystem::protect_private_windows_path(parent).map_err(|error| { CliError::Config(format!( "failed to protect bootstrap state directory {}: {error}", parent.display() @@ -1467,7 +1467,7 @@ fn load_or_create_bootstrap_hmac_key_at_with_timeout( })?; #[cfg(windows)] - let mut file = crate::file_io::open_private_windows_file(path).map_err(|error| { + let mut file = crate::filesystem::open_private_windows_file(path).map_err(|error| { CliError::Config(format!( "failed to open bootstrap HMAC key {}: {error}", path.display() @@ -2285,5 +2285,5 @@ impl GatewayMode { } #[cfg(test)] -#[path = "../tests/coverage/shared/config_tests.rs"] +#[path = "../../tests/coverage/shared/config_tests.rs"] mod tests; diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/diagnostics/mod.rs similarity index 99% rename from crates/cli/src/doctor.rs rename to crates/cli/src/diagnostics/mod.rs index a629dc9db..348621720 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -25,7 +25,7 @@ use tokio::time::timeout; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use uuid::Uuid; -use crate::config::{ +use crate::configuration::{ AgentConfigs, CodingAgent, DynamicPluginHostConfigStatus, GatewayConfig, ResolvedConfig, ServerArgs, default_plugin_config_paths, effective_plugin_toml_sources, resolve_server_config, }; @@ -211,7 +211,7 @@ fn collect_configuration( home: Option<&Path>, resolution: Check, configured_agents: Vec, - dynamic_plugins: &[crate::config::ResolvedDynamicPluginConfig], + dynamic_plugins: &[crate::configuration::ResolvedDynamicPluginConfig], plugin_diagnostics: &PluginConfigurationDiagnostics, ) -> ConfigurationInfo { let workspace_path = cwd @@ -219,7 +219,7 @@ fn collect_configuration( .unwrap_or_else(|| PathBuf::from(".nemo-relay/config.toml")); // Use the same XDG-aware resolver the config loader uses, so doctor reports the path the // runtime would actually read instead of a hard-coded `$HOME/.config/nemo-relay`. - let global_path = crate::config::user_config_dir() + let global_path = crate::configuration::user_config_dir() .map(|dir| dir.join("config.toml")) .or_else(|| home.map(|h| h.join(".config").join("nemo-relay").join("config.toml"))) .unwrap_or_else(|| PathBuf::from("~/.config/nemo-relay/config.toml")); @@ -400,12 +400,12 @@ async fn collect_agents( let configured = agent_configured(agent, &resolved.agents); let target_requested = target_agent == Some(agent); let command = agent_command(agent, &resolved.agents); - let argv = crate::agent_process::command_argv(&command); + let argv = crate::process::command_argv(&command); let exec = argv.first().map(String::as_str).unwrap_or_default(); - let path = crate::agent_process::resolve_executable(exec); + let path = crate::process::resolve_executable(exec); let version = match &path { Some(_) => { - let probe = crate::agent_process::version_probe_argv(agent, &argv); + let probe = crate::process::version_probe_argv(agent, &argv); probe_version(&probe).await } None => None, @@ -540,7 +540,7 @@ fn hook_status(agent: CodingAgent, agents: &AgentConfigs) -> (Status, String) { async fn probe_version(argv: &[String]) -> Option { // Run the shared wrapper-preserving probe and read the first line of stdout. Bounded by the network // timeout (re-used as a generic short timeout) so a misbehaving binary doesn't hang doctor. - let mut cmd = crate::agent_process::tokio_command(argv); + let mut cmd = crate::process::tokio_command(argv); cmd.stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .stdin(std::process::Stdio::null()) @@ -1592,5 +1592,5 @@ const _: fn() = || { }; #[cfg(test)] -#[path = "../tests/coverage/shared/doctor_tests.rs"] +#[path = "../../tests/coverage/shared/doctor_tests.rs"] mod tests; diff --git a/crates/cli/src/json_path.rs b/crates/cli/src/events/json_path.rs similarity index 100% rename from crates/cli/src/json_path.rs rename to crates/cli/src/events/json_path.rs diff --git a/crates/cli/src/model.rs b/crates/cli/src/events/mod.rs similarity index 99% rename from crates/cli/src/model.rs rename to crates/cli/src/events/mod.rs index 32b1bcfe4..e14a2374e 100644 --- a/crates/cli/src/model.rs +++ b/crates/cli/src/events/mod.rs @@ -3,6 +3,8 @@ use serde_json::Value; +pub(crate) mod json_path; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum AgentKind { Codex, diff --git a/crates/cli/src/file_io.rs b/crates/cli/src/filesystem/mod.rs similarity index 99% rename from crates/cli/src/file_io.rs rename to crates/cli/src/filesystem/mod.rs index 922acf428..75f4703dd 100644 --- a/crates/cli/src/file_io.rs +++ b/crates/cli/src/filesystem/mod.rs @@ -586,5 +586,5 @@ fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { } #[cfg(test)] -#[path = "../tests/coverage/shared/file_io_tests.rs"] +#[path = "../../tests/coverage/shared/file_io_tests.rs"] mod tests; diff --git a/crates/cli/src/gateway.rs b/crates/cli/src/gateway/mod.rs similarity index 99% rename from crates/cli/src/gateway.rs rename to crates/cli/src/gateway/mod.rs index ca1436f24..cde9eb90d 100644 --- a/crates/cli/src/gateway.rs +++ b/crates/cli/src/gateway/mod.rs @@ -27,10 +27,10 @@ use nemo_relay::error::FlowError; use serde_json::{Map, Value, json}; use crate::agents::alignment::{self, GatewayRouteKind}; -use crate::config::{BOOTSTRAP_CLIENT_TOKEN_HEADER, header_string}; +use crate::configuration::{BOOTSTRAP_CLIENT_TOKEN_HEADER, header_string}; use crate::error::CliError; use crate::server::AppState; -use crate::session::{GatewayCallPrep, GatewaySessionFinish, LlmGatewayStart, SessionManager}; +use crate::sessions::{GatewayCallPrep, GatewaySessionFinish, LlmGatewayStart, SessionManager}; /// Proxies supported LLM API requests through NeMo Relay's managed execution pipeline. /// @@ -76,7 +76,7 @@ struct PreparedGatewayRequest { // for both upstream forwarding and NeMo Relay LLM start events. Provider JSON parse failures are not // request failures because the gateway still forwards raw bytes unchanged. async fn prepare_gateway_request( - config: &crate::config::GatewayConfig, + config: &crate::configuration::GatewayConfig, request: Request, allow_environment_provider_auth: bool, ) -> Result { @@ -1059,7 +1059,11 @@ impl ProviderRoute { // Builds the upstream URL by combining the configured provider base with the original path and // query string. Trailing slashes are stripped from the base to avoid double-slash variants in // configured enterprise or local proxy endpoints. - fn upstream_url(self, config: &crate::config::GatewayConfig, path_and_query: &str) -> String { + fn upstream_url( + self, + config: &crate::configuration::GatewayConfig, + path_and_query: &str, + ) -> String { let base = match self { Self::OpenAiResponses | Self::OpenAiChatCompletions | Self::OpenAiModels => { config.openai_base_url.as_str() @@ -1282,5 +1286,5 @@ fn is_hop_by_hop(name: &HeaderName) -> bool { } #[cfg(test)] -#[path = "../tests/coverage/shared/gateway_tests.rs"] +#[path = "../../tests/coverage/shared/gateway_tests.rs"] mod tests; diff --git a/crates/cli/src/installer.rs b/crates/cli/src/hooks/mod.rs similarity index 98% rename from crates/cli/src/installer.rs rename to crates/cli/src/hooks/mod.rs index 270ebe4a9..bb5a762b6 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/hooks/mod.rs @@ -11,11 +11,11 @@ use futures_util::StreamExt; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; use serde_json::{Value, json}; -use crate::config::{ +use crate::configuration::{ CodingAgent, GATEWAY_URL_ENV, GatewayMode, HookForwardCommand, TRANSPARENT_RUN_ENV, }; use crate::error::CliError; -use crate::install_generation::InstallGeneration; +use crate::installation::generation::InstallGeneration; const HOOK_FORWARD_TIMEOUT: Duration = Duration::from_secs(2); const HOOK_GATEWAY_RETRY_TIMEOUT: Duration = Duration::from_secs(20); @@ -82,13 +82,10 @@ pub(crate) async fn hook_forward(command: HookForwardCommand) -> Result<(), CliE } else { None }; - let input = match read_hook_payload( - persistent - .as_ref() - .map_or(crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, |launch| { - launch.max_hook_payload_bytes - }), - ) { + let input = match read_hook_payload(persistent.as_ref().map_or( + crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + |launch| launch.max_hook_payload_bytes, + )) { Ok(input) => input, Err(error) => return handle_hook_error(error, fail_closed), }; @@ -253,8 +250,9 @@ fn recovery_plan(gateway_url: &str) -> Result Result { let bind = crate::sidecar::loopback_bind(gateway_url).map_err(CliError::Install)?; - Ok(crate::sidecar::GatewaySpec::new(bind) - .with_fingerprint(crate::config::transparent_gateway_fingerprint(gateway_url))) + Ok(crate::sidecar::GatewaySpec::new(bind).with_fingerprint( + crate::configuration::transparent_gateway_fingerprint(gateway_url), + )) } async fn send_verified_hook_forward_request( @@ -896,5 +894,5 @@ fn insert_header( } #[cfg(test)] -#[path = "../tests/coverage/shared/installer_tests.rs"] +#[path = "../../tests/coverage/shared/installer_tests.rs"] mod tests; diff --git a/crates/cli/src/install_generation.rs b/crates/cli/src/installation/generation.rs similarity index 99% rename from crates/cli/src/install_generation.rs rename to crates/cli/src/installation/generation.rs index 56c1ec612..71aa6b3b4 100644 --- a/crates/cli/src/install_generation.rs +++ b/crates/cli/src/installation/generation.rs @@ -13,7 +13,9 @@ use std::time::{Duration, Instant}; use base64::Engine; -use crate::file_io::{LockAttempt, atomic_write, try_lock_exclusive, try_lock_shared, unlock_file}; +use crate::filesystem::{ + LockAttempt, atomic_write, try_lock_exclusive, try_lock_shared, unlock_file, +}; pub(crate) const GENERATION_FILE_ENV: &str = "NEMO_RELAY_MCP_GENERATION_FILE"; pub(crate) const GENERATION_TOKEN_ENV: &str = "NEMO_RELAY_MCP_GENERATION"; @@ -1205,5 +1207,5 @@ fn retired_generation_error(path: &Path) -> String { } #[cfg(test)] -#[path = "../tests/coverage/shared/install_generation_tests.rs"] +#[path = "../../tests/coverage/shared/install_generation_tests.rs"] mod tests; diff --git a/crates/cli/src/installation/mod.rs b/crates/cli/src/installation/mod.rs new file mode 100644 index 000000000..3077bbbcc --- /dev/null +++ b/crates/cli/src/installation/mod.rs @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Transactional installation primitives. + +pub(crate) mod generation; diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index bba26d358..114ce827f 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -3,27 +3,24 @@ //! Internal application library for the `nemo-relay` executable. -mod agent_process; mod agents; mod banner; mod commands; mod completions_install; -mod config; -mod doctor; +mod configuration; +mod diagnostics; mod error; -mod file_io; +mod events; +mod filesystem; mod gateway; -mod install_generation; -mod installer; -mod json_path; -mod launcher; +mod hooks; +mod installation; mod mcp; mod mcp_environment; -mod model; -mod model_pricing; mod plugins; +mod process; mod server; -mod session; +mod sessions; mod setup; mod sidecar; diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs index 1fba5447a..68a090984 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -14,9 +14,9 @@ use std::process::ExitCode; use serde_json::{Value, json}; -use crate::config::ServerArgs; +use crate::configuration::ServerArgs; use crate::error::CliError; -use crate::install_generation::{GENERATION_FILE_ENV, GENERATION_TOKEN_ENV}; +use crate::installation::generation::{GENERATION_FILE_ENV, GENERATION_TOKEN_ENV}; pub(crate) const SERVER_NAME: &str = "nemo-relay"; const LAUNCH_ARGS: &[&str] = &["mcp"]; @@ -26,14 +26,15 @@ pub(crate) async fn run(server_args: &ServerArgs) -> Result // An installed plugin can still be enabled inside `nemo-relay run`. In that process the // wrapper already owns a healthy dynamic gateway, so this MCP instance authenticates and // monitors it instead of launching the fixed persistent sidecar. - let gateway_url = std::env::var(crate::config::GATEWAY_URL_ENV).map_err(|_| { + let gateway_url = std::env::var(crate::configuration::GATEWAY_URL_ENV).map_err(|_| { CliError::Launch(format!( "{} is required when {}=1", - crate::config::GATEWAY_URL_ENV, - crate::config::TRANSPARENT_RUN_ENV + crate::configuration::GATEWAY_URL_ENV, + crate::configuration::TRANSPARENT_RUN_ENV )) })?; - let bootstrap_fingerprint = crate::config::transparent_gateway_fingerprint(&gateway_url); + let bootstrap_fingerprint = + crate::configuration::transparent_gateway_fingerprint(&gateway_url); let lease = gateway::GatewayLease::borrow(gateway_url, bootstrap_fingerprint).await?; let frames = transport::spawn_stdin_reader()?; session::run(lease, frames, tokio::io::stdout()).await?; @@ -73,7 +74,7 @@ pub(crate) fn persistent_server( } fn transparent_run_active() -> bool { - std::env::var(crate::config::TRANSPARENT_RUN_ENV) + std::env::var(crate::configuration::TRANSPARENT_RUN_ENV) .ok() .as_deref() == Some("1") diff --git a/crates/cli/src/mcp/gateway.rs b/crates/cli/src/mcp/gateway.rs index 654e7dc26..a32c05e8b 100644 --- a/crates/cli/src/mcp/gateway.rs +++ b/crates/cli/src/mcp/gateway.rs @@ -6,9 +6,9 @@ use std::net::SocketAddr; use std::time::Duration; -use crate::config::ServerArgs; +use crate::configuration::ServerArgs; use crate::error::CliError; -use crate::install_generation::{ActiveGenerationGuard, InstallGeneration}; +use crate::installation::generation::{ActiveGenerationGuard, InstallGeneration}; use crate::sidecar::{GatewayEndpoint, GatewaySpec}; const UNHEALTHY_CONFIRMATIONS: u8 = 3; @@ -123,7 +123,7 @@ impl GatewayLease { .ok_or_else(|| { CliError::Launch(format!( "{} does not identify the authenticated NeMo Relay gateway owned by this transparent run", - crate::config::GATEWAY_URL_ENV + crate::configuration::GATEWAY_URL_ENV )) })?; let monitor = tokio::spawn(async move { diff --git a/crates/cli/src/mcp_environment.rs b/crates/cli/src/mcp_environment.rs index b12d1ed67..ef27f1890 100644 --- a/crates/cli/src/mcp_environment.rs +++ b/crates/cli/src/mcp_environment.rs @@ -7,7 +7,7 @@ use std::collections::BTreeSet; use serde_json::Value; -use crate::install_generation::{GENERATION_FILE_ENV, GENERATION_TOKEN_ENV}; +use crate::installation::generation::{GENERATION_FILE_ENV, GENERATION_TOKEN_ENV}; const BASE_MCP_ENV_VARS: &[&str] = &[ "ALL_PROXY", diff --git a/crates/cli/src/plugins.rs b/crates/cli/src/plugins.rs index d7c25a088..c0ae61809 100644 --- a/crates/cli/src/plugins.rs +++ b/crates/cli/src/plugins.rs @@ -16,7 +16,7 @@ use dialoguer::{Input, Select}; use nemo_relay::config_editor::{EditorFieldKind, EditorFieldSpec}; use serde_json::{Value, json}; -use crate::config::PluginsEditCommand; +use crate::configuration::PluginsEditCommand; use crate::error::CliError; pub(crate) mod config_io; @@ -24,6 +24,7 @@ mod dynamic_editor; mod editor_model; pub(crate) mod lifecycle; pub(crate) mod policy; +pub(crate) mod pricing; pub(crate) mod schema; use self::config_io::*; diff --git a/crates/cli/src/plugins/config_io.rs b/crates/cli/src/plugins/config_io.rs index 2f07c89b6..ce777ddfa 100644 --- a/crates/cli/src/plugins/config_io.rs +++ b/crates/cli/src/plugins/config_io.rs @@ -12,7 +12,7 @@ use nemo_relay_pii_redaction::component::register_pii_redaction_component; use serde::Serialize; use serde_json::{Map, Value}; -use crate::config::{ +use crate::configuration::{ PluginsScopeArgs, global_plugin_config_path, project_plugin_config_path, user_plugin_config_path, }; @@ -423,7 +423,7 @@ pub(crate) fn remove_dynamic_plugin_reference( target_manifest_ref .as_ref() .is_some_and(|target_manifest_ref| manifest_ref == target_manifest_ref) - || crate::config::load_bounded_dynamic_plugin_manifest(manifest_ref) + || crate::configuration::load_bounded_dynamic_plugin_manifest(manifest_ref) .map(|(manifest, _)| manifest.plugin.id.trim() == plugin_id) .unwrap_or(false) }); diff --git a/crates/cli/src/plugins/dynamic_editor.rs b/crates/cli/src/plugins/dynamic_editor.rs index 8a269fd25..2627c6e6c 100644 --- a/crates/cli/src/plugins/dynamic_editor.rs +++ b/crates/cli/src/plugins/dynamic_editor.rs @@ -282,7 +282,7 @@ fn load_dynamic_plugin_state( entry: DynamicPluginConfigEntry, plugin_ids: &mut HashSet, ) -> Result { - let (manifest, manifest_ref) = crate::config::load_bounded_dynamic_plugin_manifest( + let (manifest, manifest_ref) = crate::configuration::load_bounded_dynamic_plugin_manifest( &entry.manifest_path, ) .map_err(|error| { diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index 27240f8f8..22924a5ce 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -17,7 +17,7 @@ use nemo_relay::plugin::dynamic::{ use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; -use crate::config::{ +use crate::configuration::{ MAX_BOOTSTRAP_IDENTITY_FILE_BYTES, PluginsAddCommand, PluginsDisableCommand, PluginsEnableCommand, PluginsInspectCommand, PluginsListCommand, PluginsRemoveCommand, PluginsValidateCommand, ResolvedConfig, ResolvedDynamicPluginConfig, ServerArgs, @@ -2067,7 +2067,7 @@ fn load_manifest_for_action( path: impl Into, ) -> Result<(DynamicPluginManifest, String), CliError> { let path = path.into(); - crate::config::load_bounded_dynamic_plugin_manifest(&path) + crate::configuration::load_bounded_dynamic_plugin_manifest(&path) .map_err(|error| CliError::Config(format!("dynamic plugin {action} failed: {error}"))) } @@ -2119,7 +2119,7 @@ fn ensure_scope( scopes.len() - 1 } -fn scope_flags_selected(scope: &crate::config::PluginsScopeArgs) -> bool { +fn scope_flags_selected(scope: &crate::configuration::PluginsScopeArgs) -> bool { scope.user || scope.project || scope.global } @@ -2211,7 +2211,9 @@ fn required_startup_failure( )); } - if let Err(error) = crate::config::load_bounded_dynamic_plugin_manifest(&manifest_ref) { + if let Err(error) = + crate::configuration::load_bounded_dynamic_plugin_manifest(&manifest_ref) + { return Some(format!( "- {}: required dynamic plugin manifest at {} is unreadable: {}", entry.record.metadata.id, diff --git a/crates/cli/src/plugins/lifecycle/environment.rs b/crates/cli/src/plugins/lifecycle/environment.rs index 10fee5167..783591788 100644 --- a/crates/cli/src/plugins/lifecycle/environment.rs +++ b/crates/cli/src/plugins/lifecycle/environment.rs @@ -289,7 +289,7 @@ pub(super) fn read_environment_attestation( attestation_path.display() )); } - if !crate::config::verify_python_environment_attestation( + if !crate::configuration::verify_python_environment_attestation( &attestation.source_artifact_sha256, &attestation.environment_sha256, &attestation.authentication, @@ -326,7 +326,7 @@ pub(super) fn write_environment_attestation( let digest = environment_tree_digest(environment)?; let path = environment.join(ENVIRONMENT_ATTESTATION_FILE); let authentication = - crate::config::sign_python_environment_attestation(source_artifact_sha256, &digest) + crate::configuration::sign_python_environment_attestation(source_artifact_sha256, &digest) .map_err(|error| error.to_string())?; let mut bytes = serde_json::to_vec_pretty(&EnvironmentAttestation { version: 1, @@ -464,13 +464,15 @@ fn digest_environment_directory( path.display() )); } - let bytes = - crate::config::read_bounded_regular_file(&source, "managed Python environment file")?; + let bytes = crate::configuration::read_bounded_regular_file( + &source, + "managed Python environment file", + )?; *total = total.saturating_add(bytes.len() as u64); - if *total > crate::config::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { + if *total > crate::configuration::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { return Err(format!( "managed Python environment exceeds the {}-byte attestation budget", - crate::config::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + crate::configuration::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES )); } digest.update(relative.to_string_lossy().as_bytes()); diff --git a/crates/cli/src/plugins/lifecycle/responses.rs b/crates/cli/src/plugins/lifecycle/responses.rs index 0eb1ace6c..247e990ec 100644 --- a/crates/cli/src/plugins/lifecycle/responses.rs +++ b/crates/cli/src/plugins/lifecycle/responses.rs @@ -17,7 +17,7 @@ use nemo_relay::plugin::dynamic::{ use serde::Serialize; use serde_json::{Map, Value}; -use crate::config::{DynamicPluginHostConfigStatus, ResolvedDynamicPluginConfig}; +use crate::configuration::{DynamicPluginHostConfigStatus, ResolvedDynamicPluginConfig}; use crate::error::{CliError, PluginLifecycleFailureKind}; use crate::plugins::policy::EvaluatedDynamicPluginHostPolicy; diff --git a/crates/cli/src/plugins/lifecycle/state.rs b/crates/cli/src/plugins/lifecycle/state.rs index 6d047d514..5d01aab22 100644 --- a/crates/cli/src/plugins/lifecycle/state.rs +++ b/crates/cli/src/plugins/lifecycle/state.rs @@ -9,7 +9,7 @@ use nemo_relay::plugin::dynamic::{DynamicPluginRecord, DynamicPluginRegistry}; use serde::{Deserialize, Serialize}; use strum::{Display, IntoStaticStr}; -use crate::config::{ +use crate::configuration::{ PLUGINS_TOML, global_plugin_config_path, project_plugin_config_path, user_config_dir, user_plugin_config_path, }; diff --git a/crates/cli/src/plugins/lifecycle/trust.rs b/crates/cli/src/plugins/lifecycle/trust.rs index 82a2de462..5136ec0c7 100644 --- a/crates/cli/src/plugins/lifecycle/trust.rs +++ b/crates/cli/src/plugins/lifecycle/trust.rs @@ -318,11 +318,11 @@ fn verify_signature( let signature_path = resolve_artifact_path(manifest_ref, signature_ref); let signature_bytes = read_signature_bytes(&signature_path)?; let artifact_bytes = - crate::config::read_bounded_regular_file(artifact_path, "dynamic plugin artifact") + crate::configuration::read_bounded_regular_file(artifact_path, "dynamic plugin artifact") .map_err(|error| DynamicPluginTrustFailure::ArtifactRead { - path: artifact_path.to_path_buf(), - error, - })?; + path: artifact_path.to_path_buf(), + error, + })?; let mut parse_errors = Vec::new(); for trusted_public_key in trusted_public_keys { @@ -348,12 +348,11 @@ fn verify_signature( } fn read_signature_bytes(path: &Path) -> TrustResult> { - let raw = crate::config::read_bounded_regular_file(path, "dynamic plugin signature").map_err( - |error| DynamicPluginTrustFailure::SignatureRead { + let raw = crate::configuration::read_bounded_regular_file(path, "dynamic plugin signature") + .map_err(|error| DynamicPluginTrustFailure::SignatureRead { path: path.to_path_buf(), error, - }, - )?; + })?; let trimmed = String::from_utf8_lossy(&raw).trim().to_owned(); if trimmed.is_empty() { return Err(DynamicPluginTrustFailure::SignatureRead { @@ -403,7 +402,7 @@ fn resolve_artifact_path(manifest_ref: &str, artifact_ref: &str) -> PathBuf { fn file_sha256(path: &Path) -> Result { let mut digest = Sha256::new(); - crate::config::stream_bounded_regular_file(path, "dynamic plugin artifact", |bytes| { + crate::configuration::stream_bounded_regular_file(path, "dynamic plugin artifact", |bytes| { digest.update(bytes); }) .map_err(std::io::Error::other)?; diff --git a/crates/cli/src/model_pricing.rs b/crates/cli/src/plugins/pricing.rs similarity index 99% rename from crates/cli/src/model_pricing.rs rename to crates/cli/src/plugins/pricing.rs index 87ea8a9d6..c6914a60e 100644 --- a/crates/cli/src/model_pricing.rs +++ b/crates/cli/src/plugins/pricing.rs @@ -12,7 +12,7 @@ use nemo_relay::codec::response::Usage; use nemo_relay::plugin::{PluginComponentSpec, PluginConfig}; use serde_json::Value; -use crate::config::{ +use crate::configuration::{ PricingAddSourceCommand, PricingInitCommand, PricingResolveCommand, PricingScopeArgs, PricingValidateCommand, ServerArgs, resolve_server_config, }; @@ -285,5 +285,5 @@ fn plural<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str { } #[cfg(test)] -#[path = "../tests/coverage/commands/model_pricing_tests.rs"] +#[path = "../../tests/coverage/commands/model_pricing_tests.rs"] mod tests; diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/process/launcher.rs similarity index 98% rename from crates/cli/src/launcher.rs rename to crates/cli/src/process/launcher.rs index 41e4d2cc7..513fe1aaa 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -14,13 +14,13 @@ use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio::task::JoinHandle; -use crate::config::{ +use crate::configuration::{ AgentConfigs, CodingAgent, EasyPathCommand, GatewayConfig, RELAY_PLUGIN_ID, RELAY_SOURCE_PLUGIN_ID, ResolvedConfig, RunCommand, ServerArgs, any_config_file_exists, resolve_run_config, }; use crate::error::CliError; -use crate::installer::{generated_hooks, transparent_hook_forward_command}; +use crate::hooks::{generated_hooks, transparent_hook_forward_command}; use crate::plugins::lifecycle::ActiveDynamicPluginComponent; use crate::server; @@ -113,7 +113,7 @@ impl TransparentRun { let invocation = resolve_agent_invocation(&command, &resolved.agents)?; let agent = invocation.agent; if !dry_run { - let probe = crate::agent_process::version_probe_argv( + let probe = crate::process::version_probe_argv( agent, &invocation.argv[..=invocation.host_index], ); @@ -183,7 +183,7 @@ async fn execute_live_run_with_dynamic( gateway_url: &str, prepared: PreparedRun, ) -> Result { - let bootstrap_fingerprint = crate::config::transparent_gateway_fingerprint(gateway_url); + let bootstrap_fingerprint = crate::configuration::transparent_gateway_fingerprint(gateway_url); let running_server = RunningGateway::start( listener, gateway_config, @@ -302,7 +302,7 @@ const fn default_command_for(agent: CodingAgent) -> &'static str { /// Builds a version probe that preserves wrappers such as `npx codex` or `mise exec -- codex`. /// Opaque wrappers remain supported when their `--version` output identifies the selected host. async fn validate_agent_version(agent: CodingAgent, probe: &[String]) -> Result<(), CliError> { - let mut command = crate::agent_process::tokio_command(probe); + let mut command = crate::process::tokio_command(probe); command .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) @@ -343,7 +343,7 @@ async fn validate_agent_version(agent: CodingAgent, probe: &[String]) -> Result< // commands should be passed after `--` by the caller. fn configured_command(agent: CodingAgent, agents: &AgentConfigs) -> Option> { let command = agents.get(agent).command.as_ref()?; - let argv = crate::agent_process::command_argv(command); + let argv = crate::process::command_argv(command); (!argv.is_empty()).then_some(argv) } @@ -365,7 +365,7 @@ impl RunningGateway { // task handle so health failures and normal exits use identical cleanup semantics. fn start( listener: TcpListener, - config: crate::config::GatewayConfig, + config: crate::configuration::GatewayConfig, dynamic_plugins: Vec, bootstrap_fingerprint: String, ) -> Self { @@ -450,8 +450,11 @@ impl PreparedRun { argv, host_index, env: vec![ - (crate::config::GATEWAY_URL_ENV.into(), gateway_url.into()), - (crate::config::TRANSPARENT_RUN_ENV.into(), "1".into()), + ( + crate::configuration::GATEWAY_URL_ENV.into(), + gateway_url.into(), + ), + (crate::configuration::TRANSPARENT_RUN_ENV.into(), "1".into()), ], temp_dirs: Vec::new(), notes: Vec::new(), @@ -529,7 +532,7 @@ impl PreparedRun { let settings = claude_settings_overlay(&self.argv, self.host_index, gateway_url)?; let settings_bytes = serde_json::to_vec_pretty(&settings) .map_err(|error| CliError::Launch(error.to_string()))?; - crate::file_io::atomic_write_private(&settings_path, &settings_bytes) + crate::filesystem::atomic_write_private(&settings_path, &settings_bytes) .map_err(CliError::Launch)?; insert_after_host( &mut self.argv, @@ -618,7 +621,7 @@ impl PreparedRun { .env .iter() .find_map(|(name, value)| { - (name == crate::config::GATEWAY_URL_ENV).then_some(value.as_str()) + (name == crate::configuration::GATEWAY_URL_ENV).then_some(value.as_str()) }) .expect("transparent runs always define their gateway URL"); let overlay_home = create_hermes_overlay(source_home, &source_config, gateway_url)?; @@ -647,12 +650,12 @@ impl PreparedRun { // Spawns the prepared child process with injected environment. // Stdio is inherited by default so agent interaction remains unchanged in transparent mode. - async fn spawn(&self) -> Result { - let mut command = crate::agent_process::tokio_command(&self.argv); + async fn spawn(&self) -> Result { + let mut command = crate::process::tokio_command(&self.argv); for (name, value) in &self.env { command.env(name, value); } - crate::agent_process::SupervisedChild::spawn(&mut command) + crate::process::SupervisedChild::spawn(&mut command) .await .map_err(CliError::from) } @@ -1420,5 +1423,5 @@ fn private_temp_dir(parent: &Path, prefix: &str) -> Result { } #[cfg(test)] -#[path = "../tests/coverage/agents/launcher_tests.rs"] +#[path = "../../tests/coverage/agents/launcher_tests.rs"] mod tests; diff --git a/crates/cli/src/agent_process.rs b/crates/cli/src/process/mod.rs similarity index 97% rename from crates/cli/src/agent_process.rs rename to crates/cli/src/process/mod.rs index 27057b971..cad4f30a9 100644 --- a/crates/cli/src/agent_process.rs +++ b/crates/cli/src/process/mod.rs @@ -3,11 +3,13 @@ //! Shared coding-agent command parsing, discovery, and process construction. +pub(crate) mod launcher; + use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::process::Command; -use crate::config::CodingAgent; +use crate::configuration::CodingAgent; /// Parses the intentionally simple command strings accepted by `[agents.*].command`. /// @@ -126,5 +128,5 @@ mod supervision; pub(crate) use supervision::SupervisedChild; #[cfg(test)] -#[path = "../tests/coverage/shared/agent_process_tests.rs"] +#[path = "../../tests/coverage/shared/agent_process_tests.rs"] mod tests; diff --git a/crates/cli/src/agent_process/supervision.rs b/crates/cli/src/process/supervision.rs similarity index 100% rename from crates/cli/src/agent_process/supervision.rs rename to crates/cli/src/process/supervision.rs diff --git a/crates/cli/src/agent_process/supervision/fallback.rs b/crates/cli/src/process/supervision/fallback.rs similarity index 100% rename from crates/cli/src/agent_process/supervision/fallback.rs rename to crates/cli/src/process/supervision/fallback.rs diff --git a/crates/cli/src/agent_process/supervision/unix.rs b/crates/cli/src/process/supervision/unix.rs similarity index 100% rename from crates/cli/src/agent_process/supervision/unix.rs rename to crates/cli/src/process/supervision/unix.rs diff --git a/crates/cli/src/agent_process/supervision/windows.rs b/crates/cli/src/process/supervision/windows.rs similarity index 100% rename from crates/cli/src/agent_process/supervision/windows.rs rename to crates/cli/src/process/supervision/windows.rs diff --git a/crates/cli/src/server.rs b/crates/cli/src/server/mod.rs similarity index 99% rename from crates/cli/src/server.rs rename to crates/cli/src/server/mod.rs index e33a7a46d..5cc101fea 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server/mod.rs @@ -30,13 +30,13 @@ use tokio::net::TcpListener; use tokio::sync::oneshot; use crate::agents::adapters::{claude_code, codex, hermes}; -use crate::config::{ +use crate::configuration::{ BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey, GatewayConfig, ManagedBootstrapIdentity, }; use crate::error::CliError; use crate::gateway; use crate::plugins::lifecycle::{ActiveDynamicPluginComponent, DynamicPluginActivationSnapshot}; -use crate::session::SessionManager; +use crate::sessions::SessionManager; const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(300); @@ -118,7 +118,7 @@ fn render_startup_status(bind: SocketAddr, config: &GatewayConfig, color: bool) "NeMo Relay".to_string(), format!(" Gateway http://{bind}"), ]; - let destinations = crate::launcher::exporter_destinations(config); + let destinations = crate::process::launcher::exporter_destinations(config); if destinations.is_empty() { lines.push(" Exporters not configured".into()); } else { @@ -135,7 +135,7 @@ fn render_startup_status(bind: SocketAddr, config: &GatewayConfig, color: bool) } } - crate::launcher::render_status_frame(&lines, color) + crate::process::launcher::render_status_frame(&lines, color) } /// Serves the gateway router on a caller-owned listener with optional graceful shutdown. @@ -829,5 +829,5 @@ fn hook_payload_rejection(rejection: JsonRejection) -> CliError { } #[cfg(test)] -#[path = "../tests/coverage/shared/server_tests.rs"] +#[path = "../../tests/coverage/shared/server_tests.rs"] mod tests; diff --git a/crates/cli/src/session.rs b/crates/cli/src/sessions/mod.rs similarity index 99% rename from crates/cli/src/session.rs rename to crates/cli/src/sessions/mod.rs index 35909df3b..0a864b863 100644 --- a/crates/cli/src/session.rs +++ b/crates/cli/src/sessions/mod.rs @@ -28,9 +28,9 @@ use crate::agents::alignment::{ self, GatewayManagementPolicy, PendingSubagentStart, SessionAlias, SessionAlignmentState, insert_optional, json_string_at, json_value_at, merge_metadata, }; -use crate::config::{GatewayConfig, SessionConfig}; +use crate::configuration::{GatewayConfig, SessionConfig}; use crate::error::CliError; -use crate::model::{ +use crate::events::{ AgentKind, LlmEvent, LlmHintEvent, NormalizedEvent, SessionEvent, SubagentEvent, ToolEvent, }; @@ -2603,5 +2603,5 @@ fn gateway_session_for_call( } #[cfg(test)] -#[path = "../tests/coverage/shared/session_tests.rs"] +#[path = "../../tests/coverage/shared/session_tests.rs"] mod tests; diff --git a/crates/cli/src/setup.rs b/crates/cli/src/setup.rs index 410b01920..57436b562 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/setup.rs @@ -18,7 +18,7 @@ use dialoguer::theme::ColorfulTheme; use dialoguer::{Confirm, MultiSelect, Select}; use toml_edit::DocumentMut; -use crate::config::CodingAgent; +use crate::configuration::CodingAgent; use crate::error::CliError; mod model; diff --git a/crates/cli/src/setup/model.rs b/crates/cli/src/setup/model.rs index 9147785e9..005a3a90f 100644 --- a/crates/cli/src/setup/model.rs +++ b/crates/cli/src/setup/model.rs @@ -7,8 +7,8 @@ use std::path::{Path, PathBuf}; use toml_edit::{DocumentMut, Item, Table, value}; -use crate::config::CodingAgent; -use crate::config::{PluginsEditCommand, PluginsScopeArgs}; +use crate::configuration::CodingAgent; +use crate::configuration::{PluginsEditCommand, PluginsScopeArgs}; use crate::error::CliError; /// Where the setup saves its output. @@ -85,8 +85,7 @@ pub(crate) fn detect_installed_agents_in(path_var: Option<&std::ffi::OsStr>) -> CodingAgent::ALL .into_iter() .filter(|agent| { - crate::agent_process::resolve_executable_in_path(agent.executable(), Some(path_var)) - .is_some() + crate::process::resolve_executable_in_path(agent.executable(), Some(path_var)).is_some() }) .collect() } diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs index 4ce0c303b..6fde2f9b3 100644 --- a/crates/cli/src/sidecar.rs +++ b/crates/cli/src/sidecar.rs @@ -19,7 +19,7 @@ use std::time::{Duration, Instant}; use serde::Deserialize; -use crate::config::{ServerArgs, resolve_persistent_server_config}; +use crate::configuration::{ServerArgs, resolve_persistent_server_config}; use crate::error::CliError; use health::{RelayHealth, probe_with_instance as probe_relay_health_with_instance}; pub(crate) use health::{ @@ -276,23 +276,23 @@ fn start_gateway(spec: &GatewaySpec, state: &Path) -> Result Result { } pub(crate) fn plugin_idle_timeout() -> Result { - let raw = env::var(crate::config::PLUGIN_IDLE_TIMEOUT_ENV).unwrap_or_else(|_| "300".into()); + let raw = + env::var(crate::configuration::PLUGIN_IDLE_TIMEOUT_ENV).unwrap_or_else(|_| "300".into()); let seconds = raw.parse::().map_err(|error| { format!( "{} must be a positive integer: {error}", - crate::config::PLUGIN_IDLE_TIMEOUT_ENV + crate::configuration::PLUGIN_IDLE_TIMEOUT_ENV ) })?; if seconds == 0 { return Err(format!( "{} must be greater than 0", - crate::config::PLUGIN_IDLE_TIMEOUT_ENV + crate::configuration::PLUGIN_IDLE_TIMEOUT_ENV )); } Ok(Duration::from_secs(seconds)) diff --git a/crates/cli/src/sidecar/health.rs b/crates/cli/src/sidecar/health.rs index 87bbc2b44..07fffef82 100644 --- a/crates/cli/src/sidecar/health.rs +++ b/crates/cli/src/sidecar/health.rs @@ -11,7 +11,7 @@ use reqwest::Url; use ring::rand::{SecureRandom, SystemRandom}; use serde_json::Value; -use crate::config::BootstrapChallengeKey; +use crate::configuration::BootstrapChallengeKey; use super::{BOOTSTRAP_PROTOCOL_VERSION, HEALTHZ_TIMEOUT}; diff --git a/crates/cli/src/sidecar/state.rs b/crates/cli/src/sidecar/state.rs index e781a4530..3a0878051 100644 --- a/crates/cli/src/sidecar/state.rs +++ b/crates/cli/src/sidecar/state.rs @@ -13,7 +13,7 @@ use std::time::{Duration, Instant}; use reqwest::Url; use serde::{Deserialize, Serialize}; -use crate::file_io::{LockAttempt, atomic_write, try_lock_exclusive}; +use crate::filesystem::{LockAttempt, atomic_write, try_lock_exclusive}; use super::health::{RelayHealth, probe, request_shutdown}; use super::{BOOTSTRAP_PROTOCOL_VERSION, SIDECAR_LOCK_TIMEOUT}; @@ -75,7 +75,7 @@ impl Drop for OwnerGuard { } pub(crate) fn state_dir() -> Result { - crate::config::user_config_dir() + crate::configuration::user_config_dir() .map(|path| path.join("bootstrap")) .ok_or_else(|| { "cannot determine the per-user NeMo Relay bootstrap state directory; set HOME or USERPROFILE" @@ -202,7 +202,7 @@ pub(crate) fn publish_owner_from_env(address: SocketAddr) -> Result SessionEvent { SessionEvent { diff --git a/crates/cli/tests/coverage/agents/hermes_tests.rs b/crates/cli/tests/coverage/agents/hermes_tests.rs index f0be937e0..d3a7a9587 100644 --- a/crates/cli/tests/coverage/agents/hermes_tests.rs +++ b/crates/cli/tests/coverage/agents/hermes_tests.rs @@ -10,7 +10,7 @@ use std::time::{Duration, UNIX_EPOCH}; use serde_json::{Value, json}; use super::*; -use crate::config::CodingAgent; +use crate::configuration::CodingAgent; const TEST_GENERATION_TOKEN: &str = "test-generation"; @@ -122,7 +122,7 @@ fn hook_command_round_trips_paths_and_platform_metacharacters() { "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward hermes --gateway-url http://127.0.0.1:47632 --generation-file /tmp/generation --generation-token test-generation" ); assert_eq!( - crate::installer::decode_windows_hook_command(&persistent_hook_command_for_platform( + crate::hooks::decode_windows_hook_command(&persistent_hook_command_for_platform( Path::new(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"), Path::new(r"C:\Temp\generation"), TEST_GENERATION_TOKEN, @@ -142,7 +142,7 @@ fn hook_command_round_trips_paths_and_platform_metacharacters() { ] ); assert_eq!( - crate::installer::transparent_hook_forward_command_for_platform( + crate::hooks::transparent_hook_forward_command_for_platform( relay, CodingAgent::Hermes, "http://127.0.0.1:1234", @@ -156,7 +156,7 @@ fn hook_command_round_trips_paths_and_platform_metacharacters() { TEST_GENERATION_TOKEN, true, ); - let encoded_codex = crate::installer::persistent_hook_forward_command_for_platform( + let encoded_codex = crate::hooks::persistent_hook_forward_command_for_platform( Path::new(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"), CodingAgent::Codex, Path::new(r"C:\Temp\generation"), @@ -582,7 +582,7 @@ fn install_is_verified_idempotent_and_rotates_the_generation() { .unwrap(); assert_eq!(written, paths.all()); let first_generation = - crate::install_generation::InstallGeneration::capture(paths.generation.clone()) + crate::installation::generation::InstallGeneration::capture(paths.generation.clone()) .unwrap() .token() .to_owned(); @@ -602,7 +602,7 @@ fn install_is_verified_idempotent_and_rotates_the_generation() { install_persistent_with(paths.clone(), &relay, &environment, None, now, atomic_write).unwrap(); let second_generation = - crate::install_generation::InstallGeneration::capture(paths.generation.clone()) + crate::installation::generation::InstallGeneration::capture(paths.generation.clone()) .unwrap() .token() .to_owned(); @@ -819,7 +819,8 @@ fn composed_install_rollback_restores_the_visible_preexisting_generation() { std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); let previous = - crate::install_generation::InstallGeneration::capture(paths.generation.clone()).unwrap(); + crate::installation::generation::InstallGeneration::capture(paths.generation.clone()) + .unwrap(); let mut retirement = GenerationRetirement::acquire(&paths.generation) .unwrap() .unwrap(); @@ -850,7 +851,7 @@ fn composed_install_rollback_restores_the_visible_preexisting_generation() { "{error}" ); previous.verify_current().unwrap(); - crate::install_generation::InstallGeneration::capture(paths.generation).unwrap(); + crate::installation::generation::InstallGeneration::capture(paths.generation).unwrap(); } #[test] @@ -981,7 +982,8 @@ fn composed_uninstall_rollback_restores_the_visible_preexisting_generation() { std::fs::write(&paths.allowlist, "{\"owner\":\"keep\"}\n").unwrap(); install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); let previous = - crate::install_generation::InstallGeneration::capture(paths.generation.clone()).unwrap(); + crate::installation::generation::InstallGeneration::capture(paths.generation.clone()) + .unwrap(); let mut retirement = GenerationRetirement::acquire(&paths.generation) .unwrap() .unwrap(); @@ -1005,7 +1007,7 @@ fn composed_uninstall_rollback_restores_the_visible_preexisting_generation() { "{error}" ); previous.verify_current().unwrap(); - crate::install_generation::InstallGeneration::capture(paths.generation).unwrap(); + crate::installation::generation::InstallGeneration::capture(paths.generation).unwrap(); } #[test] @@ -1109,7 +1111,7 @@ fn persistent_state_detection_recognizes_each_relay_owned_surface() { fn transparent_config_suppresses_only_the_managed_mcp_and_uses_one_relay_hook() { let temp = tempfile::tempdir().unwrap(); let relay = relay_binary(temp.path()); - let command = crate::installer::transparent_hook_forward_command( + let command = crate::hooks::transparent_hook_forward_command( &relay, CodingAgent::Hermes, "http://127.0.0.1:1234", @@ -1287,7 +1289,7 @@ fn hermes_generation_finish_preserves_primary_errors_and_reports_restore_failure let temp = tempfile::tempdir().unwrap(); let generation = temp.path().join(GENERATION_FILE_NAME); - crate::install_generation::write_new_generation(&generation).unwrap(); + crate::installation::generation::write_new_generation(&generation).unwrap(); let mut retirement = GenerationRetirement::acquire(&generation).unwrap().unwrap(); retirement.invalidate_for_replacement().unwrap(); std::fs::write(&generation, "foreign-generation\n").unwrap(); @@ -1375,7 +1377,7 @@ fn hermes_uninstall_and_verification_reject_malformed_or_residual_state() { .unwrap() .token() .to_owned(); - crate::install_generation::write_new_generation(&hermes_paths.generation).unwrap(); + crate::installation::generation::write_new_generation(&hermes_paths.generation).unwrap(); let error = verify_install( &hermes_paths, &relay, diff --git a/crates/cli/tests/coverage/agents/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs index 0c6f1290b..dbb5cf3c4 100644 --- a/crates/cli/tests/coverage/agents/launcher_tests.rs +++ b/crates/cli/tests/coverage/agents/launcher_tests.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use crate::config::{AgentCommandConfig, GatewayConfig}; +use crate::configuration::{AgentCommandConfig, GatewayConfig}; use std::ffi::OsString; use std::sync::Mutex; @@ -42,7 +42,7 @@ impl EnvScope { Self::set(&[ (crate::sidecar::BOOTSTRAP_STATE_DIR_ENV, None), ("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", None), - (crate::config::BOOTSTRAP_FINGERPRINT_ENV, None), + (crate::configuration::BOOTSTRAP_FINGERPRINT_ENV, None), ]) } } @@ -312,7 +312,7 @@ fn prepares_codex_config_overrides() { assert!( prepared .env - .contains(&(crate::config::TRANSPARENT_RUN_ENV.into(), "1".into())) + .contains(&(crate::configuration::TRANSPARENT_RUN_ENV.into(), "1".into())) ); let path = prepared .env @@ -648,21 +648,18 @@ fn invocation_resolves_wrapper_host_before_appending_pass_through_arguments() { #[test] fn version_probe_preserves_known_wrappers_and_validates_opaque_ones() { assert_eq!( - crate::agent_process::version_probe_argv( - CodingAgent::Codex, - &["codex".into(), "exec".into()] - ), + crate::process::version_probe_argv(CodingAgent::Codex, &["codex".into(), "exec".into()]), vec!["codex", "--version"] ); assert_eq!( - crate::agent_process::version_probe_argv( + crate::process::version_probe_argv( CodingAgent::Codex, &["npx".into(), "--yes".into(), "codex".into(), "exec".into(),], ), vec!["npx", "--yes", "codex", "--version"] ); assert_eq!( - crate::agent_process::version_probe_argv( + crate::process::version_probe_argv( CodingAgent::Hermes, &["company-agent-wrapper".into(), "chat".into()], ), @@ -681,7 +678,7 @@ async fn wrapped_agent_version_probe_runs_through_the_wrapper() { ) .unwrap(); make_executable(&wrapper); - let probe = crate::agent_process::version_probe_argv( + let probe = crate::process::version_probe_argv( CodingAgent::Codex, &[wrapper.display().to_string(), "codex".into(), "exec".into()], ); diff --git a/crates/cli/tests/coverage/agents/plugin_host_tests.rs b/crates/cli/tests/coverage/agents/plugin_host_tests.rs index 3700edfcc..ca3c174e8 100644 --- a/crates/cli/tests/coverage/agents/plugin_host_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_host_tests.rs @@ -17,7 +17,7 @@ use tempfile::tempdir; use toml_edit::{DocumentMut, Item, Value as TomlValue}; use super::*; -use crate::config::{BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey}; +use crate::configuration::{BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey}; const TEST_PLUGIN_GENERATION: &str = "test-generation"; @@ -101,7 +101,7 @@ fn write_plugin_generation_for_hooks(path: &Path) { let plugin_root = path.parent().and_then(Path::parent).unwrap(); fs::create_dir_all(plugin_root).unwrap(); fs::write( - plugin_root.join(crate::install_generation::GENERATION_FILE_NAME), + plugin_root.join(crate::installation::generation::GENERATION_FILE_NAME), format!("{TEST_PLUGIN_GENERATION}\n"), ) .unwrap(); @@ -1385,7 +1385,7 @@ fn codex_uninstall_sanitizes_an_extended_contaminated_backup_without_the_key() { .insert("x-user-header", TomlValue::from("keep-header")); fs::write(&path, installed.to_string()).unwrap(); fs::write(backup_path(&path), fs::read(&path).unwrap()).unwrap(); - let key_path = crate::config::user_config_dir() + let key_path = crate::configuration::user_config_dir() .unwrap() .join("bootstrap/fingerprint-hmac.key"); fs::remove_file(key_path).unwrap(); @@ -1423,7 +1423,7 @@ fn codex_uninstall_sanitizes_an_extended_contaminated_backup_after_key_rotation( .insert("x-user-header", TomlValue::from("keep-header")); fs::write(&path, installed.to_string()).unwrap(); fs::write(backup_path(&path), fs::read(&path).unwrap()).unwrap(); - let key_path = crate::config::user_config_dir() + let key_path = crate::configuration::user_config_dir() .unwrap() .join("bootstrap/fingerprint-hmac.key"); fs::write(key_path, [0x5a; 32]).unwrap(); @@ -1456,7 +1456,7 @@ fn codex_reinstall_repairs_a_rotated_client_proof_and_keeps_custom_headers() { .unwrap() .insert("x-user-header", TomlValue::from("keep-header")); fs::write(&path, installed.to_string()).unwrap(); - let key_path = crate::config::user_config_dir() + let key_path = crate::configuration::user_config_dir() .unwrap() .join("bootstrap/fingerprint-hmac.key"); fs::write(key_path, [0x3c; 32]).unwrap(); @@ -1523,7 +1523,7 @@ fn codex_install_rollback_restores_the_original_windows_dacl() { fs::create_dir_all(&codex_dir).unwrap(); fs::write(&path, "model_provider = \"openai\"\n").unwrap(); set_windows_dacl(&path, "D:P(A;;FA;;;SY)(A;;GRGW;;;WD)"); - let original_dacl = crate::file_io::read_windows_dacl(&path).unwrap(); + let original_dacl = crate::filesystem::read_windows_dacl(&path).unwrap(); let error = install_codex_with_trust( DEFAULT_URL, @@ -1538,7 +1538,7 @@ fn codex_install_rollback_restores_the_original_windows_dacl() { "model_provider = \"openai\"\n" ); assert_eq!( - crate::file_io::read_windows_dacl(&path).unwrap(), + crate::filesystem::read_windows_dacl(&path).unwrap(), original_dacl ); } @@ -1783,9 +1783,9 @@ fn codex_setup_can_validate_hooks_while_installer_holds_the_generation_lock() { let dir = tempdir().unwrap(); let plugin_root = dir.path().join("plugin"); let hooks_path = plugin_root.join("hooks").join("hooks.json"); - let generation_path = plugin_root.join(crate::install_generation::GENERATION_FILE_NAME); + let generation_path = plugin_root.join(crate::installation::generation::GENERATION_FILE_NAME); let generation_lock = dir.path().join("generation-transaction.lock"); - let token = crate::install_generation::write_new_generation_with_token_at( + let token = crate::installation::generation::write_new_generation_with_token_at( &generation_path, &generation_lock, ) @@ -1799,9 +1799,10 @@ fn codex_setup_can_validate_hooks_while_installer_holds_the_generation_lock() { serde_json::to_vec_pretty(&generated_hooks(CodingAgent::Codex, &command)).unwrap(), ) .unwrap(); - let _transaction = crate::install_generation::GenerationRetirement::acquire(&generation_path) - .unwrap() - .unwrap(); + let _transaction = + crate::installation::generation::GenerationRetirement::acquire(&generation_path) + .unwrap() + .unwrap(); assert!( codex_hooks_installed_with_generation(&hooks_path, Some(&token)).unwrap(), @@ -2527,7 +2528,7 @@ fn claude_enable_rolls_back_backup_when_settings_write_fails() { .unwrap(), ) .unwrap(); - crate::file_io::fail_next_atomic_write(&settings); + crate::filesystem::fail_next_atomic_write(&settings); let error = enable_claude_provider(DEFAULT_URL).unwrap_err(); @@ -2743,7 +2744,7 @@ fn windows_shell_argument_quoting_and_hook_encoding_preserve_paths() { r#""C:\Program Files\NeMo 100%%cd:~,%\bin\nemo-relay.exe""# ); assert_eq!( - crate::installer::decode_windows_hook_command(&codex_plugin_hook_command_for_platform( + crate::hooks::decode_windows_hook_command(&codex_plugin_hook_command_for_platform( &relay, &generation, "test-generation", @@ -3188,7 +3189,7 @@ fn codex_install_config_rolls_back_backup_when_write_fails() { let dir = tempdir().unwrap(); let path = dir.path().join("config.toml"); fs::write(&path, "model_provider = \"openai\"\n").unwrap(); - crate::file_io::fail_next_atomic_write(&path); + crate::filesystem::fail_next_atomic_write(&path); let error = install_codex_config(&path, DEFAULT_URL).unwrap_err(); @@ -3261,7 +3262,7 @@ fn codex_install_rolls_back_hooks_when_provider_config_write_fails() { "model_provider = \"openai\"\n", ) .unwrap(); - crate::file_io::fail_next_atomic_write(&codex_dir.join("config.toml")); + crate::filesystem::fail_next_atomic_write(&codex_dir.join("config.toml")); let hooks_path = codex_dir.join("hooks.json"); let original_hooks = serde_json::to_vec_pretty(&json!({ "hooks": { diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index 6e4a95eea..3a17d4e5d 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -21,7 +21,7 @@ use super::host::{ }; use super::*; use crate::agents::host::strip_windows_verbatim_prefix; -use crate::config::CodingAgent; +use crate::configuration::CodingAgent; const OPERATION_LOCK_HELPER_DIR_ENV: &str = "NEMO_RELAY_TEST_OPERATION_LOCK_DIR"; const OPERATION_LOCK_HELPER_GLOBAL_DIR_ENV: &str = "NEMO_RELAY_TEST_OPERATION_LOCK_GLOBAL_DIR"; @@ -198,7 +198,7 @@ fn replacement_generation_guard_removes_an_owned_lock_after_marker_removal() { let dir = tempdir().unwrap(); let marker = dir.path().join("generation-marker"); let lock = dir.path().join("generation.lock"); - crate::install_generation::write_new_generation_with_token_at(&marker, &lock).unwrap(); + crate::installation::generation::write_new_generation_with_token_at(&marker, &lock).unwrap(); let guard = acquire_replacement_generation_lock(IntegrationHost::Codex, &marker, &lock, true).unwrap(); @@ -213,7 +213,7 @@ fn replacement_generation_guard_retains_its_lock_when_marker_state_is_uncertain( let dir = tempdir().unwrap(); let marker = dir.path().join("generation-marker"); let lock = dir.path().join("generation.lock"); - crate::install_generation::write_new_generation_with_token_at(&marker, &lock).unwrap(); + crate::installation::generation::write_new_generation_with_token_at(&marker, &lock).unwrap(); let guard = acquire_replacement_generation_lock(IntegrationHost::Codex, &marker, &lock, true).unwrap(); @@ -724,7 +724,7 @@ impl PluginSetupRunner for FailStateWriteAfterRefresh { fn refresh_gateway(&self) -> Result<(), String> { if !self.injected.replace(true) { - crate::file_io::fail_next_atomic_write(&self.state_path); + crate::filesystem::fail_next_atomic_write(&self.state_path); } Ok(()) } @@ -929,7 +929,8 @@ fn replace_generation_with_legacy_marker(layout: &PluginLayout) -> (String, Path generation.token().to_owned() }; std::fs::remove_file(&layout.generation_lock).unwrap(); - crate::install_generation::write_legacy_generation(&layout.generation_fence, &token).unwrap(); + crate::installation::generation::write_legacy_generation(&layout.generation_fence, &token) + .unwrap(); let mut lock_path = layout.generation_fence.as_os_str().to_os_string(); lock_path.push(".lock"); (token, PathBuf::from(lock_path)) @@ -1225,7 +1226,7 @@ fn generation_retirement_lock_contention_is_bounded_across_processes() { let dir = tempdir().unwrap(); let synchronization = tempdir().unwrap(); let generation = dir.path().join(GENERATION_FILE_NAME); - crate::install_generation::write_new_generation(&generation).unwrap(); + crate::installation::generation::write_new_generation(&generation).unwrap(); let holder = CrossProcessLockHolder::spawn( GENERATION_LOCK_HELPER_PATH_ENV, &generation, @@ -1349,7 +1350,7 @@ fn plugin_manifests_and_hooks_use_path_based_relay_command() { ) .unwrap()["hooks"]["SessionStart"][0]["hooks"][0]["command"], json!( - crate::installer::persistent_hook_forward_command( + crate::hooks::persistent_hook_forward_command( Path::new("/bin/nemo-relay"), CodingAgent::Codex, &generation_fence, @@ -1367,7 +1368,7 @@ fn plugin_manifests_and_hooks_use_path_based_relay_command() { ) .unwrap()["hooks"]["SessionStart"][0]["hooks"][0]["command"], json!( - crate::installer::persistent_hook_forward_command( + crate::hooks::persistent_hook_forward_command( Path::new("/bin/nemo-relay"), CodingAgent::ClaudeCode, &generation_fence, @@ -1400,7 +1401,7 @@ fn relay_identity_uses_running_executable_when_path_points_elsewhere() { ) .unwrap()["hooks"]["SessionStart"][0]["hooks"][0]["command"], json!( - crate::installer::persistent_hook_forward_command( + crate::hooks::persistent_hook_forward_command( &relay, CodingAgent::Codex, &generation, @@ -1985,7 +1986,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { std::fs::create_dir_all(&empty_path).unwrap(); let _path = PathScope::set_isolated(&empty_path, &dir.path().join("home")); - let install_error = install(crate::config::InstallCommand { + let install_error = install(crate::configuration::InstallCommand { host: IntegrationHost::All, install_dir: Some(dir.path().join("install")), force: false, @@ -1999,7 +2000,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { "error was: {install_error}" ); - let uninstall_error = uninstall(crate::config::UninstallCommand { + let uninstall_error = uninstall(crate::configuration::UninstallCommand { host: IntegrationHost::All, install_dir: Some(dir.path().join("install")), dry_run: false, @@ -2031,7 +2032,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { ); assert_eq!( - install(crate::config::InstallCommand { + install(crate::configuration::InstallCommand { host: IntegrationHost::Codex, install_dir: Some(dir.path().join("dry-run-install")), force: false, @@ -3293,7 +3294,7 @@ fn replacement_retirement_aggregates_refresh_and_restore_failures_without_rewrit entered_rx.recv_timeout(Duration::from_secs(1)).unwrap(); std::fs::rename(&layout.plugin_root, &backup).unwrap(); - let replacement_token = crate::install_generation::write_staged_generation_with_token( + let replacement_token = crate::installation::generation::write_staged_generation_with_token( &layout.generation_fence, &layout.generation_lock, ) @@ -3446,7 +3447,7 @@ fn first_install_cleans_generated_marketplace_after_state_write_failure() { .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); - crate::file_io::fail_next_atomic_write(&layout.state_path); + crate::filesystem::fail_next_atomic_write(&layout.state_path); let error = install_host( IntegrationHost::Codex, @@ -3680,7 +3681,7 @@ fn first_install_removes_a_partially_written_marketplace() { .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); - crate::file_io::fail_next_atomic_write(&layout.plugin_manifest); + crate::filesystem::fail_next_atomic_write(&layout.plugin_manifest); let error = install_host( IntegrationHost::Codex, @@ -3704,7 +3705,7 @@ fn failed_staging_removes_a_new_external_generation_lock() { let target = PluginLayout::new(IntegrationHost::Codex, dir.path()); let stage_parent = dir.path().join("deterministic-stage"); let staged = PluginLayout::new(IntegrationHost::Codex, &stage_parent); - crate::file_io::fail_next_atomic_write(&staged.mcp_config); + crate::filesystem::fail_next_atomic_write(&staged.mcp_config); let error = match stage_plugin_marketplace_at( IntegrationHost::Codex, @@ -3728,7 +3729,7 @@ fn failed_staging_preserves_a_preexisting_external_generation_lock() { let dir = tempdir().unwrap(); let target = PluginLayout::new(IntegrationHost::Codex, dir.path()); let orphan_marker = dir.path().join("orphan-generation"); - crate::install_generation::write_new_generation_with_token_at( + crate::installation::generation::write_new_generation_with_token_at( &orphan_marker, &target.generation_lock, ) @@ -3737,7 +3738,7 @@ fn failed_staging_preserves_a_preexisting_external_generation_lock() { let original_lock = std::fs::read(&target.generation_lock).unwrap(); let stage_parent = dir.path().join("deterministic-existing-lock-stage"); let staged = PluginLayout::new(IntegrationHost::Codex, &stage_parent); - crate::file_io::fail_next_atomic_write(&staged.mcp_config); + crate::filesystem::fail_next_atomic_write(&staged.mcp_config); let error = match stage_plugin_marketplace_at( IntegrationHost::Codex, @@ -3770,7 +3771,7 @@ fn failed_staging_preserves_a_preexisting_dangling_generation_lock_symlink() { symlink(&symlink_target, &target.generation_lock).unwrap(); let stage_parent = dir.path().join("deterministic-symlink-stage"); let staged = PluginLayout::new(IntegrationHost::Codex, &stage_parent); - crate::file_io::fail_next_atomic_write(&staged.mcp_config); + crate::filesystem::fail_next_atomic_write(&staged.mcp_config); let error = match stage_plugin_marketplace_at( IntegrationHost::Codex, @@ -5383,7 +5384,7 @@ fn uninstall_retry_skips_host_removal_after_prior_success() { &options(dir.path()), ) .unwrap(); - crate::install_generation::write_new_generation(&layout.generation_fence).unwrap(); + crate::installation::generation::write_new_generation(&layout.generation_fence).unwrap(); uninstall_host( IntegrationHost::Codex, @@ -5418,7 +5419,7 @@ fn uninstall_retry_skips_plugin_removal_after_marketplace_failure() { let setup_runner = MockSetupRunner::default(); let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); write_state(&layout, &options(dir.path())).unwrap(); - crate::install_generation::write_new_generation(&layout.generation_fence).unwrap(); + crate::installation::generation::write_new_generation(&layout.generation_fence).unwrap(); let error = uninstall_host( IntegrationHost::Codex, diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index 4d163d0fc..6d116d412 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -5,7 +5,7 @@ use clap::Parser; use std::ffi::OsString; use super::*; -use crate::config::{ +use crate::configuration::{ CompletionsCommand, PluginsCommand, PluginsEditCommand, PluginsInspectCommand, PluginsListCommand, PluginsSubcommand, PluginsValidateCommand, PricingSubcommand, PricingValidateCommand, ServerArgs, diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index 4b35b486c..717e33ea3 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -112,8 +112,8 @@ fn config() -> GatewayConfig { anthropic_base_url: "http://anthropic".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, } } @@ -1918,16 +1918,16 @@ fn bootstrap_hmac_key_uses_and_repairs_a_private_windows_dacl() { let original = load_or_create_bootstrap_hmac_key_at(&path).unwrap(); - assert!(crate::file_io::windows_path_is_private(path.parent().unwrap()).unwrap()); - assert!(crate::file_io::windows_path_is_private(&path).unwrap()); + assert!(crate::filesystem::windows_path_is_private(path.parent().unwrap()).unwrap()); + assert!(crate::filesystem::windows_path_is_private(&path).unwrap()); set_test_windows_dacl(&path, "D:P(A;;FA;;;WD)"); - assert!(!crate::file_io::windows_path_is_private(&path).unwrap()); + assert!(!crate::filesystem::windows_path_is_private(&path).unwrap()); let reloaded = load_or_create_bootstrap_hmac_key_at(&path).unwrap(); assert_eq!(reloaded, original); - assert!(crate::file_io::windows_path_is_private(&path).unwrap()); + assert!(crate::filesystem::windows_path_is_private(&path).unwrap()); } #[test] @@ -2610,11 +2610,11 @@ fn gateway_body_limit_defaults_are_stable() { assert_eq!( gateway.max_hook_payload_bytes, - crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES + crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES ); assert_eq!( gateway.max_passthrough_body_bytes, - crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES + crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES ); } diff --git a/crates/cli/tests/coverage/shared/doctor_tests.rs b/crates/cli/tests/coverage/shared/doctor_tests.rs index 161ed0857..2a4dd480a 100644 --- a/crates/cli/tests/coverage/shared/doctor_tests.rs +++ b/crates/cli/tests/coverage/shared/doctor_tests.rs @@ -8,7 +8,7 @@ use std::net::TcpListener; use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use crate::config::ResolvedDynamicPluginConfig; +use crate::configuration::ResolvedDynamicPluginConfig; fn start_doctor_http_capture_server() -> (String, Arc>, std::thread::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -621,7 +621,7 @@ fn collect_configuration_uses_xdg_global_path_and_renders_resolution_branches() #[test] fn agent_helper_statuses_cover_configured_target_and_hook_paths() { assert_eq!( - crate::agent_process::command_argv("codex --full-auto"), + crate::process::command_argv("codex --full-auto"), ["codex", "--full-auto"] ); assert_eq!( @@ -875,11 +875,11 @@ fn configuration_and_path_helpers_cover_direct_paths_and_fallbacks() { assert_eq!(info.configured_agents, vec!["codex".to_string()]); assert_eq!( - crate::agent_process::resolve_executable("definitely-missing"), + crate::process::resolve_executable("definitely-missing"), None ); assert_eq!( - crate::agent_process::resolve_executable("/definitely/missing"), + crate::process::resolve_executable("/definitely/missing"), None ); let binary = temp @@ -887,7 +887,7 @@ fn configuration_and_path_helpers_cover_direct_paths_and_fallbacks() { .join(format!("agent-bin{}", std::env::consts::EXE_SUFFIX)); std::fs::write(&binary, "").unwrap(); assert_eq!( - crate::agent_process::resolve_executable(binary.to_str().unwrap()).as_deref(), + crate::process::resolve_executable(binary.to_str().unwrap()).as_deref(), Some(binary.as_path()) ); } diff --git a/crates/cli/tests/coverage/shared/gateway_tests.rs b/crates/cli/tests/coverage/shared/gateway_tests.rs index 0b3b1f4ba..58709a9f5 100644 --- a/crates/cli/tests/coverage/shared/gateway_tests.rs +++ b/crates/cli/tests/coverage/shared/gateway_tests.rs @@ -3,9 +3,9 @@ use super::*; use crate::agents::alignment::GatewayRouteKind; -use crate::config::GatewayConfig; +use crate::configuration::GatewayConfig; use crate::server::AppState; -use crate::session::{LlmGatewayStart, SessionManager}; +use crate::sessions::{LlmGatewayStart, SessionManager}; use axum::body::Body; use axum::extract::State; use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header}; @@ -25,7 +25,7 @@ fn removes_hop_by_hop_headers() { "host" ))); assert!(!should_forward_request_header(&HeaderName::from_static( - crate::config::BOOTSTRAP_CLIENT_TOKEN_HEADER + crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER ))); assert!(should_forward_request_header(&HeaderName::from_static( "authorization" @@ -54,7 +54,7 @@ async fn prepared_gateway_request_consumes_private_client_proof() { .method(Method::POST) .uri("/v1/responses") .header( - crate::config::BOOTSTRAP_CLIENT_TOKEN_HEADER, + crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER, "hmac-sha256:private-proof", ) .body(Body::from(r#"{"model":"gpt-test"}"#)) @@ -68,7 +68,7 @@ async fn prepared_gateway_request_consumes_private_client_proof() { assert!( !prepared .headers - .contains_key(crate::config::BOOTSTRAP_CLIENT_TOKEN_HEADER) + .contains_key(crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER) ); } @@ -153,8 +153,8 @@ fn provider_routes_preserve_path_query_and_choose_upstream() { anthropic_base_url: "http://anthropic/".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; assert_eq!( @@ -183,8 +183,8 @@ fn openai_upstream_url_accepts_origin_or_v1_base() { anthropic_base_url: "http://anthropic".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; assert_eq!( @@ -791,8 +791,8 @@ async fn passthrough_rejects_unsupported_provider_path_directly() { anthropic_base_url: "http://anthropic".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let state = AppState { config: config.clone(), @@ -825,8 +825,8 @@ async fn models_rejects_non_get_requests_directly() { anthropic_base_url: "http://anthropic".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let state = AppState { config: config.clone(), diff --git a/crates/cli/tests/coverage/shared/hook_assertions.rs b/crates/cli/tests/coverage/shared/hook_assertions.rs index 18d2edce4..10a97825e 100644 --- a/crates/cli/tests/coverage/shared/hook_assertions.rs +++ b/crates/cli/tests/coverage/shared/hook_assertions.rs @@ -4,7 +4,7 @@ use serde_json::Value; pub(crate) fn command_has_arguments(command: &str, expected: &[&str]) -> bool { - let arguments = crate::installer::decode_windows_hook_command(command) + let arguments = crate::hooks::decode_windows_hook_command(command) .or_else(|| shell_words::split(command).ok()); arguments.is_some_and(|arguments| { arguments.windows(expected.len()).any(|window| { diff --git a/crates/cli/tests/coverage/shared/install_generation_tests.rs b/crates/cli/tests/coverage/shared/install_generation_tests.rs index 4fd3c328d..37de34a3e 100644 --- a/crates/cli/tests/coverage/shared/install_generation_tests.rs +++ b/crates/cli/tests/coverage/shared/install_generation_tests.rs @@ -1028,7 +1028,7 @@ fn failed_invalidation_aggregates_a_failed_marker_restore() { let error = retirement .invalidate_with(|path, _retired| { - crate::file_io::fail_next_atomic_write(path); + crate::filesystem::fail_next_atomic_write(path); Err("injected invalidation failure".into()) }) .unwrap_err(); diff --git a/crates/cli/tests/coverage/shared/installer_tests.rs b/crates/cli/tests/coverage/shared/installer_tests.rs index 4319c05b5..1084b1f8e 100644 --- a/crates/cli/tests/coverage/shared/installer_tests.rs +++ b/crates/cli/tests/coverage/shared/installer_tests.rs @@ -43,10 +43,10 @@ async fn transparent_hook_delivery_authenticates_the_wrapper_gateway() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let bind = listener.local_addr().unwrap(); let gateway_url = format!("http://{bind}"); - let fingerprint = crate::config::transparent_gateway_fingerprint(&gateway_url); - let config = crate::config::GatewayConfig { + let fingerprint = crate::configuration::transparent_gateway_fingerprint(&gateway_url); + let config = crate::configuration::GatewayConfig { bind, - ..crate::config::GatewayConfig::default() + ..crate::configuration::GatewayConfig::default() }; let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let server = tokio::spawn(crate::server::serve_transparent_listener_with_dynamic( diff --git a/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs b/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs index b53650abc..ddeb6ab25 100644 --- a/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs +++ b/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs @@ -135,10 +135,11 @@ fn generation_transaction_polling_is_cancellable_for_clean_mcp_shutdown() { let dir = tempfile::tempdir().unwrap(); let path = dir .path() - .join(crate::install_generation::GENERATION_FILE_NAME); - crate::install_generation::write_new_generation(&path).unwrap(); - let generation = crate::install_generation::InstallGeneration::capture(path.clone()).unwrap(); - let mut retirement = crate::install_generation::GenerationRetirement::acquire(&path) + .join(crate::installation::generation::GENERATION_FILE_NAME); + crate::installation::generation::write_new_generation(&path).unwrap(); + let generation = + crate::installation::generation::InstallGeneration::capture(path.clone()).unwrap(); + let mut retirement = crate::installation::generation::GenerationRetirement::acquire(&path) .unwrap() .unwrap(); retirement.invalidate_for_replacement().unwrap(); diff --git a/crates/cli/tests/coverage/shared/mcp_tests.rs b/crates/cli/tests/coverage/shared/mcp_tests.rs index 036b76519..b9da9a80e 100644 --- a/crates/cli/tests/coverage/shared/mcp_tests.rs +++ b/crates/cli/tests/coverage/shared/mcp_tests.rs @@ -10,7 +10,7 @@ use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use super::*; -use crate::install_generation::{ +use crate::installation::generation::{ GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration, write_new_generation, }; @@ -54,12 +54,12 @@ impl TransparentRunEnvironment { let guard = crate::test_support::ENV_TEST_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let previous_run = std::env::var_os(crate::config::TRANSPARENT_RUN_ENV); - let previous_gateway = std::env::var_os(crate::config::GATEWAY_URL_ENV); + let previous_run = std::env::var_os(crate::configuration::TRANSPARENT_RUN_ENV); + let previous_gateway = std::env::var_os(crate::configuration::GATEWAY_URL_ENV); // SAFETY: This scope holds the process-wide environment mutex. unsafe { - std::env::set_var(crate::config::TRANSPARENT_RUN_ENV, "1"); - std::env::remove_var(crate::config::GATEWAY_URL_ENV); + std::env::set_var(crate::configuration::TRANSPARENT_RUN_ENV, "1"); + std::env::remove_var(crate::configuration::GATEWAY_URL_ENV); } Self { _guard: guard, @@ -74,12 +74,12 @@ impl Drop for TransparentRunEnvironment { // SAFETY: This restores the process environment while the mutex remains held. unsafe { match self.previous_run.take() { - Some(value) => std::env::set_var(crate::config::TRANSPARENT_RUN_ENV, value), - None => std::env::remove_var(crate::config::TRANSPARENT_RUN_ENV), + Some(value) => std::env::set_var(crate::configuration::TRANSPARENT_RUN_ENV, value), + None => std::env::remove_var(crate::configuration::TRANSPARENT_RUN_ENV), } match self.previous_gateway.take() { - Some(value) => std::env::set_var(crate::config::GATEWAY_URL_ENV, value), - None => std::env::remove_var(crate::config::GATEWAY_URL_ENV), + Some(value) => std::env::set_var(crate::configuration::GATEWAY_URL_ENV, value), + None => std::env::remove_var(crate::configuration::GATEWAY_URL_ENV), } } } @@ -89,14 +89,17 @@ impl Drop for TransparentRunEnvironment { async fn transparent_mcp_requires_the_wrapper_gateway_url() { let _environment = TransparentRunEnvironment::without_gateway(); - let error = run(&crate::config::ServerArgs::default()) + let error = run(&crate::configuration::ServerArgs::default()) .await .unwrap_err() .to_string(); - assert!(error.contains(crate::config::GATEWAY_URL_ENV), "{error}"); assert!( - error.contains(crate::config::TRANSPARENT_RUN_ENV), + error.contains(crate::configuration::GATEWAY_URL_ENV), + "{error}" + ); + assert!( + error.contains(crate::configuration::TRANSPARENT_RUN_ENV), "{error}" ); } @@ -279,9 +282,9 @@ async fn heartbeat_keeps_a_compatible_gateway_session_alive() { let _bootstrap_home = BootstrapConfigHome::enter(&temp.path().join("xdg")); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let bind = listener.local_addr().unwrap(); - let config = crate::config::GatewayConfig { + let config = crate::configuration::GatewayConfig { bind, - ..crate::config::GatewayConfig::default() + ..crate::configuration::GatewayConfig::default() }; let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let fingerprint = "test-fingerprint"; @@ -380,10 +383,10 @@ async fn borrowed_transparent_gateway_is_authenticated_and_monitored() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let bind = listener.local_addr().unwrap(); let url = format!("http://{bind}"); - let fingerprint = crate::config::transparent_gateway_fingerprint(&url); - let config = crate::config::GatewayConfig { + let fingerprint = crate::configuration::transparent_gateway_fingerprint(&url); + let config = crate::configuration::GatewayConfig { bind, - ..crate::config::GatewayConfig::default() + ..crate::configuration::GatewayConfig::default() }; let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let gateway = tokio::spawn(crate::server::serve_transparent_listener_with_dynamic( @@ -619,7 +622,7 @@ async fn old_mcp_maintenance_loop_exits_when_install_generation_is_replaced() { let plugin_root = dir.path().join("plugin"); let generation_path = plugin_root.join(GENERATION_FILE_NAME); let generation_lock = dir.path().join("generation-transaction.lock"); - crate::install_generation::write_new_generation_with_token_at( + crate::installation::generation::write_new_generation_with_token_at( &generation_path, &generation_lock, ) @@ -676,7 +679,7 @@ async fn old_mcp_maintenance_loop_exits_when_install_generation_is_replaced() { // Force installation swaps the whole plugin tree while retaining its external transaction // lock until the replacement is committed. std::fs::rename(&plugin_root, dir.path().join("retired-plugin")).unwrap(); - crate::install_generation::write_staged_generation_with_token( + crate::installation::generation::write_staged_generation_with_token( &generation_path, &generation_lock, ) diff --git a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs index 282392ba2..6b294621e 100644 --- a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs @@ -8,7 +8,7 @@ use std::{ }; use super::*; -use crate::config::{ +use crate::configuration::{ PluginsAddCommand, PluginsDisableCommand, PluginsEnableCommand, PluginsInspectCommand, PluginsListCommand, PluginsRemoveCommand, PluginsScopeArgs, PluginsValidateCommand, ServerArgs, }; @@ -768,7 +768,7 @@ entrypoint = "../worker-runtime/worker.sh" .write(true) .open(&artifact_path) .unwrap() - .set_len(crate::config::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1) + .set_len(crate::configuration::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1) .unwrap(); std::fs::set_permissions(&artifact_path, std::fs::Permissions::from_mode(0o755)).unwrap(); std::fs::write(&manifest_path, b"not valid TOML").unwrap(); @@ -776,7 +776,7 @@ entrypoint = "../worker-runtime/worker.sh" .write(true) .open(&manifest_path) .unwrap() - .set_len(crate::config::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1) + .set_len(crate::configuration::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1) .unwrap(); let error = match load_worker_plugins(vec![WorkerPluginLoadSpec { @@ -1000,7 +1000,7 @@ fn activation_snapshot_budgets_reject_entry_and_byte_overflow() { let byte_error = byte_budget .record_bytes( path, - usize::try_from(crate::config::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES).unwrap() + 1, + usize::try_from(crate::configuration::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES).unwrap() + 1, ) .unwrap_err() .to_string(); @@ -1759,7 +1759,7 @@ fn add_registers_dynamic_plugin_in_project_plugins_toml() { }, path: plugin_dir.clone(), }, - &crate::config::ServerArgs::default(), + &crate::configuration::ServerArgs::default(), ) .unwrap(); @@ -2470,7 +2470,7 @@ fn active_dynamic_plugin_components_project_enabled_native_records_only() { let plugin_dir = temp.path().join("plugins").join("native"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_native_dynamic_manifest(&plugin_dir, "acme.native"); - let server = crate::config::ServerArgs::default(); + let server = crate::configuration::ServerArgs::default(); add( PluginsAddCommand { @@ -2517,7 +2517,7 @@ fn active_dynamic_plugin_components_accept_enabled_worker_records() { let plugin_dir = temp.path().join("plugins").join("worker"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.worker"); - let server = crate::config::ServerArgs::default(); + let server = crate::configuration::ServerArgs::default(); add( PluginsAddCommand { @@ -2560,7 +2560,7 @@ fn active_dynamic_plugin_components_accept_worker_records_without_manifest_ref() let plugin_dir = temp.path().join("plugins").join("worker"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.worker"); - let server = crate::config::ServerArgs::default(); + let server = crate::configuration::ServerArgs::default(); add( PluginsAddCommand { @@ -2622,7 +2622,7 @@ fn add_rejects_duplicate_dynamic_plugin_ids() { }, path: plugin_dir.clone(), }, - &crate::config::ServerArgs::default(), + &crate::configuration::ServerArgs::default(), ) .unwrap(); @@ -2634,7 +2634,7 @@ fn add_rejects_duplicate_dynamic_plugin_ids() { }, path: plugin_dir, }, - &crate::config::ServerArgs::default(), + &crate::configuration::ServerArgs::default(), ) .unwrap_err() .to_string(); @@ -2700,7 +2700,7 @@ allowed = false }, path: plugin_dir, }, - &crate::config::ServerArgs::default(), + &crate::configuration::ServerArgs::default(), ) .unwrap_err(); @@ -2774,7 +2774,7 @@ fn list_and_inspect_render_discovered_dynamic_plugins() { }, path: plugin_dir, }, - &crate::config::ServerArgs::default(), + &crate::configuration::ServerArgs::default(), ) .unwrap(); @@ -2846,7 +2846,7 @@ fn validate_renders_summary_for_path_and_id_targets() { }, path: plugin_dir, }, - &crate::config::ServerArgs::default(), + &crate::configuration::ServerArgs::default(), ) .unwrap(); @@ -2895,7 +2895,7 @@ fn validate_renders_summary_for_path_and_id_targets() { target: "missing.plugin".into(), json: false, }, - &crate::config::ServerArgs::default(), + &crate::configuration::ServerArgs::default(), ) .unwrap_err() .to_string(); @@ -2906,7 +2906,7 @@ fn validate_renders_summary_for_path_and_id_targets() { id: "missing.plugin".into(), json: false, }, - &crate::config::ServerArgs::default(), + &crate::configuration::ServerArgs::default(), ) .unwrap_err() .to_string(); @@ -2915,7 +2915,7 @@ fn validate_renders_summary_for_path_and_id_targets() { assert_eq!( list( PluginsListCommand::default(), - &crate::config::ServerArgs::default() + &crate::configuration::ServerArgs::default() ) .unwrap(), () @@ -2930,7 +2930,7 @@ fn enable_disable_and_remove_persist_lifecycle_state() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.guardrail"); - let server = crate::config::ServerArgs::default(); + let server = crate::configuration::ServerArgs::default(); add( PluginsAddCommand { @@ -3516,7 +3516,7 @@ fn enable_refuses_dynamic_plugins_blocked_by_host_policy_and_persists_status() { std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.enable-blocked"); - let server = crate::config::ServerArgs::default(); + let server = crate::configuration::ServerArgs::default(); add( PluginsAddCommand { @@ -3597,7 +3597,7 @@ fn disable_succeeds_when_registered_plugin_manifest_is_unreadable() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.guardrail"); - let server = crate::config::ServerArgs::default(); + let server = crate::configuration::ServerArgs::default(); add( PluginsAddCommand { @@ -3646,7 +3646,7 @@ fn validate_marks_registered_plugins_invalid_when_host_policy_blocks_them() { std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.validate-blocked"); - let server = crate::config::ServerArgs::default(); + let server = crate::configuration::ServerArgs::default(); add( PluginsAddCommand { @@ -3789,7 +3789,7 @@ fn add_can_revive_tombstoned_records() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.revive"); - let server = crate::config::ServerArgs::default(); + let server = crate::configuration::ServerArgs::default(); add( PluginsAddCommand { diff --git a/crates/cli/tests/coverage/shared/plugins_tests.rs b/crates/cli/tests/coverage/shared/plugins_tests.rs index b6e3bd1f7..ef7167397 100644 --- a/crates/cli/tests/coverage/shared/plugins_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_tests.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use crate::config::{ +use crate::configuration::{ PluginsScopeArgs, global_plugin_config_path, project_plugin_config_path, user_plugin_config_path, }; diff --git a/crates/cli/tests/coverage/shared/server_tests.rs b/crates/cli/tests/coverage/shared/server_tests.rs index 46a2d7d7f..00b820748 100644 --- a/crates/cli/tests/coverage/shared/server_tests.rs +++ b/crates/cli/tests/coverage/shared/server_tests.rs @@ -30,7 +30,7 @@ use tokio::task::JoinHandle; use tower::ServiceExt; use super::*; -use crate::config::BootstrapChallengeKey; +use crate::configuration::BootstrapChallengeKey; use crate::error::CliError; use crate::plugins::lifecycle::ActiveDynamicPluginComponent; use crate::test_support::PLUGIN_CONFIG_TEST_LOCK; @@ -165,8 +165,8 @@ fn test_config() -> GatewayConfig { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, } } @@ -430,12 +430,12 @@ async fn managed_sidecar_requires_private_client_proof_for_forwarded_credentials let mut headers = HeaderMap::new(); assert!(!state.allows_environment_provider_auth(&headers)); headers.insert( - crate::config::BOOTSTRAP_CLIENT_TOKEN_HEADER, + crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER, HeaderValue::from_static("hmac-sha256:wrong"), ); assert!(!state.allows_environment_provider_auth(&headers)); headers.insert( - crate::config::BOOTSTRAP_CLIENT_TOKEN_HEADER, + crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER, HeaderValue::from_str(&key.client_token()).unwrap(), ); assert!(state.allows_environment_provider_auth(&headers)); diff --git a/crates/cli/tests/coverage/shared/session_tests.rs b/crates/cli/tests/coverage/shared/session_tests.rs index 0c692b73a..e945b083b 100644 --- a/crates/cli/tests/coverage/shared/session_tests.rs +++ b/crates/cli/tests/coverage/shared/session_tests.rs @@ -16,7 +16,7 @@ use std::path::Path; use std::sync::{Arc, Mutex as StdMutex}; use super::*; -use crate::model::{LlmEvent, LlmHintEvent, SessionEvent, ToolEvent}; +use crate::events::{LlmEvent, LlmHintEvent, SessionEvent, ToolEvent}; use crate::test_support::PLUGIN_CONFIG_TEST_LOCK; const HERMES_ROUTED_TEST_SESSION_KEY: &str = "hermes_routed_test_session_id"; @@ -651,8 +651,8 @@ async fn nests_agent_subagent_and_tool_lifecycle() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -1903,8 +1903,8 @@ async fn writes_atif_on_session_end_from_plugin_config() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let mut headers = HeaderMap::new(); @@ -2162,8 +2162,8 @@ async fn duplicate_agent_end_does_not_overwrite_atif_with_empty_session() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -2242,8 +2242,8 @@ async fn writes_hermes_api_hook_usage_to_atif_metrics() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -2325,8 +2325,8 @@ async fn writes_hermes_api_hook_reported_cost_to_atif_metrics() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -3631,8 +3631,8 @@ async fn handles_out_of_order_subagent_and_tool_end_events() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -3708,8 +3708,8 @@ async fn out_of_order_started_subagent_end_does_not_leak_scope() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -3781,8 +3781,8 @@ async fn agent_end_closes_nested_active_subagents_lifo() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -3838,8 +3838,8 @@ async fn llm_lifecycle_starts_implicit_gateway_session() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let active = manager @@ -4322,8 +4322,8 @@ async fn llm_lifecycle_uses_single_active_hook_session_when_header_is_missing() anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); manager @@ -4450,8 +4450,8 @@ async fn single_pending_llm_hint_claims_next_gateway_llm() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); manager @@ -4548,8 +4548,8 @@ async fn multiple_llm_hints_resolve_by_generation_id() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); manager @@ -4664,8 +4664,8 @@ async fn ambiguous_llm_hints_fall_back_to_agent_scope() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); manager @@ -4758,8 +4758,8 @@ async fn no_active_hint_reuses_last_llm_owner() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); manager @@ -6553,8 +6553,8 @@ fn session_test_config() -> GatewayConfig { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, } } @@ -6568,8 +6568,8 @@ async fn turn_ended_is_noop_without_active_turn_scope() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); manager diff --git a/crates/cli/tests/coverage/shared/sidecar_state_tests.rs b/crates/cli/tests/coverage/shared/sidecar_state_tests.rs index bee23d624..cec544257 100644 --- a/crates/cli/tests/coverage/shared/sidecar_state_tests.rs +++ b/crates/cli/tests/coverage/shared/sidecar_state_tests.rs @@ -170,7 +170,7 @@ fn server_owner_guard_cleans_only_its_own_record() { Some(OsStr::new("first-token")), ), ( - crate::config::BOOTSTRAP_FINGERPRINT_ENV, + crate::configuration::BOOTSTRAP_FINGERPRINT_ENV, Some(OsStr::new("fingerprint")), ), ]); @@ -221,7 +221,7 @@ fn authenticated_owned_gateway_is_shut_down_and_cleaned_up() { ("HOME", Some(dir.path().as_os_str())), ("USERPROFILE", None), ]); - let key = crate::config::BootstrapChallengeKey::load().unwrap(); + let key = crate::configuration::BootstrapChallengeKey::load().unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let state = state_dir().unwrap(); diff --git a/crates/cli/tests/coverage/shared/sidecar_tests.rs b/crates/cli/tests/coverage/shared/sidecar_tests.rs index 74fcdca09..f3bdf74c2 100644 --- a/crates/cli/tests/coverage/shared/sidecar_tests.rs +++ b/crates/cli/tests/coverage/shared/sidecar_tests.rs @@ -114,7 +114,7 @@ fn compatible_gateway_is_reused_without_starting_another_process() { ("HOME", Some(temp.path().as_os_str())), ("USERPROFILE", None), ]); - let key = crate::config::BootstrapChallengeKey::load().unwrap(); + let key = crate::configuration::BootstrapChallengeKey::load().unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let server = std::thread::spawn(move || { @@ -221,7 +221,7 @@ fn persistent_gateway_resolution_keeps_server_configuration_in_one_spec() { assert_eq!(resolved.gateway.bind(), bind); assert_eq!( resolved.max_hook_payload_bytes, - crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES + crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES ); assert!(resolved.gateway.bootstrap_fingerprint.is_some()); assert!(resolved.gateway.user_config_scope); @@ -237,7 +237,7 @@ fn persistent_gateway_resolution_keeps_server_configuration_in_one_spec() { #[test] fn idle_timeout_drives_heartbeat_and_rejects_invalid_values() { let _environment = EnvScope::set(&[( - crate::config::PLUGIN_IDLE_TIMEOUT_ENV, + crate::configuration::PLUGIN_IDLE_TIMEOUT_ENV, Some(OsStr::new("9")), )]); assert_eq!(plugin_idle_timeout().unwrap(), Duration::from_secs(9)); @@ -245,7 +245,7 @@ fn idle_timeout_drives_heartbeat_and_rejects_invalid_values() { drop(_environment); let _environment = EnvScope::set(&[( - crate::config::PLUGIN_IDLE_TIMEOUT_ENV, + crate::configuration::PLUGIN_IDLE_TIMEOUT_ENV, Some(OsStr::new("0")), )]); assert!( From 29f52b6bcb5047490f0297945a8ae02352e46ef0 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 20:12:06 -0400 Subject: [PATCH 020/102] refactor(cli): isolate shared agent mechanics Signed-off-by: Will Killian --- crates/cli/src/agents/claude/adapter.rs | 2 +- crates/cli/src/agents/claude/alignment.rs | 2 +- crates/cli/src/agents/codex/adapter.rs | 2 +- crates/cli/src/agents/codex/alignment.rs | 2 +- crates/cli/src/agents/hermes/adapter.rs | 2 +- crates/cli/src/agents/hermes/alignment.rs | 4 ++- crates/cli/src/agents/mod.rs | 3 +-- .../cli/src/agents/{ => shared}/adapters.rs | 8 +++--- .../cli/src/agents/{ => shared}/alignment.rs | 8 +++--- crates/cli/src/agents/shared/mod.rs | 7 +++++ crates/cli/src/gateway/mod.rs | 2 +- crates/cli/src/server/mod.rs | 2 +- crates/cli/src/sessions/mod.rs | 4 +-- .../tests/coverage/agents/adapters_tests.rs | 2 +- .../tests/coverage/shared/gateway_tests.rs | 2 +- .../tests/coverage/shared/session_tests.rs | 26 +++++++++---------- 16 files changed, 43 insertions(+), 35 deletions(-) rename crates/cli/src/agents/{ => shared}/adapters.rs (99%) rename crates/cli/src/agents/{ => shared}/alignment.rs (99%) create mode 100644 crates/cli/src/agents/shared/mod.rs diff --git a/crates/cli/src/agents/claude/adapter.rs b/crates/cli/src/agents/claude/adapter.rs index a3f375883..ed1cdde98 100644 --- a/crates/cli/src/agents/claude/adapter.rs +++ b/crates/cli/src/agents/claude/adapter.rs @@ -4,7 +4,7 @@ use axum::http::HeaderMap; use serde_json::{Value, json}; -use crate::agents::adapters::{ +use crate::agents::shared::adapters::{ AdapterOutcome, CLAUDE_CODE_PAYLOAD_EXTRACTOR, ClassificationRules, classify, }; use crate::events::{AgentKind, NormalizedEvent}; diff --git a/crates/cli/src/agents/claude/alignment.rs b/crates/cli/src/agents/claude/alignment.rs index f05ea68bb..006e5e381 100644 --- a/crates/cli/src/agents/claude/alignment.rs +++ b/crates/cli/src/agents/claude/alignment.rs @@ -11,7 +11,7 @@ use axum::http::HeaderMap; use nemo_relay::api::llm::LlmRequest; use serde_json::Value; -use crate::agents::alignment::json_string_at; +use crate::agents::shared::alignment::json_string_at; use crate::configuration::header_string; use crate::events::{AgentKind, ToolEvent}; diff --git a/crates/cli/src/agents/codex/adapter.rs b/crates/cli/src/agents/codex/adapter.rs index 9285c81b3..536f778ba 100644 --- a/crates/cli/src/agents/codex/adapter.rs +++ b/crates/cli/src/agents/codex/adapter.rs @@ -4,7 +4,7 @@ use axum::http::HeaderMap; use serde_json::{Value, json}; -use crate::agents::adapters::{ +use crate::agents::shared::adapters::{ AdapterOutcome, CODEX_PAYLOAD_EXTRACTOR, ClassificationRules, classify, }; use crate::events::AgentKind; diff --git a/crates/cli/src/agents/codex/alignment.rs b/crates/cli/src/agents/codex/alignment.rs index 6f4389f3c..01c05ceda 100644 --- a/crates/cli/src/agents/codex/alignment.rs +++ b/crates/cli/src/agents/codex/alignment.rs @@ -13,7 +13,7 @@ use std::io::{BufRead, BufReader}; use axum::http::HeaderMap; use serde_json::{Map, Value, json}; -use crate::agents::alignment::{ +use crate::agents::shared::alignment::{ GatewayRouteKind, SessionAlias, insert_optional, json_string_at, merge_metadata, }; use crate::events::{AgentKind, SessionEvent, SubagentEvent}; diff --git a/crates/cli/src/agents/hermes/adapter.rs b/crates/cli/src/agents/hermes/adapter.rs index d3119a594..6465d4513 100644 --- a/crates/cli/src/agents/hermes/adapter.rs +++ b/crates/cli/src/agents/hermes/adapter.rs @@ -4,7 +4,7 @@ use axum::http::HeaderMap; use serde_json::{Map, Value, json}; -use crate::agents::adapters::{ +use crate::agents::shared::adapters::{ AdapterOutcome, ClassificationRules, HERMES_PAYLOAD_EXTRACTOR, classify, common_session_event, event_name, metadata, normalize_name, session_id, }; diff --git a/crates/cli/src/agents/hermes/alignment.rs b/crates/cli/src/agents/hermes/alignment.rs index 9fcb21f2b..85e514bff 100644 --- a/crates/cli/src/agents/hermes/alignment.rs +++ b/crates/cli/src/agents/hermes/alignment.rs @@ -10,7 +10,9 @@ use serde_json::{Map, Value, json}; -use crate::agents::alignment::{SessionAlias, insert_optional, json_string_at, merge_metadata}; +use crate::agents::shared::alignment::{ + SessionAlias, insert_optional, json_string_at, merge_metadata, +}; use crate::events::{AgentKind, SessionEvent, SubagentEvent}; #[derive(Debug, Clone)] diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 96b26f68e..3e52e525d 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -3,11 +3,10 @@ //! Canonical coding-agent identity and compatibility policy. -pub(crate) mod adapters; -pub(crate) mod alignment; pub(crate) mod hermes; pub(crate) mod host; pub(crate) mod install; +pub(crate) mod shared; use clap::ValueEnum; use semver::Version; diff --git a/crates/cli/src/agents/adapters.rs b/crates/cli/src/agents/shared/adapters.rs similarity index 99% rename from crates/cli/src/agents/adapters.rs rename to crates/cli/src/agents/shared/adapters.rs index c0fefd288..38d4b0794 100644 --- a/crates/cli/src/agents/adapters.rs +++ b/crates/cli/src/agents/shared/adapters.rs @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -#[path = "claude/adapter.rs"] +#[path = "../claude/adapter.rs"] pub(crate) mod claude_code; -#[path = "codex/adapter.rs"] +#[path = "../codex/adapter.rs"] pub(crate) mod codex; -#[path = "hermes/adapter.rs"] +#[path = "../hermes/adapter.rs"] pub(crate) mod hermes; pub(crate) const SKILL_LOAD_SOURCE_KEY: &str = "skill_load_source"; @@ -985,5 +985,5 @@ fn normalize_name(name: &str) -> String { } #[cfg(test)] -#[path = "../../tests/coverage/agents/adapters_tests.rs"] +#[path = "../../../tests/coverage/agents/adapters_tests.rs"] mod tests; diff --git a/crates/cli/src/agents/alignment.rs b/crates/cli/src/agents/shared/alignment.rs similarity index 99% rename from crates/cli/src/agents/alignment.rs rename to crates/cli/src/agents/shared/alignment.rs index f8eca393c..cfdeafee1 100644 --- a/crates/cli/src/agents/alignment.rs +++ b/crates/cli/src/agents/shared/alignment.rs @@ -19,11 +19,11 @@ pub(crate) use crate::events::json_path::{ }; use crate::events::{AgentKind, LlmEvent, NormalizedEvent, SessionEvent, SubagentEvent, ToolEvent}; -#[path = "claude/alignment.rs"] +#[path = "../claude/alignment.rs"] pub(crate) mod claude_code; -#[path = "codex/alignment.rs"] +#[path = "../codex/alignment.rs"] pub(crate) mod codex; -#[path = "hermes/alignment.rs"] +#[path = "../hermes/alignment.rs"] pub(crate) mod hermes; const REQUEST_AFFINITY_KEY_MIN_CHARS: usize = 24; @@ -1170,5 +1170,5 @@ pub(crate) fn merge_metadata(left: Value, right: Value) -> Value { } #[cfg(test)] -#[path = "../../tests/coverage/agents/alignment_tests.rs"] +#[path = "../../../tests/coverage/agents/alignment_tests.rs"] mod tests; diff --git a/crates/cli/src/agents/shared/mod.rs b/crates/cli/src/agents/shared/mod.rs new file mode 100644 index 000000000..db9b6978d --- /dev/null +++ b/crates/cli/src/agents/shared/mod.rs @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Agent-neutral event adaptation and gateway alignment mechanics. + +pub(crate) mod adapters; +pub(crate) mod alignment; diff --git a/crates/cli/src/gateway/mod.rs b/crates/cli/src/gateway/mod.rs index cde9eb90d..3cc1a76fd 100644 --- a/crates/cli/src/gateway/mod.rs +++ b/crates/cli/src/gateway/mod.rs @@ -26,7 +26,7 @@ use nemo_relay::codec::traits::LlmResponseCodec; use nemo_relay::error::FlowError; use serde_json::{Map, Value, json}; -use crate::agents::alignment::{self, GatewayRouteKind}; +use crate::agents::shared::alignment::{self, GatewayRouteKind}; use crate::configuration::{BOOTSTRAP_CLIENT_TOKEN_HEADER, header_string}; use crate::error::CliError; use crate::server::AppState; diff --git a/crates/cli/src/server/mod.rs b/crates/cli/src/server/mod.rs index 5cc101fea..073842bfb 100644 --- a/crates/cli/src/server/mod.rs +++ b/crates/cli/src/server/mod.rs @@ -29,7 +29,7 @@ use subtle::ConstantTimeEq; use tokio::net::TcpListener; use tokio::sync::oneshot; -use crate::agents::adapters::{claude_code, codex, hermes}; +use crate::agents::shared::adapters::{claude_code, codex, hermes}; use crate::configuration::{ BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey, GatewayConfig, ManagedBootstrapIdentity, }; diff --git a/crates/cli/src/sessions/mod.rs b/crates/cli/src/sessions/mod.rs index 0a864b863..d82318850 100644 --- a/crates/cli/src/sessions/mod.rs +++ b/crates/cli/src/sessions/mod.rs @@ -23,8 +23,8 @@ use nemo_relay::api::tool::{ use serde_json::{Map, Value, json}; use tokio::sync::Mutex; -use crate::agents::adapters::{SKILL_LOAD_SOURCE_KEY, SKILL_LOAD_SOURCE_PROMPT_EXPANSION}; -use crate::agents::alignment::{ +use crate::agents::shared::adapters::{SKILL_LOAD_SOURCE_KEY, SKILL_LOAD_SOURCE_PROMPT_EXPANSION}; +use crate::agents::shared::alignment::{ self, GatewayManagementPolicy, PendingSubagentStart, SessionAlias, SessionAlignmentState, insert_optional, json_string_at, json_value_at, merge_metadata, }; diff --git a/crates/cli/tests/coverage/agents/adapters_tests.rs b/crates/cli/tests/coverage/agents/adapters_tests.rs index f5381d770..65e0b73b6 100644 --- a/crates/cli/tests/coverage/agents/adapters_tests.rs +++ b/crates/cli/tests/coverage/agents/adapters_tests.rs @@ -5,7 +5,7 @@ use axum::http::HeaderMap; use serde_json::json; use super::*; -use crate::agents::adapters::{claude_code, codex, hermes}; +use crate::agents::shared::adapters::{claude_code, codex, hermes}; #[test] fn maps_claude_canonical_tool_payload() { diff --git a/crates/cli/tests/coverage/shared/gateway_tests.rs b/crates/cli/tests/coverage/shared/gateway_tests.rs index 58709a9f5..72d99d96f 100644 --- a/crates/cli/tests/coverage/shared/gateway_tests.rs +++ b/crates/cli/tests/coverage/shared/gateway_tests.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use crate::agents::alignment::GatewayRouteKind; +use crate::agents::shared::alignment::GatewayRouteKind; use crate::configuration::GatewayConfig; use crate::server::AppState; use crate::sessions::{LlmGatewayStart, SessionManager}; diff --git a/crates/cli/tests/coverage/shared/session_tests.rs b/crates/cli/tests/coverage/shared/session_tests.rs index e945b083b..5a2353ae7 100644 --- a/crates/cli/tests/coverage/shared/session_tests.rs +++ b/crates/cli/tests/coverage/shared/session_tests.rs @@ -192,7 +192,7 @@ fn register_filtered_session_subscriber( } async fn apply_codex_payload(manager: &SessionManager, headers: &HeaderMap, payload: Value) { - let outcome = crate::agents::adapters::codex::adapt(payload, headers); + let outcome = crate::agents::shared::adapters::codex::adapt(payload, headers); manager.apply_events(headers, outcome.events).await.unwrap(); } @@ -549,7 +549,7 @@ async fn drive_hermes_orphan_subagent_stop( "session_id": session_id }), ] { - let outcome = crate::agents::adapters::hermes::adapt(payload, headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, headers); manager.apply_events(headers, outcome.events).await.unwrap(); } } @@ -637,7 +637,7 @@ async fn drive_hermes_subagent_child_session( "session_id": parent_session_id }), ] { - let outcome = crate::agents::adapters::hermes::adapt(payload, headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, headers); manager.apply_events(headers, outcome.events).await.unwrap(); } } @@ -1552,7 +1552,7 @@ async fn hermes_subagent_start_does_not_reparent_active_child_session() { } }), ] { - let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2455,7 +2455,7 @@ async fn hermes_exact_api_hooks_write_atif_request_response_and_cost() { "session_id": "hermes-exact-atif" }), ] { - let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2559,7 +2559,7 @@ async fn hermes_api_request_error_writes_atif_error_step_and_fidelity() { "session_id": "hermes-error" }), ] { - let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2670,7 +2670,7 @@ async fn hermes_lossy_api_hooks_write_atif_fidelity_markers() { "session_id": "hermes-lossy-atif" }), ] { - let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2737,7 +2737,7 @@ async fn hermes_uncorrelatable_pre_tool_call_does_not_create_shutdown_trajectory "session_id": "hermes-main" }), ] { - let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2817,7 +2817,7 @@ async fn hermes_turn_end_snapshots_atif_without_boundary_system_step() { "session_id": "hermes-clean" }), ] { - let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2872,14 +2872,14 @@ async fn hermes_task_id_tool_hooks_reuse_api_session() { } }), ] { - let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await .unwrap(); } - let pre_tool = crate::agents::adapters::hermes::adapt( + let pre_tool = crate::agents::shared::adapters::hermes::adapt( json!({ "hook_event_name": "pre_tool_call", "session_id": "hermes-main", @@ -2911,7 +2911,7 @@ async fn hermes_task_id_tool_hooks_reuse_api_session() { ); } - let post_tool = crate::agents::adapters::hermes::adapt( + let post_tool = crate::agents::shared::adapters::hermes::adapt( json!({ "hook_event_name": "post_tool_call", "session_id": "hermes-main", @@ -3032,7 +3032,7 @@ async fn hermes_post_tool_call_writes_atif_observation_with_source_call_id() { "session_id": "hermes-tool-result" }), ] { - let outcome = crate::agents::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await From c5b1fec0a33e3eb5a300b09679d3a1ac1a00b7a7 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 20:55:18 -0400 Subject: [PATCH 021/102] refactor(cli): split gateway bootstrap responsibilities Signed-off-by: Will Killian --- crates/cli/src/agents/hermes/config.rs | 2 +- crates/cli/src/agents/hermes/integration.rs | 2 +- crates/cli/src/agents/host.rs | 8 ++-- crates/cli/src/agents/install/mod.rs | 2 +- crates/cli/src/agents/shared/host.rs | 3 +- .../cli/src/{sidecar.rs => bootstrap/mod.rs} | 48 ++++++++----------- .../cli/src/{sidecar => bootstrap}/state.rs | 8 ++-- crates/cli/src/configuration/mod.rs | 4 +- .../{sidecar/health.rs => gateway/client.rs} | 16 +++---- crates/cli/src/gateway/mod.rs | 1 + crates/cli/src/hooks/mod.rs | 35 ++++++++------ crates/cli/src/lib.rs | 2 +- crates/cli/src/mcp.rs | 4 +- crates/cli/src/mcp/gateway.rs | 14 +++--- .../process.rs => process/detached.rs} | 0 crates/cli/src/process/launcher.rs | 2 +- crates/cli/src/process/mod.rs | 1 + crates/cli/src/server/mod.rs | 6 +-- crates/cli/tests/architecture_tests.rs | 2 + .../cli/tests/coverage/agents/hermes_tests.rs | 2 +- .../tests/coverage/agents/launcher_tests.rs | 2 +- .../coverage/agents/plugin_host_tests.rs | 2 +- ...tate_tests.rs => bootstrap_state_tests.rs} | 0 .../{sidecar_tests.rs => bootstrap_tests.rs} | 8 ++-- .../cli/tests/coverage/shared/config_tests.rs | 3 +- ...ealth_tests.rs => gateway_client_tests.rs} | 0 .../tests/coverage/shared/installer_tests.rs | 16 +++---- .../coverage/shared/mcp_gateway_tests.rs | 10 ++-- crates/cli/tests/coverage/shared/mcp_tests.rs | 20 ++++---- .../cli/tests/coverage/shared/server_tests.rs | 4 +- 30 files changed, 116 insertions(+), 111 deletions(-) rename crates/cli/src/{sidecar.rs => bootstrap/mod.rs} (93%) rename crates/cli/src/{sidecar => bootstrap}/state.rs (97%) rename crates/cli/src/{sidecar/health.rs => gateway/client.rs} (97%) rename crates/cli/src/{sidecar/process.rs => process/detached.rs} (100%) rename crates/cli/tests/coverage/shared/{sidecar_state_tests.rs => bootstrap_state_tests.rs} (100%) rename crates/cli/tests/coverage/shared/{sidecar_tests.rs => bootstrap_tests.rs} (96%) rename crates/cli/tests/coverage/shared/{sidecar_health_tests.rs => gateway_client_tests.rs} (100%) diff --git a/crates/cli/src/agents/hermes/config.rs b/crates/cli/src/agents/hermes/config.rs index c2a33b883..9ef20c962 100644 --- a/crates/cli/src/agents/hermes/config.rs +++ b/crates/cli/src/agents/hermes/config.rs @@ -203,7 +203,7 @@ pub(super) fn owned_install_command( let env = server.get("env").and_then(Value::as_object); if server.get("args") == Some(&json!(["mcp"])) && env.and_then(|env| env.get("NEMO_RELAY_GATEWAY_BIND")) - == Some(&json!(crate::sidecar::DEFAULT_BIND)) + == Some(&json!(crate::bootstrap::DEFAULT_BIND)) { let generation = env .and_then(|env| env.get(crate::installation::generation::GENERATION_FILE_ENV)) diff --git a/crates/cli/src/agents/hermes/integration.rs b/crates/cli/src/agents/hermes/integration.rs index 87bb8b350..3ea1823e3 100644 --- a/crates/cli/src/agents/hermes/integration.rs +++ b/crates/cli/src/agents/hermes/integration.rs @@ -23,6 +23,7 @@ use super::files::{ acquire_install_lock, read_optional_utf8, remove_optional_file, replace_optional_file, }; use super::trust::{json_bytes, parse_json_object, trusted_hooks, verify_trust}; +use crate::bootstrap::DEFAULT_BIND; use crate::configuration::CodingAgent; use crate::error::CliError; use crate::filesystem::atomic_write; @@ -31,7 +32,6 @@ use crate::installation::generation::GENERATION_FILE_NAME; use crate::installation::generation::{ GENERATION_FILE_ENV, GENERATION_TOKEN_ENV, GenerationRetirement, InstallGeneration, }; -use crate::sidecar::DEFAULT_BIND; /// Hermes host configuration is user-owned even when Relay itself uses project configuration. /// Project-specific Relay behavior remains available through transparent `nemo-relay run`. diff --git a/crates/cli/src/agents/host.rs b/crates/cli/src/agents/host.rs index 95de6e8d4..adcb3b0ee 100644 --- a/crates/cli/src/agents/host.rs +++ b/crates/cli/src/agents/host.rs @@ -34,7 +34,7 @@ use shared::{current_exe, healthz, print_check, print_info}; use crate::configuration::CodingAgent; #[cfg(test)] -pub(super) use crate::sidecar::DEFAULT_URL; +pub(super) use crate::bootstrap::DEFAULT_URL; pub(crate) fn install_codex_plugin_with_generation( gateway_url: &str, @@ -50,7 +50,7 @@ pub(crate) fn install_codex_plugin_with_generation( } pub(crate) fn stop_plugin_gateway() -> Result<(), String> { - crate::sidecar::stop_owned_sidecar_and_reset(crate::sidecar::DEFAULT_URL) + crate::bootstrap::state::stop_owned_and_reset(crate::bootstrap::DEFAULT_URL) } pub(crate) fn uninstall_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Result<(), String> { @@ -209,9 +209,9 @@ fn doctor_ok( } #[cfg(test)] -use crate::hooks::generated_hooks; +use crate::bootstrap::*; #[cfg(test)] -use crate::sidecar::*; +use crate::hooks::generated_hooks; #[cfg(test)] use claude::*; #[cfg(test)] diff --git a/crates/cli/src/agents/install/mod.rs b/crates/cli/src/agents/install/mod.rs index cf5bdd685..45cbc480c 100644 --- a/crates/cli/src/agents/install/mod.rs +++ b/crates/cli/src/agents/install/mod.rs @@ -46,7 +46,7 @@ use state::{ remove_path, state_path, write_state, write_state_for_host, }; -pub(super) use crate::sidecar::DEFAULT_URL as DEFAULT_GATEWAY_URL; +pub(super) use crate::bootstrap::DEFAULT_URL as DEFAULT_GATEWAY_URL; pub(super) const MARKETPLACE_NAME: &str = "nemo-relay-local"; pub(super) const PLUGIN_NAME: &str = "nemo-relay-plugin"; pub(super) const RELAY_COMMAND: &str = "nemo-relay"; diff --git a/crates/cli/src/agents/shared/host.rs b/crates/cli/src/agents/shared/host.rs index f8a3648e1..15725eeff 100644 --- a/crates/cli/src/agents/shared/host.rs +++ b/crates/cli/src/agents/shared/host.rs @@ -10,12 +10,13 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; use toml_edit::{DocumentMut, Item, Table}; +pub(super) use crate::bootstrap::current_exe; pub(super) use crate::filesystem::{ atomic_write, atomic_write_private, atomic_write_with_permissions, }; #[cfg(windows)] use crate::filesystem::{atomic_write_with_windows_dacl, read_windows_dacl}; -pub(super) use crate::sidecar::{current_exe, healthz}; +pub(super) use crate::gateway::client::healthz; pub(crate) fn shell_quote(path: &Path) -> String { shell_quote_for_platform(path, cfg!(windows)) diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/bootstrap/mod.rs similarity index 93% rename from crates/cli/src/sidecar.rs rename to crates/cli/src/bootstrap/mod.rs index 6fde2f9b3..605b99d52 100644 --- a/crates/cli/src/sidecar.rs +++ b/crates/cli/src/bootstrap/mod.rs @@ -3,9 +3,7 @@ //! Thin bootstrap coordinator for the existing Relay gateway server. -mod health; -mod process; -mod state; +pub(crate) mod state; use std::env; use std::ffi::OsString; @@ -21,32 +19,28 @@ use serde::Deserialize; use crate::configuration::{ServerArgs, resolve_persistent_server_config}; use crate::error::CliError; -use health::{RelayHealth, probe_with_instance as probe_relay_health_with_instance}; -pub(crate) use health::{ - VerifiedHttpError, VerifiedHttpResponse, authenticated_instance_id, healthz, - healthz_compatible, loopback_bind, +use crate::gateway::client::{ + self as gateway_client, RelayHealth, VerifiedHttpError, VerifiedHttpResponse, loopback_bind, + probe_with_instance as probe_relay_health_with_instance, }; +use crate::process::detached; #[cfg(test)] -pub(crate) use process::{ +pub(crate) use detached::{ WINDOWS_CREATE_BREAKAWAY_FROM_JOB, WINDOWS_CREATE_NEW_PROCESS_GROUP, WINDOWS_CREATE_NO_WINDOW, WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK, WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, - windows_creation_flags as windows_sidecar_creation_flags, + windows_creation_flags as windows_detached_creation_flags, }; -pub(crate) use state::BOOTSTRAP_STATE_DIR_ENV; #[cfg(test)] -pub(crate) use state::lock_name as sidecar_lock_name; -pub(crate) use state::{ - publish_owner_from_env as publish_sidecar_owner_from_env, state_dir as sidecar_state_dir, - stop_owned_and_reset as stop_owned_sidecar_and_reset, -}; +pub(crate) use state::lock_name as bootstrap_lock_name; +use state::{BOOTSTRAP_STATE_DIR_ENV, state_dir as bootstrap_state_dir}; pub(crate) const DEFAULT_BIND: &str = "127.0.0.1:47632"; pub(crate) const DEFAULT_URL: &str = "http://127.0.0.1:47632"; pub(crate) const HEALTHZ_TIMEOUT: Duration = Duration::from_millis(500); pub(crate) const BOOTSTRAP_PROTOCOL_VERSION: u64 = 2; -pub(super) const SIDECAR_LOCK_TIMEOUT: Duration = Duration::from_secs(20); -const SIDECAR_START_TIMEOUT: Duration = Duration::from_secs(10); +pub(super) const BOOTSTRAP_LOCK_TIMEOUT: Duration = Duration::from_secs(20); +const BOOTSTRAP_START_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct GatewayEndpoint { @@ -104,7 +98,7 @@ impl GatewaySpec { } pub(crate) fn healthy_instance(&self, url: &str) -> Option { - health::compatible_instance_id(url, self.bootstrap_fingerprint.as_deref()) + gateway_client::compatible_instance_id(url, self.bootstrap_fingerprint.as_deref()) } pub(crate) fn existing_healthy_instance(&self, url: &str) -> Result, String> { @@ -128,7 +122,7 @@ impl GatewaySpec { let Some(fingerprint) = self.bootstrap_fingerprint.as_deref() else { return Err(VerifiedHttpError::missing_fingerprint()); }; - health::post_verified( + gateway_client::post_verified( url, fingerprint, path, @@ -161,7 +155,7 @@ fn acquire_gateway(spec: &GatewaySpec) -> Result { } } - let state = sidecar_state_dir()?; + let state = bootstrap_state_dir()?; state::create_private_dir(&state)?; let _startup_lock = state::lock_endpoint(&state, &url)?; if spec.bind.port() == 0 { @@ -177,7 +171,7 @@ fn acquire_gateway(spec: &GatewaySpec) -> Result { fn recover_gateway(spec: &GatewaySpec, expected_instance: &str) -> Result { let requested_url = format!("http://{}", spec.bind); - let state = sidecar_state_dir()?; + let state = bootstrap_state_dir()?; state::create_private_dir(&state)?; let _startup_lock = state::lock_endpoint(&state, &requested_url)?; @@ -302,11 +296,11 @@ fn start_gateway(spec: &GatewaySpec, state: &Path) -> Result Result<(), String> { }) .map(|_| ()) }, - process::terminate_tree, + detached::terminate_tree, ) } @@ -402,7 +396,7 @@ impl ArmedChild { impl Drop for ArmedChild { fn drop(&mut self) { if let Some(mut child) = self.0.take() { - process::terminate_tree(&mut child); + detached::terminate_tree(&mut child); } } } @@ -532,5 +526,5 @@ pub(crate) fn plugin_heartbeat_interval() -> Result { } #[cfg(test)] -#[path = "../tests/coverage/shared/sidecar_tests.rs"] +#[path = "../../tests/coverage/shared/bootstrap_tests.rs"] mod tests; diff --git a/crates/cli/src/sidecar/state.rs b/crates/cli/src/bootstrap/state.rs similarity index 97% rename from crates/cli/src/sidecar/state.rs rename to crates/cli/src/bootstrap/state.rs index 3a0878051..3df4922fd 100644 --- a/crates/cli/src/sidecar/state.rs +++ b/crates/cli/src/bootstrap/state.rs @@ -15,8 +15,8 @@ use serde::{Deserialize, Serialize}; use crate::filesystem::{LockAttempt, atomic_write, try_lock_exclusive}; -use super::health::{RelayHealth, probe, request_shutdown}; -use super::{BOOTSTRAP_PROTOCOL_VERSION, SIDECAR_LOCK_TIMEOUT}; +use super::{BOOTSTRAP_LOCK_TIMEOUT, BOOTSTRAP_PROTOCOL_VERSION}; +use crate::gateway::client::{RelayHealth, probe, request_shutdown}; pub(crate) const BOOTSTRAP_STATE_DIR_ENV: &str = "NEMO_RELAY_BOOTSTRAP_STATE_DIR"; const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); @@ -137,7 +137,7 @@ pub(super) fn write_recovery( } pub(crate) fn lock_endpoint(state: &Path, url: &str) -> Result { - lock_endpoint_for(state, url, SIDECAR_LOCK_TIMEOUT) + lock_endpoint_for(state, url, BOOTSTRAP_LOCK_TIMEOUT) } pub(crate) fn lock_endpoint_for( @@ -323,5 +323,5 @@ pub(crate) fn lock_name(url: &str) -> String { } #[cfg(test)] -#[path = "../../tests/coverage/shared/sidecar_state_tests.rs"] +#[path = "../../tests/coverage/shared/bootstrap_state_tests.rs"] mod tests; diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs index 5867f16c5..fe495a3bc 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -877,11 +877,11 @@ fn persistent_bootstrap_fingerprint( .map(dynamic_plugin_bootstrap_identity) .collect::, _>>()?; let gateway = &resolved.gateway; - let idle_timeout_secs = crate::sidecar::plugin_idle_timeout() + let idle_timeout_secs = crate::bootstrap::plugin_idle_timeout() .map_err(CliError::Config)? .as_secs(); let document = serde_json::json!({ - "bootstrap_protocol": crate::sidecar::BOOTSTRAP_PROTOCOL_VERSION, + "bootstrap_protocol": crate::bootstrap::BOOTSTRAP_PROTOCOL_VERSION, "relay_version": env!("CARGO_PKG_VERSION"), "openai_base_url": gateway.openai_base_url, "anthropic_base_url": gateway.anthropic_base_url, diff --git a/crates/cli/src/sidecar/health.rs b/crates/cli/src/gateway/client.rs similarity index 97% rename from crates/cli/src/sidecar/health.rs rename to crates/cli/src/gateway/client.rs index 07fffef82..749abd99c 100644 --- a/crates/cli/src/sidecar/health.rs +++ b/crates/cli/src/gateway/client.rs @@ -13,10 +13,10 @@ use serde_json::Value; use crate::configuration::BootstrapChallengeKey; -use super::{BOOTSTRAP_PROTOCOL_VERSION, HEALTHZ_TIMEOUT}; +use crate::bootstrap::{BOOTSTRAP_PROTOCOL_VERSION, HEALTHZ_TIMEOUT}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum RelayHealth { +pub(crate) enum RelayHealth { Compatible, Incompatible, Foreign, @@ -47,7 +47,7 @@ impl VerifiedHttpError { } } - pub(super) fn missing_fingerprint() -> Self { + pub(crate) fn missing_fingerprint() -> Self { Self::after_payload("managed Relay gateway is missing its bootstrap fingerprint") } } @@ -205,11 +205,11 @@ pub(crate) fn authenticated_instance_id(url: &str, bootstrap_fingerprint: &str) compatible_instance_id(url, Some(bootstrap_fingerprint)) } -pub(super) fn probe(url: &str, bootstrap_fingerprint: Option<&str>) -> RelayHealth { +pub(crate) fn probe(url: &str, bootstrap_fingerprint: Option<&str>) -> RelayHealth { probe_with_instance(url, bootstrap_fingerprint).0 } -pub(super) fn compatible_instance_id( +pub(crate) fn compatible_instance_id( url: &str, bootstrap_fingerprint: Option<&str>, ) -> Option { @@ -219,7 +219,7 @@ pub(super) fn compatible_instance_id( .flatten() } -pub(super) fn probe_with_instance( +pub(crate) fn probe_with_instance( url: &str, bootstrap_fingerprint: Option<&str>, ) -> (RelayHealth, Option) { @@ -288,7 +288,7 @@ pub(super) fn probe_with_instance( ) } -pub(super) fn request_shutdown(url: &str, token: &str) -> Result<(), String> { +pub(crate) fn request_shutdown(url: &str, token: &str) -> Result<(), String> { let (host, port) = parse_loopback_url(url)?; let address = (host.as_str(), port) .to_socket_addrs() @@ -517,5 +517,5 @@ pub(crate) fn loopback_authority(host: &str, port: u16) -> String { } #[cfg(test)] -#[path = "../../tests/coverage/shared/sidecar_health_tests.rs"] +#[path = "../../tests/coverage/shared/gateway_client_tests.rs"] mod tests; diff --git a/crates/cli/src/gateway/mod.rs b/crates/cli/src/gateway/mod.rs index 3cc1a76fd..9fc4671ff 100644 --- a/crates/cli/src/gateway/mod.rs +++ b/crates/cli/src/gateway/mod.rs @@ -1285,6 +1285,7 @@ fn is_hop_by_hop(name: &HeaderName) -> bool { ) } +pub(crate) mod client; #[cfg(test)] #[path = "../../tests/coverage/shared/gateway_tests.rs"] mod tests; diff --git a/crates/cli/src/hooks/mod.rs b/crates/cli/src/hooks/mod.rs index bb5a762b6..e881058c6 100644 --- a/crates/cli/src/hooks/mod.rs +++ b/crates/cli/src/hooks/mod.rs @@ -189,13 +189,13 @@ fn resolve_hook_destination( return HookDestination { gateway_url: command_url .or(environment_url) - .unwrap_or_else(|| crate::sidecar::DEFAULT_URL.into()), + .unwrap_or_else(|| crate::bootstrap::DEFAULT_URL.into()), lifecycle: HookGatewayLifecycle::Transparent, }; } if forward_only { return HookDestination { - gateway_url: command_url.unwrap_or_else(|| crate::sidecar::DEFAULT_URL.into()), + gateway_url: command_url.unwrap_or_else(|| crate::bootstrap::DEFAULT_URL.into()), lifecycle: HookGatewayLifecycle::Existing, }; } @@ -212,13 +212,13 @@ fn resolve_hook_destination( }; } HookDestination { - gateway_url: crate::sidecar::DEFAULT_URL.into(), + gateway_url: crate::bootstrap::DEFAULT_URL.into(), lifecycle: HookGatewayLifecycle::Existing, } } async fn wait_for_existing_gateway( - gateway: crate::sidecar::GatewaySpec, + gateway: crate::bootstrap::GatewaySpec, gateway_url: String, ) -> Result<(), CliError> { tokio::task::spawn_blocking(move || { @@ -243,25 +243,27 @@ async fn wait_for_existing_gateway( .map_err(CliError::Launch) } -fn recovery_plan(gateway_url: &str) -> Result { - let bind = crate::sidecar::loopback_bind(gateway_url).map_err(CliError::Install)?; - crate::sidecar::resolve_plugin_gateway(&Default::default(), bind) +fn recovery_plan(gateway_url: &str) -> Result { + let bind = crate::gateway::client::loopback_bind(gateway_url).map_err(CliError::Install)?; + crate::bootstrap::resolve_plugin_gateway(&Default::default(), bind) } -fn transparent_gateway_spec(gateway_url: &str) -> Result { - let bind = crate::sidecar::loopback_bind(gateway_url).map_err(CliError::Install)?; - Ok(crate::sidecar::GatewaySpec::new(bind).with_fingerprint( +fn transparent_gateway_spec(gateway_url: &str) -> Result { + let bind = crate::gateway::client::loopback_bind(gateway_url).map_err(CliError::Install)?; + Ok(crate::bootstrap::GatewaySpec::new(bind).with_fingerprint( crate::configuration::transparent_gateway_fingerprint(gateway_url), )) } async fn send_verified_hook_forward_request( command: &HookForwardCommand, - gateway: &crate::sidecar::GatewaySpec, + gateway: &crate::bootstrap::GatewaySpec, gateway_url: &str, input: String, -) -> Result, CliError> -{ +) -> Result< + Result, + CliError, +> { let headers = gateway_headers( command.profile.as_deref(), command.session_metadata.as_deref(), @@ -351,7 +353,10 @@ async fn handle_hook_forward_response( } fn handle_verified_hook_forward_response( - response: Result, + response: Result< + crate::gateway::client::VerifiedHttpResponse, + crate::gateway::client::VerifiedHttpError, + >, fail_closed: bool, ) -> Result<(), CliError> { match response { @@ -515,7 +520,7 @@ fn persistent_hook_arguments( "hook-forward".into(), agent.as_arg().into(), "--gateway-url".into(), - crate::sidecar::DEFAULT_URL.into(), + crate::bootstrap::DEFAULT_URL.into(), "--generation-file".into(), generation_file.display().to_string(), "--generation-token".into(), diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 114ce827f..7cfe89b04 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -5,6 +5,7 @@ mod agents; mod banner; +mod bootstrap; mod commands; mod completions_install; mod configuration; @@ -22,7 +23,6 @@ mod process; mod server; mod sessions; mod setup; -mod sidecar; #[cfg(test)] #[path = "../tests/coverage/shared/hook_assertions.rs"] diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs index 68a090984..1e0300106 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -66,7 +66,7 @@ pub(crate) fn persistent_server( "command": relay, "args": LAUNCH_ARGS, "env": { - "NEMO_RELAY_GATEWAY_BIND": crate::sidecar::DEFAULT_BIND, + "NEMO_RELAY_GATEWAY_BIND": crate::bootstrap::DEFAULT_BIND, (GENERATION_FILE_ENV): generation_file, (GENERATION_TOKEN_ENV): generation_token } @@ -81,7 +81,7 @@ fn transparent_run_active() -> bool { } fn default_mcp_bind() -> SocketAddr { - crate::sidecar::DEFAULT_BIND + crate::bootstrap::DEFAULT_BIND .parse() .expect("default MCP gateway bind is valid") } diff --git a/crates/cli/src/mcp/gateway.rs b/crates/cli/src/mcp/gateway.rs index a32c05e8b..1d3f4343e 100644 --- a/crates/cli/src/mcp/gateway.rs +++ b/crates/cli/src/mcp/gateway.rs @@ -6,10 +6,10 @@ use std::net::SocketAddr; use std::time::Duration; +use crate::bootstrap::{GatewayEndpoint, GatewaySpec}; use crate::configuration::ServerArgs; use crate::error::CliError; use crate::installation::generation::{ActiveGenerationGuard, InstallGeneration}; -use crate::sidecar::{GatewayEndpoint, GatewaySpec}; const UNHEALTHY_CONFIRMATIONS: u8 = 3; const UNHEALTHY_CONFIRMATION_INTERVAL: Duration = Duration::from_millis(50); @@ -34,9 +34,9 @@ impl GatewayPlan { .map(|(generation, guard)| (Some(generation), Some(guard))) .unwrap_or((None, None)); let bind = server_args.bind.unwrap_or_else(super::default_mcp_bind); - let launch = crate::sidecar::resolve_plugin_gateway(server_args, bind)?; + let launch = crate::bootstrap::resolve_plugin_gateway(server_args, bind)?; let heartbeat_interval = - crate::sidecar::plugin_heartbeat_interval().map_err(CliError::Launch)?; + crate::bootstrap::plugin_heartbeat_interval().map_err(CliError::Launch)?; Ok(Self { spec: launch.gateway, heartbeat_interval, @@ -51,7 +51,7 @@ impl GatewayPlan { Ok(GatewayLease { monitor }) } - async fn monitor(self, endpoint: crate::sidecar::GatewayEndpoint) -> Result<(), CliError> { + async fn monitor(self, endpoint: crate::bootstrap::GatewayEndpoint) -> Result<(), CliError> { let health_spec = self.spec.clone(); let restart_spec = self.spec.clone(); let restart_generation = self.generation.clone(); @@ -162,7 +162,7 @@ async fn authenticated_instance_id( bootstrap_fingerprint: String, ) -> Result, CliError> { tokio::task::spawn_blocking(move || { - crate::sidecar::authenticated_instance_id(&gateway_url, &bootstrap_fingerprint) + crate::gateway::client::authenticated_instance_id(&gateway_url, &bootstrap_fingerprint) }) .await .map_err(|error| { @@ -275,7 +275,7 @@ where { maintain_gateway_instances_with_generation( bind, - crate::sidecar::GatewayEndpoint { + crate::bootstrap::GatewayEndpoint { address: bind, url: gateway_url, instance_id: "test-initial-instance".into(), @@ -297,7 +297,7 @@ where async fn maintain_gateway_instances_with_generation( bind: SocketAddr, - mut endpoint: crate::sidecar::GatewayEndpoint, + mut endpoint: crate::bootstrap::GatewayEndpoint, heartbeat_interval: Duration, mut healthy: H, mut restart: R, diff --git a/crates/cli/src/sidecar/process.rs b/crates/cli/src/process/detached.rs similarity index 100% rename from crates/cli/src/sidecar/process.rs rename to crates/cli/src/process/detached.rs diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index 513fe1aaa..379e32d48 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -1125,7 +1125,7 @@ async fn wait_for_health(gateway_url: &str, bootstrap_fingerprint: &str) -> Resu let gateway_url = gateway_url.to_string(); let bootstrap_fingerprint = bootstrap_fingerprint.to_string(); if tokio::task::spawn_blocking(move || { - crate::sidecar::healthz_compatible(&gateway_url, &bootstrap_fingerprint) + crate::gateway::client::healthz_compatible(&gateway_url, &bootstrap_fingerprint) }) .await .map_err(|error| CliError::Launch(format!("gateway readiness task failed: {error}")))? diff --git a/crates/cli/src/process/mod.rs b/crates/cli/src/process/mod.rs index cad4f30a9..1337220cd 100644 --- a/crates/cli/src/process/mod.rs +++ b/crates/cli/src/process/mod.rs @@ -3,6 +3,7 @@ //! Shared coding-agent command parsing, discovery, and process construction. +pub(crate) mod detached; pub(crate) mod launcher; use std::ffi::{OsStr, OsString}; diff --git a/crates/cli/src/server/mod.rs b/crates/cli/src/server/mod.rs index 073842bfb..8d999b7f1 100644 --- a/crates/cli/src/server/mod.rs +++ b/crates/cli/src/server/mod.rs @@ -251,7 +251,7 @@ async fn serve_listener_with_dynamic_inner( identity.verify_current()?; } let _owner = - crate::sidecar::publish_sidecar_owner_from_env(local_address).map_err(CliError::Launch)?; + crate::bootstrap::state::publish_owner_from_env(local_address).map_err(CliError::Launch)?; if let Some(path) = ready_file { write_ready_file(path, local_address, &instance_id)?; } @@ -520,7 +520,7 @@ async fn healthz(State(state): State, headers: HeaderMap) -> Response "status": if compatible { "ok" } else { "incompatible" }, "service": "nemo-relay", "version": env!("CARGO_PKG_VERSION"), - "bootstrap_protocol": crate::sidecar::BOOTSTRAP_PROTOCOL_VERSION, + "bootstrap_protocol": crate::bootstrap::BOOTSTRAP_PROTOCOL_VERSION, "instance_id": state.instance_id, })), ) @@ -532,7 +532,7 @@ fn write_ready_file(path: &Path, bind: SocketAddr, instance_id: &str) -> Result< "address": bind, "service": "nemo-relay", "version": env!("CARGO_PKG_VERSION"), - "bootstrap_protocol": crate::sidecar::BOOTSTRAP_PROTOCOL_VERSION, + "bootstrap_protocol": crate::bootstrap::BOOTSTRAP_PROTOCOL_VERSION, "instance_id": instance_id, })) .map_err(|error| CliError::Launch(format!("failed to encode readiness file: {error}")))?; diff --git a/crates/cli/tests/architecture_tests.rs b/crates/cli/tests/architecture_tests.rs index 641f8e179..a9279dc1d 100644 --- a/crates/cli/tests/architecture_tests.rs +++ b/crates/cli/tests/architecture_tests.rs @@ -36,6 +36,8 @@ fn retired_top_level_agent_modules_do_not_return() { "plugin_install", "hermes.rs", "coding_agent.rs", + "sidecar", + "sidecar.rs", ] { assert!(!src.join(path).exists(), "retired module returned: {path}"); } diff --git a/crates/cli/tests/coverage/agents/hermes_tests.rs b/crates/cli/tests/coverage/agents/hermes_tests.rs index d3a7a9587..9825bd3f1 100644 --- a/crates/cli/tests/coverage/agents/hermes_tests.rs +++ b/crates/cli/tests/coverage/agents/hermes_tests.rs @@ -134,7 +134,7 @@ fn hook_command_round_trips_paths_and_platform_metacharacters() { "hook-forward", "hermes", "--gateway-url", - crate::sidecar::DEFAULT_URL, + crate::bootstrap::DEFAULT_URL, "--generation-file", r"C:\Temp\generation", "--generation-token", diff --git a/crates/cli/tests/coverage/agents/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs index dbb5cf3c4..de50c1a24 100644 --- a/crates/cli/tests/coverage/agents/launcher_tests.rs +++ b/crates/cli/tests/coverage/agents/launcher_tests.rs @@ -40,7 +40,7 @@ impl EnvScope { fn without_managed_bootstrap() -> Self { Self::set(&[ - (crate::sidecar::BOOTSTRAP_STATE_DIR_ENV, None), + (crate::bootstrap::state::BOOTSTRAP_STATE_DIR_ENV, None), ("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", None), (crate::configuration::BOOTSTRAP_FINGERPRINT_ENV, None), ]) diff --git a/crates/cli/tests/coverage/agents/plugin_host_tests.rs b/crates/cli/tests/coverage/agents/plugin_host_tests.rs index ca3c174e8..b5560b12f 100644 --- a/crates/cli/tests/coverage/agents/plugin_host_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_host_tests.rs @@ -3432,7 +3432,7 @@ fn shared_defaults_cover_idle_lifecycle_and_lock_names() { plugin_heartbeat_interval().unwrap(), Duration::from_secs(30) ); - assert_eq!(sidecar_lock_name(""), "unknown"); + assert_eq!(bootstrap_lock_name(""), "unknown"); } #[test] diff --git a/crates/cli/tests/coverage/shared/sidecar_state_tests.rs b/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs similarity index 100% rename from crates/cli/tests/coverage/shared/sidecar_state_tests.rs rename to crates/cli/tests/coverage/shared/bootstrap_state_tests.rs diff --git a/crates/cli/tests/coverage/shared/sidecar_tests.rs b/crates/cli/tests/coverage/shared/bootstrap_tests.rs similarity index 96% rename from crates/cli/tests/coverage/shared/sidecar_tests.rs rename to crates/cli/tests/coverage/shared/bootstrap_tests.rs index f3bdf74c2..c024c9b14 100644 --- a/crates/cli/tests/coverage/shared/sidecar_tests.rs +++ b/crates/cli/tests/coverage/shared/bootstrap_tests.rs @@ -272,14 +272,14 @@ fn binary_override_is_explicit_and_validated() { #[test] fn windows_detachment_requests_only_supported_breakaway_flags() { let base = WINDOWS_CREATE_NEW_PROCESS_GROUP | WINDOWS_CREATE_NO_WINDOW; - assert_eq!(windows_sidecar_creation_flags(false, None), (base, false)); + assert_eq!(windows_detached_creation_flags(false, None), (base, false)); assert_eq!( - windows_sidecar_creation_flags(true, Some(WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK)), + windows_detached_creation_flags(true, Some(WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK)), (base | WINDOWS_CREATE_BREAKAWAY_FROM_JOB, false) ); assert_eq!( - windows_sidecar_creation_flags(true, Some(WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK)), + windows_detached_creation_flags(true, Some(WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK)), (base, false) ); - assert_eq!(windows_sidecar_creation_flags(true, Some(0)), (base, true)); + assert_eq!(windows_detached_creation_flags(true, Some(0)), (base, true)); } diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index 717e33ea3..5f91bebcf 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -1880,7 +1880,8 @@ fn plugin_launch_carries_effective_hook_limit_below_and_above_default() { format!("[gateway]\nmax_hook_payload_bytes = {limit}\n"), ) .unwrap(); - let launch = crate::sidecar::resolve_plugin_gateway(&ServerArgs::default(), bind).unwrap(); + let launch = + crate::bootstrap::resolve_plugin_gateway(&ServerArgs::default(), bind).unwrap(); assert_eq!(launch.max_hook_payload_bytes, limit); } } diff --git a/crates/cli/tests/coverage/shared/sidecar_health_tests.rs b/crates/cli/tests/coverage/shared/gateway_client_tests.rs similarity index 100% rename from crates/cli/tests/coverage/shared/sidecar_health_tests.rs rename to crates/cli/tests/coverage/shared/gateway_client_tests.rs diff --git a/crates/cli/tests/coverage/shared/installer_tests.rs b/crates/cli/tests/coverage/shared/installer_tests.rs index 1084b1f8e..e21d79674 100644 --- a/crates/cli/tests/coverage/shared/installer_tests.rs +++ b/crates/cli/tests/coverage/shared/installer_tests.rs @@ -61,7 +61,7 @@ async fn transparent_hook_delivery_authenticates_the_wrapper_gateway() { let url = gateway_url.clone(); let fingerprint = fingerprint.clone(); if tokio::task::spawn_blocking(move || { - crate::sidecar::healthz_compatible(&url, &fingerprint) + crate::gateway::client::healthz_compatible(&url, &fingerprint) }) .await .unwrap() @@ -170,7 +170,7 @@ fn explicit_persistent_destinations_ignore_ambient_urls() { assert_eq!(destination.lifecycle, HookGatewayLifecycle::Existing); let destination = resolve_hook_destination(None, Some("http://dynamic".into()), true, false); - assert_eq!(destination.gateway_url, crate::sidecar::DEFAULT_URL); + assert_eq!(destination.gateway_url, crate::bootstrap::DEFAULT_URL); assert_eq!(destination.lifecycle, HookGatewayLifecycle::Existing); let destination = resolve_hook_destination(Some("http://embedded".into()), None, false, true); @@ -178,14 +178,14 @@ fn explicit_persistent_destinations_ignore_ambient_urls() { assert_eq!(destination.lifecycle, HookGatewayLifecycle::Transparent); let destination = resolve_hook_destination(None, None, false, false); - assert_eq!(destination.gateway_url, crate::sidecar::DEFAULT_URL); + assert_eq!(destination.gateway_url, crate::bootstrap::DEFAULT_URL); assert_eq!(destination.lifecycle, HookGatewayLifecycle::Existing); } #[test] fn verified_hook_response_rejects_invalid_status_and_fail_open_http_errors() { let error = handle_verified_hook_forward_response( - Ok(crate::sidecar::VerifiedHttpResponse { + Ok(crate::gateway::client::VerifiedHttpResponse { status: 0, body: Vec::new(), }), @@ -474,14 +474,14 @@ fn packaged_plugin_hooks_use_expected_forwarding_commands() { claude["hooks"]["SessionStart"][0]["hooks"][0]["command"], json!(format!( "nemo-relay hook-forward claude --gateway-url {} --forward-only", - crate::sidecar::DEFAULT_URL + crate::bootstrap::DEFAULT_URL )) ); assert_eq!( codex["hooks"]["SessionStart"][0]["hooks"][0]["command"], json!(format!( "nemo-relay hook-forward codex --gateway-url {} --forward-only", - crate::sidecar::DEFAULT_URL + crate::bootstrap::DEFAULT_URL )) ); assert_eq!( @@ -490,7 +490,7 @@ fn packaged_plugin_hooks_use_expected_forwarding_commands() { CodingAgent::ClaudeCode, &format!( "nemo-relay hook-forward claude --gateway-url {} --forward-only", - crate::sidecar::DEFAULT_URL + crate::bootstrap::DEFAULT_URL ), )["hooks"] ); @@ -500,7 +500,7 @@ fn packaged_plugin_hooks_use_expected_forwarding_commands() { CodingAgent::Codex, &format!( "nemo-relay hook-forward codex --gateway-url {} --forward-only", - crate::sidecar::DEFAULT_URL + crate::bootstrap::DEFAULT_URL ), )["hooks"] ); diff --git a/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs b/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs index ddeb6ab25..a749eaef6 100644 --- a/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs +++ b/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs @@ -39,7 +39,7 @@ async fn production_heartbeat_recovers_after_one_thirty_second_interval() { if let Some(sender) = sender { let _ = sender.send(()); } - Ok(crate::sidecar::GatewayEndpoint { + Ok(crate::bootstrap::GatewayEndpoint { address, url: "http://recovered".into(), instance_id: "recovered".into(), @@ -63,7 +63,7 @@ async fn lifecycle_retirement_is_checked_before_a_healthy_heartbeat() { let health_calls_for_probe = health_calls.clone(); let monitor = tokio::spawn(maintain_gateway_instances_with_generation( "127.0.0.1:47632".parse().unwrap(), - crate::sidecar::GatewayEndpoint { + crate::bootstrap::GatewayEndpoint { address: "127.0.0.1:47632".parse().unwrap(), url: "http://gateway".into(), instance_id: "first".into(), @@ -96,7 +96,7 @@ async fn lifecycle_retirement_during_health_is_checked_before_adoption() { let retired_for_verification = retired.clone(); let monitor = tokio::spawn(maintain_gateway_instances_with_generation( "127.0.0.1:47632".parse().unwrap(), - crate::sidecar::GatewayEndpoint { + crate::bootstrap::GatewayEndpoint { address: "127.0.0.1:47632".parse().unwrap(), url: "http://gateway".into(), instance_id: "first".into(), @@ -177,7 +177,7 @@ async fn concurrent_clients_consume_the_same_replacement_allowance() { let observed_replacement = observed_replacement.clone(); monitors.push(tokio::spawn(maintain_gateway_instances_with_generation( "127.0.0.1:47632".parse().unwrap(), - crate::sidecar::GatewayEndpoint { + crate::bootstrap::GatewayEndpoint { address: "127.0.0.1:47632".parse().unwrap(), url: "http://gateway".into(), instance_id: "first".into(), @@ -200,7 +200,7 @@ async fn concurrent_clients_consume_the_same_replacement_allowance() { *current = Some("second".into()); restart_count.fetch_add(1, Ordering::SeqCst); } - Ok(crate::sidecar::GatewayEndpoint { + Ok(crate::bootstrap::GatewayEndpoint { address, url: "http://gateway".into(), instance_id: current.clone().unwrap(), diff --git a/crates/cli/tests/coverage/shared/mcp_tests.rs b/crates/cli/tests/coverage/shared/mcp_tests.rs index b9da9a80e..3ca2468f1 100644 --- a/crates/cli/tests/coverage/shared/mcp_tests.rs +++ b/crates/cli/tests/coverage/shared/mcp_tests.rs @@ -299,7 +299,7 @@ async fn heartbeat_keeps_a_compatible_gateway_session_alive() { loop { let probe_url = url.clone(); if tokio::task::spawn_blocking(move || { - crate::sidecar::healthz_compatible(&probe_url, fingerprint) + crate::gateway::client::healthz_compatible(&probe_url, fingerprint) }) .await .unwrap() @@ -326,7 +326,7 @@ async fn heartbeat_keeps_a_compatible_gateway_session_alive() { let observed_tx = observed_tx.clone(); async move { let healthy = tokio::task::spawn_blocking(move || { - crate::sidecar::healthz_compatible(&url, fingerprint) + crate::gateway::client::healthz_compatible(&url, fingerprint) }) .await .map_err(|error| { @@ -347,7 +347,7 @@ async fn heartbeat_keeps_a_compatible_gateway_session_alive() { move |address, _expected_instance| { restart_calls.fetch_add(1, Ordering::SeqCst); async move { - Ok(crate::sidecar::GatewayEndpoint { + Ok(crate::bootstrap::GatewayEndpoint { address, url: "http://unexpected-restart".into(), instance_id: "unexpected".into(), @@ -401,7 +401,7 @@ async fn borrowed_transparent_gateway_is_authenticated_and_monitored() { let probe_url = url.clone(); let probe_fingerprint = fingerprint.clone(); if tokio::task::spawn_blocking(move || { - crate::sidecar::healthz_compatible(&probe_url, &probe_fingerprint) + crate::gateway::client::healthz_compatible(&probe_url, &probe_fingerprint) }) .await .unwrap() @@ -464,7 +464,7 @@ async fn heartbeat_performs_one_restart_and_tracks_the_recovered_gateway() { let restart_calls = restart_calls.clone(); async move { restart_calls.fetch_add(1, Ordering::SeqCst); - Ok(crate::sidecar::GatewayEndpoint { + Ok(crate::bootstrap::GatewayEndpoint { address, url: "http://recovered-gateway".into(), instance_id: "recovered".into(), @@ -518,7 +518,7 @@ async fn heartbeat_ignores_isolated_transient_health_failures() { move |address, _expected_instance| { restart_calls.fetch_add(1, Ordering::SeqCst); async move { - Ok(crate::sidecar::GatewayEndpoint { + Ok(crate::bootstrap::GatewayEndpoint { address, url: "http://gateway".into(), instance_id: "gateway".into(), @@ -553,7 +553,7 @@ async fn heartbeat_rediscovery_consumes_the_shared_restart_allowance() { let restart_calls = restart_calls.clone(); async move { let attempt = restart_calls.fetch_add(1, Ordering::SeqCst); - Ok(crate::sidecar::GatewayEndpoint { + Ok(crate::bootstrap::GatewayEndpoint { address, url: "http://gateway".into(), instance_id: format!("gateway-{attempt}"), @@ -600,7 +600,7 @@ async fn heartbeat_attempts_at_most_one_successful_restart() { let restart_calls = restart_calls.clone(); async move { restart_calls.fetch_add(1, Ordering::SeqCst); - Ok(crate::sidecar::GatewayEndpoint { + Ok(crate::bootstrap::GatewayEndpoint { address, url: "http://still-unhealthy".into(), instance_id: "still-unhealthy".into(), @@ -654,7 +654,7 @@ async fn old_mcp_maintenance_loop_exits_when_install_generation_is_replaced() { move |address, _expected_instance| { restart_calls.fetch_add(1, Ordering::SeqCst); async move { - Ok(crate::sidecar::GatewayEndpoint { + Ok(crate::bootstrap::GatewayEndpoint { address, url: "http://unexpected-restart".into(), instance_id: "unexpected".into(), @@ -764,7 +764,7 @@ fn persistent_mcp_server_contract_is_host_neutral_and_generation_fenced() { assert_eq!(server["args"], json!(["mcp"])); assert_eq!( server["env"]["NEMO_RELAY_GATEWAY_BIND"], - crate::sidecar::DEFAULT_BIND + crate::bootstrap::DEFAULT_BIND ); assert_eq!( server["env"]["NEMO_RELAY_MCP_GENERATION_FILE"], diff --git a/crates/cli/tests/coverage/shared/server_tests.rs b/crates/cli/tests/coverage/shared/server_tests.rs index 00b820748..78fbc10a2 100644 --- a/crates/cli/tests/coverage/shared/server_tests.rs +++ b/crates/cli/tests/coverage/shared/server_tests.rs @@ -377,7 +377,7 @@ async fn healthz_returns_ok() { assert_eq!(body["version"], json!(env!("CARGO_PKG_VERSION"))); assert_eq!( body["bootstrap_protocol"], - json!(crate::sidecar::BOOTSTRAP_PROTOCOL_VERSION) + json!(crate::bootstrap::BOOTSTRAP_PROTOCOL_VERSION) ); assert!( body["instance_id"] @@ -576,7 +576,7 @@ fn readiness_file_is_published_atomically_with_gateway_identity() { assert_eq!(ready["version"], json!(env!("CARGO_PKG_VERSION"))); assert_eq!( ready["bootstrap_protocol"], - json!(crate::sidecar::BOOTSTRAP_PROTOCOL_VERSION) + json!(crate::bootstrap::BOOTSTRAP_PROTOCOL_VERSION) ); assert_eq!(ready["instance_id"], json!("test-instance")); assert!(!path.with_extension("json.tmp").exists()); From 64f091e946271a4dbda17d394aeb2ddb2c0fc9b0 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 21:13:42 -0400 Subject: [PATCH 022/102] refactor(cli): normalize command and service module roots Signed-off-by: Will Killian --- .../completions/install.rs} | 2 +- .../cli/src/commands/{completions.rs => completions/mod.rs} | 4 +++- crates/cli/src/lib.rs | 1 - crates/cli/src/{mcp.rs => mcp/mod.rs} | 2 +- crates/cli/src/{plugins.rs => plugins/mod.rs} | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) rename crates/cli/src/{completions_install.rs => commands/completions/install.rs} (98%) rename crates/cli/src/commands/{completions.rs => completions/mod.rs} (93%) rename crates/cli/src/{mcp.rs => mcp/mod.rs} (98%) rename crates/cli/src/{plugins.rs => plugins/mod.rs} (99%) diff --git a/crates/cli/src/completions_install.rs b/crates/cli/src/commands/completions/install.rs similarity index 98% rename from crates/cli/src/completions_install.rs rename to crates/cli/src/commands/completions/install.rs index 4714508d3..dee6e73d1 100644 --- a/crates/cli/src/completions_install.rs +++ b/crates/cli/src/commands/completions/install.rs @@ -112,5 +112,5 @@ fn write_atomic(target: &Path, bytes: &[u8]) -> Result<(), CliError> { } #[cfg(test)] -#[path = "../tests/coverage/shared/completions_install_tests.rs"] +#[path = "../../../tests/coverage/shared/completions_install_tests.rs"] mod tests; diff --git a/crates/cli/src/commands/completions.rs b/crates/cli/src/commands/completions/mod.rs similarity index 93% rename from crates/cli/src/commands/completions.rs rename to crates/cli/src/commands/completions/mod.rs index 6728ed5d1..e543d3c01 100644 --- a/crates/cli/src/commands/completions.rs +++ b/crates/cli/src/commands/completions/mod.rs @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +mod install; + use std::process::ExitCode; use clap::CommandFactory; @@ -10,7 +12,7 @@ use crate::error::CliError; pub(super) fn execute(command: CompletionsCommand) -> Result { if command.install { - let path = crate::completions_install::install(command.shell)?; + let path = install::install(command.shell)?; println!("✓ Installed completions: {}", path.display()); } else { generate_to(command.shell, &mut std::io::stdout())?; diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 7cfe89b04..277e91d82 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -7,7 +7,6 @@ mod agents; mod banner; mod bootstrap; mod commands; -mod completions_install; mod configuration; mod diagnostics; mod error; diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp/mod.rs similarity index 98% rename from crates/cli/src/mcp.rs rename to crates/cli/src/mcp/mod.rs index 1e0300106..b3a378254 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp/mod.rs @@ -106,5 +106,5 @@ use session::serve_stdio; use transport::{MAX_MCP_FRAME_BYTES, read_bounded_frame}; #[cfg(test)] -#[path = "../tests/coverage/shared/mcp_tests.rs"] +#[path = "../../tests/coverage/shared/mcp_tests.rs"] mod tests; diff --git a/crates/cli/src/plugins.rs b/crates/cli/src/plugins/mod.rs similarity index 99% rename from crates/cli/src/plugins.rs rename to crates/cli/src/plugins/mod.rs index c0ae61809..f4d114fbf 100644 --- a/crates/cli/src/plugins.rs +++ b/crates/cli/src/plugins/mod.rs @@ -1379,5 +1379,5 @@ fn editor_error(err: dialoguer::Error) -> CliError { } #[cfg(test)] -#[path = "../tests/coverage/shared/plugins_tests.rs"] +#[path = "../../tests/coverage/shared/plugins_tests.rs"] mod tests; From 54630012d927806ed85b1072ebb784e3ddc63fbc Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 21:23:59 -0400 Subject: [PATCH 023/102] refactor(cli): move setup into configuration Signed-off-by: Will Killian --- crates/cli/src/commands/configuration.rs | 4 ++-- crates/cli/src/commands/mod.rs | 3 +-- crates/cli/src/configuration/mod.rs | 2 ++ crates/cli/src/{setup.rs => configuration/wizard.rs} | 3 ++- .../cli/src/{setup/model.rs => configuration/wizard_model.rs} | 0 crates/cli/src/lib.rs | 1 - crates/cli/src/process/launcher.rs | 4 ++-- 7 files changed, 9 insertions(+), 8 deletions(-) rename crates/cli/src/{setup.rs => configuration/wizard.rs} (99%) rename crates/cli/src/{setup/model.rs => configuration/wizard_model.rs} (100%) diff --git a/crates/cli/src/commands/configuration.rs b/crates/cli/src/commands/configuration.rs index acf79c8e9..449d62661 100644 --- a/crates/cli/src/commands/configuration.rs +++ b/crates/cli/src/commands/configuration.rs @@ -8,9 +8,9 @@ use crate::error::CliError; pub(super) async fn execute(command: ConfigCommand) -> Result { if command.reset { - crate::setup::reset(command.agent)?; + crate::configuration::wizard::reset(command.agent)?; } else { - crate::setup::run(command.agent).await?; + crate::configuration::wizard::run(command.agent).await?; } Ok(ExitCode::SUCCESS) } diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index ab9945145..5db276b9f 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -22,7 +22,6 @@ use crate::configuration::{Cli, CodingAgent, Command, ServerArgs}; use crate::configuration::{CompletionsCommand, PluginsCommand, PricingCommand}; use crate::{ configuration as runtime_configuration, diagnostics as runtime_diagnostics, error, server, - setup, }; // Runs the async CLI entrypoint and converts any surfaced gateway error into a non-zero process @@ -114,7 +113,7 @@ async fn run_default(server_args: &ServerArgs) -> Result CliError { } #[cfg(test)] -#[path = "../tests/coverage/shared/setup_tests.rs"] +#[path = "../../tests/coverage/shared/setup_tests.rs"] mod tests; diff --git a/crates/cli/src/setup/model.rs b/crates/cli/src/configuration/wizard_model.rs similarity index 100% rename from crates/cli/src/setup/model.rs rename to crates/cli/src/configuration/wizard_model.rs diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 277e91d82..1b98d92db 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -21,7 +21,6 @@ mod plugins; mod process; mod server; mod sessions; -mod setup; #[cfg(test)] #[path = "../tests/coverage/shared/hook_assertions.rs"] diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index 379e32d48..0d097b928 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -42,7 +42,7 @@ pub(crate) async fn run( /// Runs the easy-path bare-agent shortcut (`nemo-relay claude`, `nemo-relay codex`, etc.). /// /// If no config file is present at any discovery layer, this fires the interactive setup inline -/// (`crate::setup::run`) which writes a `config.toml`, then proceeds to launch the agent. When +/// (`crate::configuration::wizard::run`) which writes a `config.toml`, then proceeds to launch the agent. When /// config IS present, the easy path constructs a synthetic `RunCommand` and delegates to the /// same transparent-run pipeline `nemo-relay run` uses — same observability wiring, same agent /// argv resolution, same lifecycle management. @@ -65,7 +65,7 @@ pub(crate) async fn easy_path( // it returns, config discovery will pick up the freshly-written `config.toml` and // `run()` below will see a populated environment. If setup errors (non-TTY, user // cancelled), surface that directly. - crate::setup::run(Some(agent)).await?; + crate::configuration::wizard::run(Some(agent)).await?; } let synthetic = RunCommand { agent: Some(agent), From 9b9ad4640a1065a02d6752577b9166e0b623295d Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 21:31:38 -0400 Subject: [PATCH 024/102] refactor(cli): isolate command arguments from runtime inputs Signed-off-by: Will Killian --- crates/cli/src/agents/install/mod.rs | 6 +- crates/cli/src/agents/mod.rs | 5 +- crates/cli/src/bootstrap/mod.rs | 4 +- crates/cli/src/commands/arguments.rs | 746 ++++++++++++++++++ .../cli/src/commands/completions/install.rs | 2 +- crates/cli/src/commands/completions/mod.rs | 2 +- crates/cli/src/commands/configuration.rs | 7 +- crates/cli/src/commands/diagnostics.rs | 6 +- crates/cli/src/commands/hook_forward.rs | 4 +- crates/cli/src/commands/install.rs | 6 +- crates/cli/src/commands/mcp.rs | 4 +- crates/cli/src/commands/mod.rs | 17 +- crates/cli/src/commands/model_pricing.rs | 16 +- crates/cli/src/commands/plugins.rs | 31 +- crates/cli/src/commands/run.rs | 8 +- crates/cli/src/configuration/mod.rs | 657 ++++----------- crates/cli/src/diagnostics/mod.rs | 9 +- crates/cli/src/hooks/mod.rs | 10 +- crates/cli/src/mcp/gateway.rs | 4 +- crates/cli/src/mcp/mod.rs | 4 +- crates/cli/src/plugins/lifecycle.rs | 34 +- crates/cli/src/plugins/pricing.rs | 6 +- crates/cli/src/process/launcher.rs | 27 +- .../tests/coverage/agents/launcher_tests.rs | 24 +- .../coverage/agents/plugin_install_tests.rs | 6 +- .../cli/tests/coverage/commands/main_tests.rs | 2 +- .../tests/coverage/shared/bootstrap_tests.rs | 2 +- .../cli/tests/coverage/shared/config_tests.rs | 100 +-- .../tests/coverage/shared/installer_tests.rs | 2 +- crates/cli/tests/coverage/shared/mcp_tests.rs | 2 +- .../shared/plugins_lifecycle_tests.rs | 83 +- 31 files changed, 1124 insertions(+), 712 deletions(-) create mode 100644 crates/cli/src/commands/arguments.rs diff --git a/crates/cli/src/agents/install/mod.rs b/crates/cli/src/agents/install/mod.rs index 45cbc480c..25adc9f75 100644 --- a/crates/cli/src/agents/install/mod.rs +++ b/crates/cli/src/agents/install/mod.rs @@ -18,7 +18,7 @@ use std::time::{Duration, Instant}; use serde::Serialize; use serde_json::{Value, json}; -use crate::configuration::{CodingAgent, InstallCommand, IntegrationHost, UninstallCommand}; +use crate::configuration::{CodingAgent, InstallRequest, IntegrationHost, UninstallRequest}; use crate::error::CliError; use crate::installation::generation::{ GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration, @@ -237,7 +237,7 @@ fn failed_host_plugin_readiness( readiness } -pub(crate) fn install(command: InstallCommand) -> Result { +pub(crate) fn install(command: InstallRequest) -> Result { let operation_lock_dir = if command.dry_run { PathBuf::new() } else { @@ -264,7 +264,7 @@ pub(crate) fn install(command: InstallCommand) -> Result { ) } -pub(crate) fn uninstall(command: UninstallCommand) -> Result { +pub(crate) fn uninstall(command: UninstallRequest) -> Result { let operation_lock_dir = if command.dry_run { PathBuf::new() } else { diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 3e52e525d..46bb3cfcd 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -8,15 +8,12 @@ pub(crate) mod host; pub(crate) mod install; pub(crate) mod shared; -use clap::ValueEnum; use semver::Version; /// Coding-agent hosts supported by the CLI. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] -#[value(rename_all = "kebab-case")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum CodingAgent { /// `claude-code` remains an input alias for older Relay configuration. - #[value(name = "claude", alias = "claude-code")] ClaudeCode, Codex, Hermes, diff --git a/crates/cli/src/bootstrap/mod.rs b/crates/cli/src/bootstrap/mod.rs index 605b99d52..60a026e7c 100644 --- a/crates/cli/src/bootstrap/mod.rs +++ b/crates/cli/src/bootstrap/mod.rs @@ -17,7 +17,7 @@ use std::time::{Duration, Instant}; use serde::Deserialize; -use crate::configuration::{ServerArgs, resolve_persistent_server_config}; +use crate::configuration::{GatewayOverrides, resolve_persistent_server_config}; use crate::error::CliError; use crate::gateway::client::{ self as gateway_client, RelayHealth, VerifiedHttpError, VerifiedHttpResponse, loopback_bind, @@ -451,7 +451,7 @@ pub(crate) struct PluginGatewaySpec { } pub(crate) fn resolve_plugin_gateway( - server_args: &ServerArgs, + server_args: &GatewayOverrides, bind: SocketAddr, ) -> Result { let mut persistent_args = server_args.clone(); diff --git a/crates/cli/src/commands/arguments.rs b/crates/cli/src/commands/arguments.rs new file mode 100644 index 000000000..df1f6d4ba --- /dev/null +++ b/crates/cli/src/commands/arguments.rs @@ -0,0 +1,746 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::net::SocketAddr; +use std::path::PathBuf; + +use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; + +use crate::agents::CodingAgent; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub(crate) enum AgentArg { + #[value(name = "claude", alias = "claude-code")] + Claude, + Codex, + Hermes, +} + +impl From for CodingAgent { + fn from(value: AgentArg) -> Self { + match value { + AgentArg::Claude => Self::ClaudeCode, + AgentArg::Codex => Self::Codex, + AgentArg::Hermes => Self::Hermes, + } + } +} + +impl ServerArgs { + pub(crate) fn to_runtime(&self) -> crate::configuration::GatewayOverrides { + crate::configuration::GatewayOverrides { + config: self.config.clone(), + bind: self.bind, + openai_base_url: self.openai_base_url.clone(), + anthropic_base_url: self.anthropic_base_url.clone(), + plugin_config_path: self.plugin_config_path.clone(), + ready_file: self.ready_file.clone(), + max_hook_payload_bytes: self.max_hook_payload_bytes, + max_passthrough_body_bytes: self.max_passthrough_body_bytes, + } + } +} + +impl RunCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::RunOverrides { + crate::configuration::RunOverrides { + agent: self.agent.map(Into::into), + config: self.config, + openai_base_url: self.openai_base_url, + anthropic_base_url: self.anthropic_base_url, + session_metadata: self.session_metadata, + plugin_config_path: self.plugin_config_path, + dry_run: self.dry_run, + print: self.print, + command: self.command, + } + } +} + +impl HookForwardCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::HookForwardRequest { + crate::configuration::HookForwardRequest { + agent: self.agent.into(), + gateway_url: self.gateway_url, + generation_file: self.generation_file, + generation_token: self.generation_token, + forward_only: self.forward_only, + transparent_run: self.transparent_run, + profile: self.profile, + session_metadata: self.session_metadata, + gateway_mode: self.gateway_mode.map(Into::into), + fail_closed: self.fail_closed, + } + } +} + +#[derive(Debug, Clone, Parser)] +#[command(name = "nemo-relay")] +#[command(about = "Coding-agent gateway for NeMo Relay observability")] +#[command(version)] +pub(crate) struct Cli { + #[command(flatten)] + pub(crate) server: ServerArgs, + #[command(subcommand)] + pub(crate) command: Option, +} + +#[derive(Debug, Clone, Subcommand)] +pub(crate) enum Command { + /// Run Claude Code with observability (setup on first use) + #[command( + long_about = "Run Anthropic's `claude` CLI under an ephemeral NeMo Relay gateway. \ + Observability (ATIF + OpenInference) is wired in transparently via \ + ANTHROPIC_BASE_URL. First-time use launches the setup wizard so the \ + `[agents.claude]` block lands in `.nemo-relay/config.toml` and observation \ + starts on the next invocation without prompts.", + after_help = "Examples:\n \ + nemo-relay claude\n \ + nemo-relay claude -- chat \"refactor the launcher\"\n \ + nemo-relay claude -- --resume " + )] + Claude(EasyPathCommand), + /// Run Codex with observability (setup on first use) + #[command( + long_about = "Run OpenAI's `codex` CLI under an ephemeral NeMo Relay gateway. NeMo Relay \ + injects a `nemo-relay-openai` provider override so codex points at the \ + gateway; the gateway then forwards to `--openai-base-url` (defaults to \ + api.openai.com) with `OPENAI_API_KEY` injected on the codex route (see \ + NMF-86 — codex's own auth.json JWT is stripped). The supported host version \ + is validated before launch.", + after_help = "Examples:\n \ + nemo-relay codex\n \ + nemo-relay codex -- exec \"fix the bug in foo.rs\"\n \ + nemo-relay --openai-base-url https://inference-api.nvidia.com codex" + )] + Codex(EasyPathCommand), + /// Run Hermes with observability (setup on first use) + #[command( + long_about = "Run Hermes Agent under an ephemeral NeMo Relay gateway. The wrapper uses a \ + process-private HERMES_HOME overlay for dynamic hooks, without rewriting \ + the user's Hermes configuration. Use `nemo-relay install hermes` when bare \ + Hermes processes should load the shared native Relay gateway on \ + 127.0.0.1:47632 through MCP.", + after_help = "Examples:\n \ + nemo-relay hermes\n \ + nemo-relay hermes -- chat --provider custom" + )] + Hermes(EasyPathCommand), + /// Keep a shared Relay gateway ready for an MCP client. + #[command( + long_about = "Start or reuse a shared native NeMo Relay gateway for an MCP stdio \ + connection. The command acquires the gateway immediately, before reading \ + MCP protocol frames. The gateway binds 127.0.0.1:47632 by default and MCP \ + initialization completes only after Relay identity and readiness are \ + verified. Multiple MCP clients share the gateway; it remains available \ + until its idle timeout after the final client closes. This command \ + advertises no MCP tools.", + after_help = "Examples:\n \ + nemo-relay mcp\n \ + nemo-relay --bind 127.0.0.1:4041 mcp # explicit standalone/test bind" + )] + Mcp, + /// Run the interactive setup (writes `.nemo-relay/config.toml`) + Config(ConfigCommand), + /// Create or edit plugin configuration (writes `plugins.toml`) + Plugins(PluginsCommand), + /// Install coding-agent plugins from the local nemo-relay CLI. + Install(InstallCommand), + /// Uninstall coding-agent plugins installed by `nemo-relay install`. + Uninstall(UninstallCommand), + /// Validate and configure model pricing catalogs. + ModelPricing(PricingCommand), + /// Diagnose env, agents, config, observability (optionally scoped to one agent) + Doctor(DoctorCommand), + /// List supported and locally-detected agents (use `--json` for machine output) + Agents(AgentsCommand), + /// Print shell completion script (e.g. `nemo-relay completions zsh > ~/.zfunc/_nemo-relay`) + Completions(CompletionsCommand), + /// Run an agent deterministically (no wizard; errors if config is missing) + Run(RunCommand), + /// Internal: subprocess used by installed hooks to forward events. Not typed by humans. + #[command(hide = true)] + HookForward(HookForwardCommand), +} + +/// Args for `nemo-relay doctor`. `--json` is on this command (rather than as a global flag) +/// so it doesn't pollute the help output of subcommands where it has no meaning. +#[derive(Debug, Clone, Args)] +pub(crate) struct DoctorCommand { + /// Limit readiness checks to one supported agent. + #[arg(value_enum)] + pub(crate) agent: Option, + /// Diagnose an installed coding-agent integration instead of the normal Relay config. + #[arg(long, value_enum)] + pub(crate) plugin: Option, + /// Plugin install state directory. Defaults to the platform data directory. + #[arg(long)] + pub(crate) install_dir: Option, + /// Emit machine-readable JSON instead of the formatted human report. Versioned via + /// `schema_version`; stable shape for CI / evaluation harness consumption. + #[arg(long)] + pub(crate) json: bool, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct InstallCommand { + #[arg(value_enum)] + pub(crate) host: IntegrationHost, + #[arg(long)] + pub(crate) install_dir: Option, + #[arg(long)] + pub(crate) force: bool, + #[arg(long)] + pub(crate) dry_run: bool, + #[arg(long)] + pub(crate) skip_doctor: bool, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct UninstallCommand { + #[arg(value_enum)] + pub(crate) host: IntegrationHost, + #[arg(long)] + pub(crate) install_dir: Option, + #[arg(long)] + pub(crate) dry_run: bool, +} + +/// Args for `nemo-relay agents`. Shares the `--json` shape with `nemo-relay doctor`'s +/// `agents` field so the two outputs can be unified by downstream consumers. +#[derive(Debug, Clone, Args)] +pub(crate) struct AgentsCommand { + /// Emit the supported + detected agent list as JSON instead of formatted text. + #[arg(long)] + pub(crate) json: bool, +} + +/// Args for `nemo-relay completions ` (print to stdout) or `nemo-relay completions --install` +/// (auto-detect $SHELL and write to the standard fpath / completions directory). +/// +/// The Homebrew / curl-install flows drop completion scripts automatically; this subcommand is +/// the escape hatch for CI, custom shells, regeneration, and `cargo install` users where no +/// post-install hook runs. +#[derive(Debug, Clone, Args)] +pub(crate) struct CompletionsCommand { + /// Shell to generate the completion script for. Optional when used with `--install` (the + /// installer auto-detects `$SHELL`). + #[arg(value_enum)] + pub(crate) shell: Option, + /// Write the completion script into the shell's standard completions directory instead of + /// printing to stdout. Auto-detects `$SHELL` when no shell argument is given. + #[arg(long)] + pub(crate) install: bool, +} + +/// Args for `nemo-relay config`. The setup wizard runs by default; `--reset` short-circuits to +/// a destructive clear. An optional positional agent name scopes both the wizard and `--reset` +/// to a single agent's settings, leaving other agents' blocks untouched. +#[derive(Debug, Clone, Args)] +pub(crate) struct ConfigCommand { + /// Scope this run to one agent. Wizard skips the agent multi-select; `--reset` removes + /// only that agent's block from the existing config file. Omit to operate on all agents. + #[arg(value_enum)] + pub(crate) agent: Option, + /// Delete the project config file or the scoped transparent-wrapper agent block. Persistent + /// Hermes MCP, hooks, and trust are removed with `nemo-relay uninstall hermes`. The wizard + /// does not run after reset; invoke `nemo-relay config` again to recreate configuration. + #[arg(long)] + pub(crate) reset: bool, +} + +/// Args for `nemo-relay plugins`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsCommand { + #[command(subcommand)] + pub(crate) command: PluginsSubcommand, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct PluginJsonContext<'a> { + pub(crate) command: &'static str, + pub(crate) target: Option<&'a str>, +} + +/// Plugin configuration subcommands. +#[derive(Debug, Clone, Subcommand)] +pub(crate) enum PluginsSubcommand { + /// Interactively create or edit built-in and dynamic plugin configuration. + Edit(PluginsEditCommand), + /// Register a manifest-backed dynamic plugin in `plugins.toml`. + Add(PluginsAddCommand), + /// Validate a manifest-backed dynamic plugin by path or installed ID. + Validate(PluginsValidateCommand), + /// List discovered dynamic plugins from the resolved host config. + List(PluginsListCommand), + /// Inspect one discovered dynamic plugin by canonical ID. + Inspect(PluginsInspectCommand), + /// Mark a registered dynamic plugin enabled in desired state. + Enable(PluginsEnableCommand), + /// Mark a registered dynamic plugin disabled in desired state. + Disable(PluginsDisableCommand), + /// Tombstone a registered dynamic plugin and remove its host discovery reference. + Remove(PluginsRemoveCommand), +} + +impl PluginsSubcommand { + pub(crate) fn json_context(&self) -> Option> { + match self { + Self::Validate(command) if command.json => Some(PluginJsonContext { + command: "plugins validate", + target: Some(command.target.as_str()), + }), + Self::List(command) if command.json => Some(PluginJsonContext { + command: "plugins list", + target: None, + }), + Self::Inspect(command) if command.json => Some(PluginJsonContext { + command: "plugins inspect", + target: Some(command.id.as_str()), + }), + _ => None, + } + } +} + +/// Args for `nemo-relay model-pricing`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PricingCommand { + #[command(subcommand)] + pub(crate) command: PricingSubcommand, +} + +/// Model pricing catalog and resolver subcommands. +#[derive(Debug, Clone, Subcommand)] +pub(crate) enum PricingSubcommand { + /// Validate a model pricing catalog JSON file. + Validate(PricingValidateCommand), + /// Initialize model pricing in `plugins.toml`. + Init(PricingInitCommand), + /// Add a model pricing catalog file source to `plugins.toml`. + AddSource(PricingAddSourceCommand), + /// Resolve which model pricing entry matches a model and optional usage. + Resolve(PricingResolveCommand), +} + +/// Common target-scope flags for model pricing config mutations. +#[derive(Debug, Clone, Default, Args)] +#[command(group( + ArgGroup::new("pricing_scope") + .args(["user", "project", "global"]) + .multiple(false) +))] +pub(crate) struct PricingScopeArgs { + /// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) user: bool, + /// Edit the nearest project config at `.nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) project: bool, + /// Edit the system config at `/etc/nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) global: bool, +} + +/// Args for `nemo-relay model-pricing validate`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PricingValidateCommand { + /// Path to a Relay model pricing catalog JSON file. + pub(crate) path: PathBuf, +} + +/// Args for `nemo-relay model-pricing init`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PricingInitCommand { + #[command(flatten)] + pub(crate) scope: PricingScopeArgs, +} + +/// Args for `nemo-relay model-pricing add-source`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PricingAddSourceCommand { + #[command(flatten)] + pub(crate) scope: PricingScopeArgs, + /// Path to a Relay model pricing catalog JSON file. + pub(crate) path: PathBuf, + /// Append as a lower-priority source instead of prepending as the highest-priority override. + #[arg(long)] + pub(crate) append: bool, +} + +/// Args for `nemo-relay model-pricing resolve`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PricingResolveCommand { + /// Model ID or routed model name to look up. + pub(crate) model: String, + /// Optional provider or route, such as `openai`, `anthropic`, or `azure/openai`. + #[arg(long)] + pub(crate) provider: Option, + /// Prompt/input token count to use for an estimate. + #[arg(long)] + pub(crate) prompt_tokens: Option, + /// Completion/output token count to use for an estimate. + #[arg(long)] + pub(crate) completion_tokens: Option, + /// Prompt-cache read token count to use for an estimate. + #[arg(long)] + pub(crate) cache_read_tokens: Option, + /// Prompt-cache write token count to use for an estimate. + #[arg(long)] + pub(crate) cache_write_tokens: Option, +} + +/// Args for `nemo-relay plugins edit`. +#[derive(Debug, Clone, Default, Args)] +#[command(group( + ArgGroup::new("scope") + .args(["user", "project", "global"]) + .multiple(false) +))] +pub(crate) struct PluginsScopeArgs { + /// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) user: bool, + /// Edit the nearest project config at `.nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) project: bool, + /// Edit the system config at `/etc/nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) global: bool, +} + +/// Args for `nemo-relay plugins edit`. +#[derive(Debug, Clone, Default, Args)] +pub(crate) struct PluginsEditCommand { + #[command(flatten)] + pub(crate) scope: PluginsScopeArgs, +} + +/// Args for `nemo-relay plugins add`. +#[derive(Debug, Clone, Default, Args)] +pub(crate) struct PluginsAddCommand { + #[command(flatten)] + pub(crate) scope: PluginsScopeArgs, + /// Path to a plugin directory or explicit `relay-plugin.toml`. + pub(crate) path: PathBuf, +} + +/// Args for `nemo-relay plugins validate`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsValidateCommand { + /// Canonical plugin ID or a local plugin directory / `relay-plugin.toml` path. + pub(crate) target: String, + /// Emit machine-readable JSON output. + #[arg(long)] + pub(crate) json: bool, +} + +/// Args for `nemo-relay plugins list`. +#[derive(Debug, Clone, Default, Args)] +pub(crate) struct PluginsListCommand { + /// Include tombstoned dynamic plugin records in the output. + #[arg(long)] + pub(crate) all: bool, + /// Emit machine-readable JSON output. + #[arg(long)] + pub(crate) json: bool, +} + +/// Args for `nemo-relay plugins inspect`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsInspectCommand { + /// Canonical plugin ID. + pub(crate) id: String, + /// Emit machine-readable JSON output. + #[arg(long)] + pub(crate) json: bool, +} + +/// Args for `nemo-relay plugins enable`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsEnableCommand { + /// Canonical plugin ID. + pub(crate) id: String, +} + +/// Args for `nemo-relay plugins disable`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsDisableCommand { + /// Canonical plugin ID. + pub(crate) id: String, +} + +/// Args for `nemo-relay plugins remove`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsRemoveCommand { + /// Canonical plugin ID. + pub(crate) id: String, +} + +#[derive(Debug, Clone, Default, Args)] +pub(crate) struct ServerArgs { + /// Path to an explicit config file (disables auto-discovery of workspace/global/system) + #[arg(long)] + pub(crate) config: Option, + /// Address for the gateway to listen on in daemon mode (default 127.0.0.1:4040) + #[arg(long, env = "NEMO_RELAY_GATEWAY_BIND")] + pub(crate) bind: Option, + /// Upstream OpenAI-compatible base URL (e.g. https://api.openai.com/v1, NVIDIA inference) + #[arg(long, env = "NEMO_RELAY_OPENAI_BASE_URL")] + pub(crate) openai_base_url: Option, + /// Upstream Anthropic base URL (e.g. https://api.anthropic.com) + #[arg(long, env = "NEMO_RELAY_ANTHROPIC_BASE_URL")] + pub(crate) anthropic_base_url: Option, + /// Internal override for the plugin configuration file. + #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] + pub(crate) plugin_config_path: Option, + /// Internal readiness file used by plugin sidecar bootstrap. + #[arg(long, hide = true)] + pub(crate) ready_file: Option, + /// Maximum accepted coding-agent hook payload size, in bytes. + #[arg(long, env = "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES")] + pub(crate) max_hook_payload_bytes: Option, + /// Maximum accepted provider passthrough request body size, in bytes. + #[arg(long, env = "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES")] + pub(crate) max_passthrough_body_bytes: Option, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct HookForwardCommand { + #[arg(value_enum)] + pub(crate) agent: AgentArg, + #[arg(long)] + pub(crate) gateway_url: Option, + /// Private install-generation fence used by generated persistent hooks. + #[arg(long, hide = true)] + pub(crate) generation_file: Option, + /// Immutable generation identity paired with `generation_file` by installed hooks. + #[arg(long, hide = true)] + pub(crate) generation_token: Option, + /// Forward to an existing gateway without starting or recovering Relay. + #[arg( + long, + conflicts_with_all = ["generation_file", "generation_token"] + )] + pub(crate) forward_only: bool, + /// Marks the process-private hook source injected by `nemo-relay run`. + #[arg( + long, + hide = true, + conflicts_with_all = ["generation_file", "generation_token", "forward_only"] + )] + pub(crate) transparent_run: bool, + #[arg(long)] + pub(crate) profile: Option, + #[arg(long)] + pub(crate) session_metadata: Option, + #[arg(long, value_enum)] + pub(crate) gateway_mode: Option, + #[arg(long)] + pub(crate) fail_closed: bool, +} + +/// Args for the easy-path agent shortcut (`nemo-relay claude`, `nemo-relay codex`, etc.). +/// Holds only pass-through agent args; the agent itself is selected by which subcommand variant +/// is invoked, and upstream settings come from the resolved config file. If no config file is +/// present, the dispatcher fires setup. +#[derive(Debug, Clone, Args)] +pub(crate) struct EasyPathCommand { + /// Pass-through args forwarded to the underlying agent process. Use `--` to separate them + /// from `nemo-relay`'s own flags. See the `Examples` section below for agent-specific shapes. + #[arg(last = true)] + pub(crate) command: Vec, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct RunCommand { + #[arg(long, value_enum)] + pub(crate) agent: Option, + #[arg(long)] + pub(crate) config: Option, + #[arg(long)] + pub(crate) openai_base_url: Option, + #[arg(long)] + pub(crate) anthropic_base_url: Option, + #[arg(long)] + pub(crate) session_metadata: Option, + /// Internal override for the plugin configuration file. + #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] + pub(crate) plugin_config_path: Option, + #[arg(long)] + pub(crate) dry_run: bool, + #[arg(long)] + pub(crate) print: bool, + #[arg(last = true)] + pub(crate) command: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub(crate) enum IntegrationHost { + Codex, + #[value(name = "claude-code", alias = "claude")] + ClaudeCode, + Hermes, + All, +} + +impl From for crate::configuration::IntegrationHost { + fn from(value: IntegrationHost) -> Self { + match value { + IntegrationHost::Codex => Self::Codex, + IntegrationHost::ClaudeCode => Self::ClaudeCode, + IntegrationHost::Hermes => Self::Hermes, + IntegrationHost::All => Self::All, + } + } +} + +impl InstallCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::InstallRequest { + crate::configuration::InstallRequest { + host: self.host.into(), + install_dir: self.install_dir, + force: self.force, + dry_run: self.dry_run, + skip_doctor: self.skip_doctor, + } + } +} + +impl UninstallCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::UninstallRequest { + crate::configuration::UninstallRequest { + host: self.host.into(), + install_dir: self.install_dir, + dry_run: self.dry_run, + } + } +} + +impl From for crate::configuration::PluginsScopeArgs { + fn from(value: PluginsScopeArgs) -> Self { + Self { + user: value.user, + project: value.project, + global: value.global, + } + } +} + +impl PluginsEditCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::PluginsEditCommand { + crate::configuration::PluginsEditCommand { + scope: self.scope.into(), + } + } +} +impl PluginsAddCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::PluginsAddCommand { + crate::configuration::PluginsAddCommand { + scope: self.scope.into(), + path: self.path, + } + } +} +impl PluginsValidateCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::PluginsValidateCommand { + crate::configuration::PluginsValidateCommand { + target: self.target, + json: self.json, + } + } +} +impl PluginsListCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::PluginsListCommand { + crate::configuration::PluginsListCommand { + all: self.all, + json: self.json, + } + } +} +impl PluginsInspectCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::PluginsInspectCommand { + crate::configuration::PluginsInspectCommand { + id: self.id, + json: self.json, + } + } +} +impl PluginsEnableCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::PluginsEnableCommand { + crate::configuration::PluginsEnableCommand { id: self.id } + } +} +impl PluginsDisableCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::PluginsDisableCommand { + crate::configuration::PluginsDisableCommand { id: self.id } + } +} +impl PluginsRemoveCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::PluginsRemoveCommand { + crate::configuration::PluginsRemoveCommand { id: self.id } + } +} + +impl From for crate::configuration::PricingScopeArgs { + fn from(value: PricingScopeArgs) -> Self { + Self { + user: value.user, + project: value.project, + global: value.global, + } + } +} +impl PricingValidateCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::PricingValidateCommand { + crate::configuration::PricingValidateCommand { path: self.path } + } +} +impl PricingInitCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::PricingInitCommand { + crate::configuration::PricingInitCommand { + scope: self.scope.into(), + } + } +} +impl PricingAddSourceCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::PricingAddSourceCommand { + crate::configuration::PricingAddSourceCommand { + scope: self.scope.into(), + path: self.path, + append: self.append, + } + } +} +impl PricingResolveCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::PricingResolveCommand { + crate::configuration::PricingResolveCommand { + model: self.model, + provider: self.provider, + prompt_tokens: self.prompt_tokens, + completion_tokens: self.completion_tokens, + cache_read_tokens: self.cache_read_tokens, + cache_write_tokens: self.cache_write_tokens, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub(crate) enum GatewayModeArg { + HookOnly, + Passthrough, + Required, +} + +impl From for crate::configuration::GatewayMode { + fn from(value: GatewayModeArg) -> Self { + match value { + GatewayModeArg::HookOnly => Self::HookOnly, + GatewayModeArg::Passthrough => Self::Passthrough, + GatewayModeArg::Required => Self::Required, + } + } +} diff --git a/crates/cli/src/commands/completions/install.rs b/crates/cli/src/commands/completions/install.rs index dee6e73d1..4a53edf19 100644 --- a/crates/cli/src/commands/completions/install.rs +++ b/crates/cli/src/commands/completions/install.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use clap_complete::Shell; -use crate::configuration::Cli; +use crate::commands::arguments::Cli; use crate::error::CliError; /// Generates the completion script for `$SHELL` and writes it to the matching shell-specific diff --git a/crates/cli/src/commands/completions/mod.rs b/crates/cli/src/commands/completions/mod.rs index e543d3c01..89ef1c718 100644 --- a/crates/cli/src/commands/completions/mod.rs +++ b/crates/cli/src/commands/completions/mod.rs @@ -7,7 +7,7 @@ use std::process::ExitCode; use clap::CommandFactory; -use crate::configuration::{Cli, CompletionsCommand}; +use crate::commands::arguments::{Cli, CompletionsCommand}; use crate::error::CliError; pub(super) fn execute(command: CompletionsCommand) -> Result { diff --git a/crates/cli/src/commands/configuration.rs b/crates/cli/src/commands/configuration.rs index 449d62661..c74914a68 100644 --- a/crates/cli/src/commands/configuration.rs +++ b/crates/cli/src/commands/configuration.rs @@ -3,14 +3,15 @@ use std::process::ExitCode; -use crate::configuration::ConfigCommand; +use super::arguments::ConfigCommand; use crate::error::CliError; pub(super) async fn execute(command: ConfigCommand) -> Result { + let agent = command.agent.map(Into::into); if command.reset { - crate::configuration::wizard::reset(command.agent)?; + crate::configuration::wizard::reset(agent)?; } else { - crate::configuration::wizard::run(command.agent).await?; + crate::configuration::wizard::run(agent).await?; } Ok(ExitCode::SUCCESS) } diff --git a/crates/cli/src/commands/diagnostics.rs b/crates/cli/src/commands/diagnostics.rs index dcdb0d48c..b83244a7d 100644 --- a/crates/cli/src/commands/diagnostics.rs +++ b/crates/cli/src/commands/diagnostics.rs @@ -3,13 +3,13 @@ use std::process::ExitCode; -use crate::configuration::DoctorCommand; +use super::arguments::DoctorCommand; use crate::error::CliError; pub(super) async fn execute(command: DoctorCommand) -> Result { if let Some(plugin) = command.plugin { - crate::agents::install::doctor(plugin, command.install_dir, command.json) + crate::agents::install::doctor(plugin.into(), command.install_dir, command.json) } else { - crate::diagnostics::run_doctor(command.agent, command.json).await + crate::diagnostics::run_doctor(command.agent.map(Into::into), command.json).await } } diff --git a/crates/cli/src/commands/hook_forward.rs b/crates/cli/src/commands/hook_forward.rs index 0a58df4c6..a579170f3 100644 --- a/crates/cli/src/commands/hook_forward.rs +++ b/crates/cli/src/commands/hook_forward.rs @@ -1,9 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use crate::configuration::HookForwardCommand; +use super::arguments::HookForwardCommand; use crate::error::CliError; pub(super) async fn execute(command: HookForwardCommand) -> Result<(), CliError> { - crate::hooks::hook_forward(command).await + crate::hooks::hook_forward(command.into_runtime()).await } diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index bc058c04b..4e9d9bfbb 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -3,13 +3,13 @@ use std::process::ExitCode; -use crate::configuration::{InstallCommand, UninstallCommand}; +use super::arguments::{InstallCommand, UninstallCommand}; use crate::error::CliError; pub(super) fn install(command: InstallCommand) -> Result { - crate::agents::install::install(command) + crate::agents::install::install(command.into_runtime()) } pub(super) fn uninstall(command: UninstallCommand) -> Result { - crate::agents::install::uninstall(command) + crate::agents::install::uninstall(command.into_runtime()) } diff --git a/crates/cli/src/commands/mcp.rs b/crates/cli/src/commands/mcp.rs index dd91813b8..5a2e0fef5 100644 --- a/crates/cli/src/commands/mcp.rs +++ b/crates/cli/src/commands/mcp.rs @@ -3,9 +3,9 @@ use std::process::ExitCode; -use crate::configuration::ServerArgs; +use super::arguments::ServerArgs; use crate::error::CliError; pub(super) async fn execute(server: &ServerArgs) -> Result { - crate::mcp::run(server).await + crate::mcp::run(&server.to_runtime()).await } diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 5db276b9f..f4e3db0fa 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -3,6 +3,7 @@ //! Command parsing, dispatch, rendering, and exit-code ownership. +pub(crate) mod arguments; mod completions; mod configuration; mod diagnostics; @@ -17,9 +18,10 @@ use std::process::ExitCode; use clap::Parser; -use crate::configuration::{Cli, CodingAgent, Command, ServerArgs}; +use self::arguments::{Cli, Command, ServerArgs}; #[cfg(test)] -use crate::configuration::{CompletionsCommand, PluginsCommand, PricingCommand}; +use self::arguments::{CompletionsCommand, PluginsCommand, PricingCommand}; +use crate::agents::CodingAgent; use crate::{ configuration as runtime_configuration, diagnostics as runtime_diagnostics, error, server, }; @@ -83,6 +85,7 @@ fn generate_completions_to( } async fn run_default(server_args: &ServerArgs) -> Result { + let runtime_args = server_args.to_runtime(); // Bare `nemo-relay` with no subcommand: // - If the user passed any daemon-specific flag (`--bind`, upstream URLs, ATIF dir, // OpenInference endpoint), they obviously want the long-running gateway daemon — @@ -91,14 +94,14 @@ async fn run_default(server_args: &ServerArgs) -> Result Result Result { match command.command { - PricingSubcommand::Validate(command) => crate::plugins::pricing::validate(command)?, - PricingSubcommand::Init(command) => crate::plugins::pricing::init(command)?, - PricingSubcommand::AddSource(command) => crate::plugins::pricing::add_source(command)?, - PricingSubcommand::Resolve(command) => crate::plugins::pricing::resolve(command)?, + PricingSubcommand::Validate(command) => { + crate::plugins::pricing::validate(command.into_runtime())? + } + PricingSubcommand::Init(command) => crate::plugins::pricing::init(command.into_runtime())?, + PricingSubcommand::AddSource(command) => { + crate::plugins::pricing::add_source(command.into_runtime())? + } + PricingSubcommand::Resolve(command) => { + crate::plugins::pricing::resolve(command.into_runtime())? + } } Ok(ExitCode::SUCCESS) } diff --git a/crates/cli/src/commands/plugins.rs b/crates/cli/src/commands/plugins.rs index 122601fe7..07fa2bc6c 100644 --- a/crates/cli/src/commands/plugins.rs +++ b/crates/cli/src/commands/plugins.rs @@ -3,26 +3,39 @@ use std::process::ExitCode; -use crate::configuration::{PluginsCommand, PluginsSubcommand, ServerArgs}; +use super::arguments::{PluginsCommand, PluginsSubcommand, ServerArgs}; use crate::error::CliError; pub(super) fn execute(command: PluginsCommand, server: &ServerArgs) -> Result { + let server = server.to_runtime(); let json_context = command .command .json_context() .map(|context| (context.command, context.target.map(str::to_owned))); let json = json_context.is_some(); let result = match command.command { - PluginsSubcommand::Edit(command) => crate::plugins::edit(command), - PluginsSubcommand::Add(command) => crate::plugins::lifecycle::add(command, server), + PluginsSubcommand::Edit(command) => crate::plugins::edit(command.into_runtime()), + PluginsSubcommand::Add(command) => { + crate::plugins::lifecycle::add(command.into_runtime(), &server) + } PluginsSubcommand::Validate(command) => { - crate::plugins::lifecycle::validate(command, server) + crate::plugins::lifecycle::validate(command.into_runtime(), &server) + } + PluginsSubcommand::List(command) => { + crate::plugins::lifecycle::list(command.into_runtime(), &server) + } + PluginsSubcommand::Inspect(command) => { + crate::plugins::lifecycle::inspect(command.into_runtime(), &server) + } + PluginsSubcommand::Enable(command) => { + crate::plugins::lifecycle::enable(command.into_runtime(), &server) + } + PluginsSubcommand::Disable(command) => { + crate::plugins::lifecycle::disable(command.into_runtime(), &server) + } + PluginsSubcommand::Remove(command) => { + crate::plugins::lifecycle::remove(command.into_runtime(), &server) } - PluginsSubcommand::List(command) => crate::plugins::lifecycle::list(command, server), - PluginsSubcommand::Inspect(command) => crate::plugins::lifecycle::inspect(command, server), - PluginsSubcommand::Enable(command) => crate::plugins::lifecycle::enable(command, server), - PluginsSubcommand::Disable(command) => crate::plugins::lifecycle::disable(command, server), - PluginsSubcommand::Remove(command) => crate::plugins::lifecycle::remove(command, server), }; match result { Ok(()) => Ok(ExitCode::SUCCESS), diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index ee19ee8c0..7ece72da2 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -3,15 +3,16 @@ use std::process::ExitCode; +use super::arguments::{EasyPathCommand, RunCommand, ServerArgs}; use crate::agents::CodingAgent; -use crate::configuration::{EasyPathCommand, RunCommand, ServerArgs}; use crate::error::CliError; pub(super) async fn execute( command: RunCommand, server: &ServerArgs, ) -> Result { - crate::process::launcher::run(command, Some(server)).await + let inherited = server.to_runtime(); + crate::process::launcher::run(command.into_runtime(), Some(&inherited)).await } pub(super) async fn easy_path( @@ -19,5 +20,6 @@ pub(super) async fn easy_path( command: EasyPathCommand, server: &ServerArgs, ) -> Result { - crate::process::launcher::easy_path(agent, command, Some(server)).await + let inherited = server.to_runtime(); + crate::process::launcher::easy_path(agent, command.command, Some(&inherited)).await } diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs index 97e82a2be..bcfe6de5f 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -13,7 +13,6 @@ use std::thread; use std::time::{Duration, Instant}; use axum::http::HeaderMap; -use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; use nemo_relay::plugin::dynamic::{ DYNAMIC_PLUGIN_MANIFEST_FILENAME, DynamicPluginManifest, DynamicPluginManifestLoad, }; @@ -42,443 +41,25 @@ pub(crate) const RELAY_SOURCE_PLUGIN_ID: &str = "nemo-relay-plugin@nemo-relay"; /// Maximum regular-file size hashed into persistent gateway identity (512 MiB). pub(crate) const MAX_BOOTSTRAP_IDENTITY_FILE_BYTES: u64 = 512 * 1024 * 1024; -#[derive(Debug, Clone, Parser)] -#[command(name = "nemo-relay")] -#[command(about = "Coding-agent gateway for NeMo Relay observability")] -#[command(version)] -pub(crate) struct Cli { - #[command(flatten)] - pub(crate) server: ServerArgs, - #[command(subcommand)] - pub(crate) command: Option, -} - -#[derive(Debug, Clone, Subcommand)] -pub(crate) enum Command { - /// Run Claude Code with observability (setup on first use) - #[command( - long_about = "Run Anthropic's `claude` CLI under an ephemeral NeMo Relay gateway. \ - Observability (ATIF + OpenInference) is wired in transparently via \ - ANTHROPIC_BASE_URL. First-time use launches the setup wizard so the \ - `[agents.claude]` block lands in `.nemo-relay/config.toml` and observation \ - starts on the next invocation without prompts.", - after_help = "Examples:\n \ - nemo-relay claude\n \ - nemo-relay claude -- chat \"refactor the launcher\"\n \ - nemo-relay claude -- --resume " - )] - Claude(EasyPathCommand), - /// Run Codex with observability (setup on first use) - #[command( - long_about = "Run OpenAI's `codex` CLI under an ephemeral NeMo Relay gateway. NeMo Relay \ - injects a `nemo-relay-openai` provider override so codex points at the \ - gateway; the gateway then forwards to `--openai-base-url` (defaults to \ - api.openai.com) with `OPENAI_API_KEY` injected on the codex route (see \ - NMF-86 — codex's own auth.json JWT is stripped). The supported host version \ - is validated before launch.", - after_help = "Examples:\n \ - nemo-relay codex\n \ - nemo-relay codex -- exec \"fix the bug in foo.rs\"\n \ - nemo-relay --openai-base-url https://inference-api.nvidia.com codex" - )] - Codex(EasyPathCommand), - /// Run Hermes with observability (setup on first use) - #[command( - long_about = "Run Hermes Agent under an ephemeral NeMo Relay gateway. The wrapper uses a \ - process-private HERMES_HOME overlay for dynamic hooks, without rewriting \ - the user's Hermes configuration. Use `nemo-relay install hermes` when bare \ - Hermes processes should load the shared native Relay gateway on \ - 127.0.0.1:47632 through MCP.", - after_help = "Examples:\n \ - nemo-relay hermes\n \ - nemo-relay hermes -- chat --provider custom" - )] - Hermes(EasyPathCommand), - /// Keep a shared Relay gateway ready for an MCP client. - #[command( - long_about = "Start or reuse a shared native NeMo Relay gateway for an MCP stdio \ - connection. The command acquires the gateway immediately, before reading \ - MCP protocol frames. The gateway binds 127.0.0.1:47632 by default and MCP \ - initialization completes only after Relay identity and readiness are \ - verified. Multiple MCP clients share the gateway; it remains available \ - until its idle timeout after the final client closes. This command \ - advertises no MCP tools.", - after_help = "Examples:\n \ - nemo-relay mcp\n \ - nemo-relay --bind 127.0.0.1:4041 mcp # explicit standalone/test bind" - )] - Mcp, - /// Run the interactive setup (writes `.nemo-relay/config.toml`) - Config(ConfigCommand), - /// Create or edit plugin configuration (writes `plugins.toml`) - Plugins(PluginsCommand), - /// Install coding-agent plugins from the local nemo-relay CLI. - Install(InstallCommand), - /// Uninstall coding-agent plugins installed by `nemo-relay install`. - Uninstall(UninstallCommand), - /// Validate and configure model pricing catalogs. - ModelPricing(PricingCommand), - /// Diagnose env, agents, config, observability (optionally scoped to one agent) - Doctor(DoctorCommand), - /// List supported and locally-detected agents (use `--json` for machine output) - Agents(AgentsCommand), - /// Print shell completion script (e.g. `nemo-relay completions zsh > ~/.zfunc/_nemo-relay`) - Completions(CompletionsCommand), - /// Run an agent deterministically (no wizard; errors if config is missing) - Run(RunCommand), - /// Internal: subprocess used by installed hooks to forward events. Not typed by humans. - #[command(hide = true)] - HookForward(HookForwardCommand), -} - -/// Args for `nemo-relay doctor`. `--json` is on this command (rather than as a global flag) -/// so it doesn't pollute the help output of subcommands where it has no meaning. -#[derive(Debug, Clone, Args)] -pub(crate) struct DoctorCommand { - /// Limit readiness checks to one supported agent. - #[arg(value_enum)] - pub(crate) agent: Option, - /// Diagnose an installed coding-agent integration instead of the normal Relay config. - #[arg(long, value_enum)] - pub(crate) plugin: Option, - /// Plugin install state directory. Defaults to the platform data directory. - #[arg(long)] - pub(crate) install_dir: Option, - /// Emit machine-readable JSON instead of the formatted human report. Versioned via - /// `schema_version`; stable shape for CI / evaluation harness consumption. - #[arg(long)] - pub(crate) json: bool, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct InstallCommand { - #[arg(value_enum)] - pub(crate) host: IntegrationHost, - #[arg(long)] - pub(crate) install_dir: Option, - #[arg(long)] - pub(crate) force: bool, - #[arg(long)] - pub(crate) dry_run: bool, - #[arg(long)] - pub(crate) skip_doctor: bool, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct UninstallCommand { - #[arg(value_enum)] - pub(crate) host: IntegrationHost, - #[arg(long)] - pub(crate) install_dir: Option, - #[arg(long)] - pub(crate) dry_run: bool, -} - -/// Args for `nemo-relay agents`. Shares the `--json` shape with `nemo-relay doctor`'s -/// `agents` field so the two outputs can be unified by downstream consumers. -#[derive(Debug, Clone, Args)] -pub(crate) struct AgentsCommand { - /// Emit the supported + detected agent list as JSON instead of formatted text. - #[arg(long)] - pub(crate) json: bool, -} - -/// Args for `nemo-relay completions ` (print to stdout) or `nemo-relay completions --install` -/// (auto-detect $SHELL and write to the standard fpath / completions directory). -/// -/// The Homebrew / curl-install flows drop completion scripts automatically; this subcommand is -/// the escape hatch for CI, custom shells, regeneration, and `cargo install` users where no -/// post-install hook runs. -#[derive(Debug, Clone, Args)] -pub(crate) struct CompletionsCommand { - /// Shell to generate the completion script for. Optional when used with `--install` (the - /// installer auto-detects `$SHELL`). - #[arg(value_enum)] - pub(crate) shell: Option, - /// Write the completion script into the shell's standard completions directory instead of - /// printing to stdout. Auto-detects `$SHELL` when no shell argument is given. - #[arg(long)] - pub(crate) install: bool, -} - -/// Args for `nemo-relay config`. The setup wizard runs by default; `--reset` short-circuits to -/// a destructive clear. An optional positional agent name scopes both the wizard and `--reset` -/// to a single agent's settings, leaving other agents' blocks untouched. -#[derive(Debug, Clone, Args)] -pub(crate) struct ConfigCommand { - /// Scope this run to one agent. Wizard skips the agent multi-select; `--reset` removes - /// only that agent's block from the existing config file. Omit to operate on all agents. - #[arg(value_enum)] - pub(crate) agent: Option, - /// Delete the project config file or the scoped transparent-wrapper agent block. Persistent - /// Hermes MCP, hooks, and trust are removed with `nemo-relay uninstall hermes`. The wizard - /// does not run after reset; invoke `nemo-relay config` again to recreate configuration. - #[arg(long)] - pub(crate) reset: bool, -} - -/// Args for `nemo-relay plugins`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsCommand { - #[command(subcommand)] - pub(crate) command: PluginsSubcommand, -} - -#[derive(Debug, Clone, Copy)] -pub(crate) struct PluginJsonContext<'a> { - pub(crate) command: &'static str, - pub(crate) target: Option<&'a str>, -} - -/// Plugin configuration subcommands. -#[derive(Debug, Clone, Subcommand)] -pub(crate) enum PluginsSubcommand { - /// Interactively create or edit built-in and dynamic plugin configuration. - Edit(PluginsEditCommand), - /// Register a manifest-backed dynamic plugin in `plugins.toml`. - Add(PluginsAddCommand), - /// Validate a manifest-backed dynamic plugin by path or installed ID. - Validate(PluginsValidateCommand), - /// List discovered dynamic plugins from the resolved host config. - List(PluginsListCommand), - /// Inspect one discovered dynamic plugin by canonical ID. - Inspect(PluginsInspectCommand), - /// Mark a registered dynamic plugin enabled in desired state. - Enable(PluginsEnableCommand), - /// Mark a registered dynamic plugin disabled in desired state. - Disable(PluginsDisableCommand), - /// Tombstone a registered dynamic plugin and remove its host discovery reference. - Remove(PluginsRemoveCommand), -} - -impl PluginsSubcommand { - pub(crate) fn json_context(&self) -> Option> { - match self { - Self::Validate(command) if command.json => Some(PluginJsonContext { - command: "plugins validate", - target: Some(command.target.as_str()), - }), - Self::List(command) if command.json => Some(PluginJsonContext { - command: "plugins list", - target: None, - }), - Self::Inspect(command) if command.json => Some(PluginJsonContext { - command: "plugins inspect", - target: Some(command.id.as_str()), - }), - _ => None, - } - } -} - -/// Args for `nemo-relay model-pricing`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PricingCommand { - #[command(subcommand)] - pub(crate) command: PricingSubcommand, -} - -/// Model pricing catalog and resolver subcommands. -#[derive(Debug, Clone, Subcommand)] -pub(crate) enum PricingSubcommand { - /// Validate a model pricing catalog JSON file. - Validate(PricingValidateCommand), - /// Initialize model pricing in `plugins.toml`. - Init(PricingInitCommand), - /// Add a model pricing catalog file source to `plugins.toml`. - AddSource(PricingAddSourceCommand), - /// Resolve which model pricing entry matches a model and optional usage. - Resolve(PricingResolveCommand), -} - -/// Common target-scope flags for model pricing config mutations. -#[derive(Debug, Clone, Default, Args)] -#[command(group( - ArgGroup::new("pricing_scope") - .args(["user", "project", "global"]) - .multiple(false) -))] -pub(crate) struct PricingScopeArgs { - /// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) user: bool, - /// Edit the nearest project config at `.nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) project: bool, - /// Edit the system config at `/etc/nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) global: bool, -} - -/// Args for `nemo-relay model-pricing validate`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PricingValidateCommand { - /// Path to a Relay model pricing catalog JSON file. - pub(crate) path: PathBuf, -} - -/// Args for `nemo-relay model-pricing init`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PricingInitCommand { - #[command(flatten)] - pub(crate) scope: PricingScopeArgs, -} - -/// Args for `nemo-relay model-pricing add-source`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PricingAddSourceCommand { - #[command(flatten)] - pub(crate) scope: PricingScopeArgs, - /// Path to a Relay model pricing catalog JSON file. - pub(crate) path: PathBuf, - /// Append as a lower-priority source instead of prepending as the highest-priority override. - #[arg(long)] - pub(crate) append: bool, -} - -/// Args for `nemo-relay model-pricing resolve`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PricingResolveCommand { - /// Model ID or routed model name to look up. - pub(crate) model: String, - /// Optional provider or route, such as `openai`, `anthropic`, or `azure/openai`. - #[arg(long)] - pub(crate) provider: Option, - /// Prompt/input token count to use for an estimate. - #[arg(long)] - pub(crate) prompt_tokens: Option, - /// Completion/output token count to use for an estimate. - #[arg(long)] - pub(crate) completion_tokens: Option, - /// Prompt-cache read token count to use for an estimate. - #[arg(long)] - pub(crate) cache_read_tokens: Option, - /// Prompt-cache write token count to use for an estimate. - #[arg(long)] - pub(crate) cache_write_tokens: Option, -} - -/// Args for `nemo-relay plugins edit`. -#[derive(Debug, Clone, Default, Args)] -#[command(group( - ArgGroup::new("scope") - .args(["user", "project", "global"]) - .multiple(false) -))] -pub(crate) struct PluginsScopeArgs { - /// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) user: bool, - /// Edit the nearest project config at `.nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) project: bool, - /// Edit the system config at `/etc/nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) global: bool, -} - -/// Args for `nemo-relay plugins edit`. -#[derive(Debug, Clone, Default, Args)] -pub(crate) struct PluginsEditCommand { - #[command(flatten)] - pub(crate) scope: PluginsScopeArgs, -} - -/// Args for `nemo-relay plugins add`. -#[derive(Debug, Clone, Default, Args)] -pub(crate) struct PluginsAddCommand { - #[command(flatten)] - pub(crate) scope: PluginsScopeArgs, - /// Path to a plugin directory or explicit `relay-plugin.toml`. - pub(crate) path: PathBuf, -} - -/// Args for `nemo-relay plugins validate`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsValidateCommand { - /// Canonical plugin ID or a local plugin directory / `relay-plugin.toml` path. - pub(crate) target: String, - /// Emit machine-readable JSON output. - #[arg(long)] - pub(crate) json: bool, -} - -/// Args for `nemo-relay plugins list`. -#[derive(Debug, Clone, Default, Args)] -pub(crate) struct PluginsListCommand { - /// Include tombstoned dynamic plugin records in the output. - #[arg(long)] - pub(crate) all: bool, - /// Emit machine-readable JSON output. - #[arg(long)] - pub(crate) json: bool, -} - -/// Args for `nemo-relay plugins inspect`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsInspectCommand { - /// Canonical plugin ID. - pub(crate) id: String, - /// Emit machine-readable JSON output. - #[arg(long)] - pub(crate) json: bool, -} - -/// Args for `nemo-relay plugins enable`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsEnableCommand { - /// Canonical plugin ID. - pub(crate) id: String, -} - -/// Args for `nemo-relay plugins disable`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsDisableCommand { - /// Canonical plugin ID. - pub(crate) id: String, -} - -/// Args for `nemo-relay plugins remove`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsRemoveCommand { - /// Canonical plugin ID. - pub(crate) id: String, -} +pub(crate) const DEFAULT_MAX_HOOK_PAYLOAD_BYTES: usize = 20 * 1024 * 1024; +pub(crate) const DEFAULT_MAX_PASSTHROUGH_BODY_BYTES: usize = 100 * 1024 * 1024; +pub(crate) const GATEWAY_URL_ENV: &str = "NEMO_RELAY_GATEWAY_URL"; +pub(crate) const TRANSPARENT_RUN_ENV: &str = "NEMO_RELAY_TRANSPARENT_RUN"; -#[derive(Debug, Clone, Default, Args)] -pub(crate) struct ServerArgs { - /// Path to an explicit config file (disables auto-discovery of workspace/global/system) - #[arg(long)] +/// Agent-neutral gateway configuration overrides supplied by any process entrypoint. +#[derive(Debug, Clone, Default)] +pub(crate) struct GatewayOverrides { pub(crate) config: Option, - /// Address for the gateway to listen on in daemon mode (default 127.0.0.1:4040) - #[arg(long, env = "NEMO_RELAY_GATEWAY_BIND")] pub(crate) bind: Option, - /// Upstream OpenAI-compatible base URL (e.g. https://api.openai.com/v1, NVIDIA inference) - #[arg(long, env = "NEMO_RELAY_OPENAI_BASE_URL")] pub(crate) openai_base_url: Option, - /// Upstream Anthropic base URL (e.g. https://api.anthropic.com) - #[arg(long, env = "NEMO_RELAY_ANTHROPIC_BASE_URL")] pub(crate) anthropic_base_url: Option, - /// Internal override for the plugin configuration file. - #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] pub(crate) plugin_config_path: Option, - /// Internal readiness file used by plugin sidecar bootstrap. - #[arg(long, hide = true)] pub(crate) ready_file: Option, - /// Maximum accepted coding-agent hook payload size, in bytes. - #[arg(long, env = "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES")] pub(crate) max_hook_payload_bytes: Option, - /// Maximum accepted provider passthrough request body size, in bytes. - #[arg(long, env = "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES")] pub(crate) max_passthrough_body_bytes: Option, } -impl ServerArgs { - /// True when the user passed any flag that signals "I want the gateway, not the wizard." Used - /// by the bare `nemo-relay` dispatch to choose between launching the long-running daemon and - /// dropping into setup. `--config` is included: someone running `nemo-relay --config ` - /// with no subcommand has explicitly pointed at a config file, which is only meaningful for - /// daemon startup — the wizard creates configs, it doesn't consume them. +impl GatewayOverrides { pub(crate) fn requested_daemon_mode(&self) -> bool { self.bind.is_some() || self.openai_base_url.is_some() @@ -491,97 +72,45 @@ impl ServerArgs { } } -pub(crate) const DEFAULT_MAX_HOOK_PAYLOAD_BYTES: usize = 20 * 1024 * 1024; -pub(crate) const DEFAULT_MAX_PASSTHROUGH_BODY_BYTES: usize = 100 * 1024 * 1024; -pub(crate) const GATEWAY_URL_ENV: &str = "NEMO_RELAY_GATEWAY_URL"; -pub(crate) const TRANSPARENT_RUN_ENV: &str = "NEMO_RELAY_TRANSPARENT_RUN"; - +/// Runtime input for transparent agent execution, independent of Clap. #[derive(Debug, Clone)] -pub(crate) struct GatewayConfig { - pub(crate) bind: SocketAddr, - pub(crate) openai_base_url: String, - pub(crate) anthropic_base_url: String, - pub(crate) metadata: Option, - pub(crate) plugin_config: Option, - pub(crate) max_hook_payload_bytes: usize, - pub(crate) max_passthrough_body_bytes: usize, +pub(crate) struct RunOverrides { + pub(crate) agent: Option, + pub(crate) config: Option, + pub(crate) openai_base_url: Option, + pub(crate) anthropic_base_url: Option, + pub(crate) session_metadata: Option, + pub(crate) plugin_config_path: Option, + pub(crate) dry_run: bool, + pub(crate) print: bool, + pub(crate) command: Vec, } -#[derive(Debug, Clone, Args)] -pub(crate) struct HookForwardCommand { - #[arg(value_enum)] +/// Runtime hook-forward request, independent of command-line parsing. +#[derive(Debug, Clone)] +pub(crate) struct HookForwardRequest { pub(crate) agent: CodingAgent, - #[arg(long)] pub(crate) gateway_url: Option, - /// Private install-generation fence used by generated persistent hooks. - #[arg(long, hide = true)] pub(crate) generation_file: Option, - /// Immutable generation identity paired with `generation_file` by installed hooks. - #[arg(long, hide = true)] pub(crate) generation_token: Option, - /// Forward to an existing gateway without starting or recovering Relay. - #[arg( - long, - conflicts_with_all = ["generation_file", "generation_token"] - )] pub(crate) forward_only: bool, - /// Marks the process-private hook source injected by `nemo-relay run`. - #[arg( - long, - hide = true, - conflicts_with_all = ["generation_file", "generation_token", "forward_only"] - )] pub(crate) transparent_run: bool, - #[arg(long)] pub(crate) profile: Option, - #[arg(long)] pub(crate) session_metadata: Option, - #[arg(long, value_enum)] pub(crate) gateway_mode: Option, - #[arg(long)] pub(crate) fail_closed: bool, } -/// Args for the easy-path agent shortcut (`nemo-relay claude`, `nemo-relay codex`, etc.). -/// Holds only pass-through agent args; the agent itself is selected by which subcommand variant -/// is invoked, and upstream settings come from the resolved config file. If no config file is -/// present, the dispatcher fires setup. -#[derive(Debug, Clone, Args)] -pub(crate) struct EasyPathCommand { - /// Pass-through args forwarded to the underlying agent process. Use `--` to separate them - /// from `nemo-relay`'s own flags. See the `Examples` section below for agent-specific shapes. - #[arg(last = true)] - pub(crate) command: Vec, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct RunCommand { - #[arg(long, value_enum)] - pub(crate) agent: Option, - #[arg(long)] - pub(crate) config: Option, - #[arg(long)] - pub(crate) openai_base_url: Option, - #[arg(long)] - pub(crate) anthropic_base_url: Option, - #[arg(long)] - pub(crate) session_metadata: Option, - /// Internal override for the plugin configuration file. - #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] - pub(crate) plugin_config_path: Option, - #[arg(long)] - pub(crate) dry_run: bool, - #[arg(long)] - pub(crate) print: bool, - #[arg(last = true)] - pub(crate) command: Vec, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GatewayMode { + HookOnly, + Passthrough, + Required, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] -#[value(rename_all = "kebab-case")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum IntegrationHost { Codex, - #[value(name = "claude-code", alias = "claude")] ClaudeCode, Hermes, All, @@ -619,12 +148,107 @@ impl IntegrationHost { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -#[value(rename_all = "kebab-case")] -pub(crate) enum GatewayMode { - HookOnly, - Passthrough, - Required, +#[derive(Debug, Clone)] +pub(crate) struct InstallRequest { + pub(crate) host: IntegrationHost, + pub(crate) install_dir: Option, + pub(crate) force: bool, + pub(crate) dry_run: bool, + pub(crate) skip_doctor: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct UninstallRequest { + pub(crate) host: IntegrationHost, + pub(crate) install_dir: Option, + pub(crate) dry_run: bool, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct PluginsScopeArgs { + pub(crate) user: bool, + pub(crate) project: bool, + pub(crate) global: bool, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct PluginsEditCommand { + pub(crate) scope: PluginsScopeArgs, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct PluginsAddCommand { + pub(crate) scope: PluginsScopeArgs, + pub(crate) path: PathBuf, +} + +#[derive(Debug, Clone)] +pub(crate) struct PluginsValidateCommand { + pub(crate) target: String, + pub(crate) json: bool, +} +#[derive(Debug, Clone, Default)] +pub(crate) struct PluginsListCommand { + pub(crate) all: bool, + pub(crate) json: bool, +} +#[derive(Debug, Clone)] +pub(crate) struct PluginsInspectCommand { + pub(crate) id: String, + pub(crate) json: bool, +} +#[derive(Debug, Clone)] +pub(crate) struct PluginsEnableCommand { + pub(crate) id: String, +} +#[derive(Debug, Clone)] +pub(crate) struct PluginsDisableCommand { + pub(crate) id: String, +} +#[derive(Debug, Clone)] +pub(crate) struct PluginsRemoveCommand { + pub(crate) id: String, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct PricingScopeArgs { + pub(crate) user: bool, + pub(crate) project: bool, + pub(crate) global: bool, +} +#[derive(Debug, Clone)] +pub(crate) struct PricingValidateCommand { + pub(crate) path: PathBuf, +} +#[derive(Debug, Clone)] +pub(crate) struct PricingInitCommand { + pub(crate) scope: PricingScopeArgs, +} +#[derive(Debug, Clone)] +pub(crate) struct PricingAddSourceCommand { + pub(crate) scope: PricingScopeArgs, + pub(crate) path: PathBuf, + pub(crate) append: bool, +} +#[derive(Debug, Clone)] +pub(crate) struct PricingResolveCommand { + pub(crate) model: String, + pub(crate) provider: Option, + pub(crate) prompt_tokens: Option, + pub(crate) completion_tokens: Option, + pub(crate) cache_read_tokens: Option, + pub(crate) cache_write_tokens: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct GatewayConfig { + pub(crate) bind: SocketAddr, + pub(crate) openai_base_url: String, + pub(crate) anthropic_base_url: String, + pub(crate) metadata: Option, + pub(crate) plugin_config: Option, + pub(crate) max_hook_payload_bytes: usize, + pub(crate) max_passthrough_body_bytes: usize, } #[derive(Debug, Clone, Default)] @@ -776,7 +400,7 @@ impl Default for GatewayConfig { /// /// File discovery and merge behavior live in `load_shared_config`; this function only applies the /// server-facing command-line layer so launcher-only settings cannot leak into daemon mode. -pub(crate) fn resolve_server_config(args: &ServerArgs) -> Result { +pub(crate) fn resolve_server_config(args: &GatewayOverrides) -> Result { let mut resolved = load_shared_config(args.config.as_ref(), args.plugin_config_path.as_ref())?; apply_server_overrides(&mut resolved.gateway, args)?; enforce_required_dynamic_plugin_startup(args.config.as_ref(), &resolved)?; @@ -785,7 +409,7 @@ pub(crate) fn resolve_server_config(args: &ServerArgs) -> Result Result { if args.config.is_some() || args.plugin_config_path.is_some() || args.ready_file.is_some() { return Err(CliError::Config( @@ -807,7 +431,7 @@ pub(crate) fn resolve_persistent_server_config( #[derive(Debug, Clone)] pub(crate) struct ManagedBootstrapIdentity { expected: String, - persistent_args: ServerArgs, + persistent_args: GatewayOverrides, resolved: ResolvedConfig, active_dynamic_plugins: Vec, } @@ -835,7 +459,7 @@ impl ManagedBootstrapIdentity { /// spawned by the plugin bootstrap path. The child recomputes identity from the configuration and /// active lifecycle records it is about to activate before publishing ownership or readiness. pub(crate) fn managed_bootstrap_identity( - args: &ServerArgs, + args: &GatewayOverrides, resolved: &ResolvedConfig, active_dynamic_plugins: &[ActiveDynamicPluginComponent], ) -> Result, CliError> { @@ -1587,8 +1211,8 @@ pub(crate) fn resolve_plugins_config( /// Session metadata and plugin config are parsed as JSON here so malformed CLI values fail before /// the child agent is spawned. pub(crate) fn resolve_run_config( - command: &RunCommand, - inherited: Option<&ServerArgs>, + command: &RunOverrides, + inherited: Option<&GatewayOverrides>, ) -> Result { let config = command .config @@ -1614,7 +1238,7 @@ pub(crate) fn resolve_run_config( // Applies subcommand-specific `run` overrides after inherited top-level flags. JSON-bearing fields // are parsed here so invalid metadata or plugin config fails before the gateway binds a port. -fn apply_run_overrides(config: &mut GatewayConfig, command: &RunCommand) -> Result<(), CliError> { +fn apply_run_overrides(config: &mut GatewayConfig, command: &RunOverrides) -> Result<(), CliError> { apply_run_url_overrides(config, command); apply_run_json_overrides(config, command)?; Ok(()) @@ -1622,7 +1246,7 @@ fn apply_run_overrides(config: &mut GatewayConfig, command: &RunCommand) -> Resu // Applies plain string/path run overrides. These fields do not need parsing, so they stay separate // from JSON options whose errors should include field context. -fn apply_run_url_overrides(config: &mut GatewayConfig, command: &RunCommand) { +fn apply_run_url_overrides(config: &mut GatewayConfig, command: &RunOverrides) { if let Some(value) = &command.openai_base_url { config.openai_base_url = value.clone(); } @@ -1635,7 +1259,7 @@ fn apply_run_url_overrides(config: &mut GatewayConfig, command: &RunCommand) { // before transparent run mode binds its ephemeral gateway listener. fn apply_run_json_overrides( config: &mut GatewayConfig, - command: &RunCommand, + command: &RunOverrides, ) -> Result<(), CliError> { if let Some(value) = &command.session_metadata { config.metadata = Some(parse_json_option("session metadata", value)?); @@ -1645,7 +1269,10 @@ fn apply_run_json_overrides( // Applies direct server flags on top of already-merged configuration. Only present options mutate // the config so lower-priority file values survive when a flag was omitted. -fn apply_server_overrides(config: &mut GatewayConfig, args: &ServerArgs) -> Result<(), CliError> { +fn apply_server_overrides( + config: &mut GatewayConfig, + args: &GatewayOverrides, +) -> Result<(), CliError> { if let Some(value) = args.bind { config.bind = value; } diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index 348621720..7b824e5f9 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -26,8 +26,9 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest; use uuid::Uuid; use crate::configuration::{ - AgentConfigs, CodingAgent, DynamicPluginHostConfigStatus, GatewayConfig, ResolvedConfig, - ServerArgs, default_plugin_config_paths, effective_plugin_toml_sources, resolve_server_config, + AgentConfigs, CodingAgent, DynamicPluginHostConfigStatus, GatewayConfig, GatewayOverrides, + ResolvedConfig, default_plugin_config_paths, effective_plugin_toml_sources, + resolve_server_config, }; use crate::error::CliError; @@ -130,7 +131,7 @@ pub(crate) struct AgentInfo { pub(crate) async fn collect_report( target_agent: Option, ) -> Result { - let (resolved, resolution) = match resolve_server_config(&ServerArgs::default()) { + let (resolved, resolution) = match resolve_server_config(&GatewayOverrides::default()) { Ok(resolved) => ( resolved, Check { @@ -1506,7 +1507,7 @@ pub(crate) fn format_json(report: &DoctorReport) -> Result { /// Runs `agents` — a thin wrapper over `collect_agents` that emits only the agent list. Shares /// the same JSON schema as `doctor.agents` for consistency. pub(crate) async fn agents_report() -> Vec { - let resolved = resolve_server_config(&ServerArgs::default()).unwrap_or_default(); + let resolved = resolve_server_config(&GatewayOverrides::default()).unwrap_or_default(); collect_agents(None, &resolved).await } diff --git a/crates/cli/src/hooks/mod.rs b/crates/cli/src/hooks/mod.rs index e881058c6..6372e26d8 100644 --- a/crates/cli/src/hooks/mod.rs +++ b/crates/cli/src/hooks/mod.rs @@ -12,7 +12,7 @@ use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; use serde_json::{Value, json}; use crate::configuration::{ - CodingAgent, GATEWAY_URL_ENV, GatewayMode, HookForwardCommand, TRANSPARENT_RUN_ENV, + CodingAgent, GATEWAY_URL_ENV, GatewayMode, HookForwardRequest, TRANSPARENT_RUN_ENV, }; use crate::error::CliError; use crate::installation::generation::InstallGeneration; @@ -26,7 +26,7 @@ const MAX_HOOK_RESPONSE_BYTES: usize = 1024 * 1024; /// Empty stdin is normalized to `{}` so hooks that provide no payload still generate observable /// marks. Delivery failures are fail-open by default to avoid blocking coding agents, but /// `--fail-closed` converts missing URLs, HTTP failures, and upstream errors into process errors. -pub(crate) async fn hook_forward(command: HookForwardCommand) -> Result<(), CliError> { +pub(crate) async fn hook_forward(command: HookForwardRequest) -> Result<(), CliError> { // A transparent wrapper can coexist with any installed Relay plugin. Its process marker makes // persistent plugin hooks inert, while only the wrapper-owned command carries // `--transparent-run` and forwards to the process-private gateway. This avoids rewriting host @@ -166,7 +166,7 @@ enum HookGatewayLifecycle { // Installed hooks use the shared fixed gateway that MCP owns. Transparent runs set the dynamic // environment URL and already own that gateway's lifecycle. -fn hook_destination(command: &HookForwardCommand) -> HookDestination { +fn hook_destination(command: &HookForwardRequest) -> HookDestination { resolve_hook_destination( command.gateway_url.clone(), std::env::var(GATEWAY_URL_ENV).ok(), @@ -256,7 +256,7 @@ fn transparent_gateway_spec(gateway_url: &str) -> Result Result, CliError> { diff --git a/crates/cli/src/mcp/gateway.rs b/crates/cli/src/mcp/gateway.rs index 1d3f4343e..6f5b6b937 100644 --- a/crates/cli/src/mcp/gateway.rs +++ b/crates/cli/src/mcp/gateway.rs @@ -7,7 +7,7 @@ use std::net::SocketAddr; use std::time::Duration; use crate::bootstrap::{GatewayEndpoint, GatewaySpec}; -use crate::configuration::ServerArgs; +use crate::configuration::GatewayOverrides; use crate::error::CliError; use crate::installation::generation::{ActiveGenerationGuard, InstallGeneration}; @@ -23,7 +23,7 @@ pub(super) struct GatewayPlan { } impl GatewayPlan { - pub(super) async fn resolve(server_args: &ServerArgs) -> Result { + pub(super) async fn resolve(server_args: &GatewayOverrides) -> Result { let captured = tokio::task::spawn_blocking(InstallGeneration::capture_guarded_from_env) .await .map_err(|error| { diff --git a/crates/cli/src/mcp/mod.rs b/crates/cli/src/mcp/mod.rs index b3a378254..071ebf654 100644 --- a/crates/cli/src/mcp/mod.rs +++ b/crates/cli/src/mcp/mod.rs @@ -14,14 +14,14 @@ use std::process::ExitCode; use serde_json::{Value, json}; -use crate::configuration::ServerArgs; +use crate::configuration::GatewayOverrides; use crate::error::CliError; use crate::installation::generation::{GENERATION_FILE_ENV, GENERATION_TOKEN_ENV}; pub(crate) const SERVER_NAME: &str = "nemo-relay"; const LAUNCH_ARGS: &[&str] = &["mcp"]; -pub(crate) async fn run(server_args: &ServerArgs) -> Result { +pub(crate) async fn run(server_args: &GatewayOverrides) -> Result { if transparent_run_active() { // An installed plugin can still be enabled inside `nemo-relay run`. In that process the // wrapper already owns a healthy dynamic gateway, so this MCP instance authenticates and diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index 22924a5ce..6839fdbc0 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -18,9 +18,9 @@ use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use crate::configuration::{ - MAX_BOOTSTRAP_IDENTITY_FILE_BYTES, PluginsAddCommand, PluginsDisableCommand, + GatewayOverrides, MAX_BOOTSTRAP_IDENTITY_FILE_BYTES, PluginsAddCommand, PluginsDisableCommand, PluginsEnableCommand, PluginsInspectCommand, PluginsListCommand, PluginsRemoveCommand, - PluginsValidateCommand, ResolvedConfig, ResolvedDynamicPluginConfig, ServerArgs, + PluginsValidateCommand, ResolvedConfig, ResolvedDynamicPluginConfig, load_bounded_dynamic_plugin_manifest_bytes, read_bounded_regular_file, resolve_plugins_config, }; use crate::error::{CliError, PluginLifecycleFailureKind}; @@ -76,13 +76,13 @@ pub(crate) fn test_python_environment_digest_calls() -> usize { self::environment::environment_tree_digest_calls() } -pub(crate) fn add(command: PluginsAddCommand, server: &ServerArgs) -> Result<(), CliError> { +pub(crate) fn add(command: PluginsAddCommand, server: &GatewayOverrides) -> Result<(), CliError> { add_with_environment_runner(command, server, &ProcessPythonEnvironmentCommandRunner) } fn add_with_environment_runner( command: PluginsAddCommand, - server: &ServerArgs, + server: &GatewayOverrides, environment_runner: &impl PythonEnvironmentCommandRunner, ) -> Result<(), CliError> { const COMMAND: &str = "plugins add"; @@ -247,7 +247,7 @@ pub(crate) fn enforce_required_dynamic_plugin_startup( pub(crate) fn validate( command: PluginsValidateCommand, - server: &ServerArgs, + server: &GatewayOverrides, ) -> Result<(), CliError> { match PluginTarget::parse(&command.target) { PluginTarget::Path(path) => { @@ -350,7 +350,7 @@ pub(crate) fn validate( } } -pub(crate) fn list(command: PluginsListCommand, server: &ServerArgs) -> Result<(), CliError> { +pub(crate) fn list(command: PluginsListCommand, server: &GatewayOverrides) -> Result<(), CliError> { let resolved = resolve_plugins_config(server.config.as_ref())?; let host_config_by_id = host_config_by_id(&resolved); let scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; @@ -387,7 +387,10 @@ pub(crate) fn list(command: PluginsListCommand, server: &ServerArgs) -> Result<( Ok(()) } -pub(crate) fn inspect(command: PluginsInspectCommand, server: &ServerArgs) -> Result<(), CliError> { +pub(crate) fn inspect( + command: PluginsInspectCommand, + server: &GatewayOverrides, +) -> Result<(), CliError> { let resolved = resolve_plugins_config(server.config.as_ref())?; let host_config_by_id = host_config_by_id(&resolved); let scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; @@ -417,15 +420,24 @@ pub(crate) fn inspect(command: PluginsInspectCommand, server: &ServerArgs) -> Re Ok(()) } -pub(crate) fn enable(command: PluginsEnableCommand, server: &ServerArgs) -> Result<(), CliError> { +pub(crate) fn enable( + command: PluginsEnableCommand, + server: &GatewayOverrides, +) -> Result<(), CliError> { mutate_enabled_state(command.id, server, true) } -pub(crate) fn disable(command: PluginsDisableCommand, server: &ServerArgs) -> Result<(), CliError> { +pub(crate) fn disable( + command: PluginsDisableCommand, + server: &GatewayOverrides, +) -> Result<(), CliError> { mutate_enabled_state(command.id, server, false) } -pub(crate) fn remove(command: PluginsRemoveCommand, server: &ServerArgs) -> Result<(), CliError> { +pub(crate) fn remove( + command: PluginsRemoveCommand, + server: &GatewayOverrides, +) -> Result<(), CliError> { let mut scopes = load_scoped_registries(server.config.as_ref())?; if find_record_by_id(&scopes, &command.id)?.is_none() { let resolved = resolve_plugins_config(server.config.as_ref())?; @@ -1744,7 +1756,7 @@ fn active_dynamic_plugin_components_from_scopes( fn mutate_enabled_state( plugin_id: String, - server: &ServerArgs, + server: &GatewayOverrides, enabled: bool, ) -> Result<(), CliError> { let command = if enabled { diff --git a/crates/cli/src/plugins/pricing.rs b/crates/cli/src/plugins/pricing.rs index c6914a60e..3f363d2ea 100644 --- a/crates/cli/src/plugins/pricing.rs +++ b/crates/cli/src/plugins/pricing.rs @@ -13,8 +13,8 @@ use nemo_relay::plugin::{PluginComponentSpec, PluginConfig}; use serde_json::Value; use crate::configuration::{ - PricingAddSourceCommand, PricingInitCommand, PricingResolveCommand, PricingScopeArgs, - PricingValidateCommand, ServerArgs, resolve_server_config, + GatewayOverrides, PricingAddSourceCommand, PricingInitCommand, PricingResolveCommand, + PricingScopeArgs, PricingValidateCommand, resolve_server_config, }; use crate::error::CliError; use crate::plugins::config_io::{PluginConfigDocument, TargetScope, target_path, validate_config}; @@ -165,7 +165,7 @@ struct ResolvedPricing { } fn pricing_catalog_sources_from_current_config() -> Result, CliError> { - let resolved = resolve_server_config(&ServerArgs::default())?; + let resolved = resolve_server_config(&GatewayOverrides::default())?; let Some(plugin_config) = resolved.gateway.plugin_config else { return Ok(vec![]); }; diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index 0d097b928..3003c1340 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -15,8 +15,8 @@ use tokio::sync::oneshot; use tokio::task::JoinHandle; use crate::configuration::{ - AgentConfigs, CodingAgent, EasyPathCommand, GatewayConfig, RELAY_PLUGIN_ID, - RELAY_SOURCE_PLUGIN_ID, ResolvedConfig, RunCommand, ServerArgs, any_config_file_exists, + AgentConfigs, CodingAgent, GatewayConfig, GatewayOverrides, RELAY_PLUGIN_ID, + RELAY_SOURCE_PLUGIN_ID, ResolvedConfig, RunOverrides, any_config_file_exists, resolve_run_config, }; use crate::error::CliError; @@ -31,8 +31,8 @@ use crate::server; /// server shut down. The child's exit status is preserved when it fits in `ExitCode`; otherwise the /// launcher reports generic failure. pub(crate) async fn run( - command: RunCommand, - inherited: Option<&ServerArgs>, + command: RunOverrides, + inherited: Option<&GatewayOverrides>, ) -> Result { let run = TransparentRun::new(command, inherited).await?; run.print_if_requested(); @@ -43,13 +43,13 @@ pub(crate) async fn run( /// /// If no config file is present at any discovery layer, this fires the interactive setup inline /// (`crate::configuration::wizard::run`) which writes a `config.toml`, then proceeds to launch the agent. When -/// config IS present, the easy path constructs a synthetic `RunCommand` and delegates to the +/// config IS present, the easy path constructs a synthetic `RunOverrides` and delegates to the /// same transparent-run pipeline `nemo-relay run` uses — same observability wiring, same agent /// argv resolution, same lifecycle management. pub(crate) async fn easy_path( agent: CodingAgent, - command: EasyPathCommand, - inherited: Option<&ServerArgs>, + command: Vec, + inherited: Option<&GatewayOverrides>, ) -> Result { // Explicit `--config ` short-circuits the discovery-based setup trigger: when the // user has pointed at a specific file, that file is the contract — fire setup only if it @@ -67,7 +67,7 @@ pub(crate) async fn easy_path( // cancelled), surface that directly. crate::configuration::wizard::run(Some(agent)).await?; } - let synthetic = RunCommand { + let synthetic = RunOverrides { agent: Some(agent), // Forward the explicit config path so `run` parses the same file the user asked for, // rather than re-discovering from defaults. @@ -78,7 +78,7 @@ pub(crate) async fn easy_path( plugin_config_path: None, dry_run: false, print: false, - command: command.command, + command, }; run(synthetic, inherited).await } @@ -97,7 +97,10 @@ struct TransparentRun { impl TransparentRun { // Resolves configuration, binds the ephemeral listener, and builds agent-specific launch wiring // without starting the gateway or spawning the child command. - async fn new(command: RunCommand, inherited: Option<&ServerArgs>) -> Result { + async fn new( + command: RunOverrides, + inherited: Option<&GatewayOverrides>, + ) -> Result { let dry_run = command.dry_run; let print = command.print; let explicit_config = command @@ -249,7 +252,7 @@ struct AgentInvocation { } fn resolve_agent_invocation( - command: &RunCommand, + command: &RunOverrides, agents: &AgentConfigs, ) -> Result { if let Some(agent) = command.agent { @@ -287,7 +290,7 @@ fn resolve_agent_invocation( #[cfg(test)] fn resolve_agent_and_argv( - command: &RunCommand, + command: &RunOverrides, agents: &AgentConfigs, ) -> Result<(CodingAgent, Vec), CliError> { resolve_agent_invocation(command, agents).map(|invocation| (invocation.agent, invocation.argv)) diff --git a/crates/cli/tests/coverage/agents/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs index de50c1a24..604232d2c 100644 --- a/crates/cli/tests/coverage/agents/launcher_tests.rs +++ b/crates/cli/tests/coverage/agents/launcher_tests.rs @@ -62,7 +62,7 @@ impl Drop for EnvScope { #[test] fn infers_agent_from_command_or_uses_override() { - let command = RunCommand { + let command = RunOverrides { agent: None, config: None, openai_base_url: None, @@ -77,7 +77,7 @@ fn infers_agent_from_command_or_uses_override() { assert_eq!(agent, CodingAgent::Codex); assert_eq!(argv, vec!["/usr/bin/codex"]); - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::ClaudeCode), command: vec!["wrapper".into()], ..command @@ -95,7 +95,7 @@ fn uses_configured_command_when_no_argv_is_supplied() { }, ..AgentConfigs::default() }; - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: None, openai_base_url: None, @@ -122,7 +122,7 @@ fn uses_configured_hermes_command_when_no_argv_is_supplied() { }, ..AgentConfigs::default() }; - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Hermes), config: None, openai_base_url: None, @@ -142,7 +142,7 @@ fn uses_configured_hermes_command_when_no_argv_is_supplied() { #[test] fn inference_failure_has_actionable_message() { - let command = RunCommand { + let command = RunOverrides { agent: None, config: None, openai_base_url: None, @@ -167,7 +167,7 @@ fn missing_command_without_agent_errors() { // argv[0] to infer an agent from. With --agent set, we fall back to the agent's default // binary name (e.g., `hermes`), so that branch is exercised in the resolution test // below rather than here. - let command = RunCommand { + let command = RunOverrides { agent: None, config: None, openai_base_url: None, @@ -190,7 +190,7 @@ fn missing_command_without_agent_errors() { fn agent_without_configured_command_falls_back_to_default_binary() { // `--agent hermes` with no `[agents.hermes] command = "..."` override resolves to the // default executable name on $PATH. - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Hermes), config: None, openai_base_url: None, @@ -211,7 +211,7 @@ fn agent_without_configured_command_falls_back_to_default_binary() { fn agent_with_passthrough_args_appends_to_configured_command() { // The easy-path uses this code path: `nemo-relay codex -- --model X` resolves to the // configured (or default) codex command with `--model X` appended. - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: None, openai_base_url: None, @@ -625,7 +625,7 @@ fn invocation_resolves_wrapper_host_before_appending_pass_through_arguments() { }, ..AgentConfigs::default() }; - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: None, openai_base_url: None, @@ -1444,7 +1444,7 @@ async fn run_starts_gateway_injects_env_and_returns_agent_exit_code() { std::fs::write(&config, "[upstream]\n").unwrap(); let output = temp.path().join("env.txt"); let command_argv = fake_agent_command(temp.path(), &output); - let command = RunCommand { + let command = RunOverrides { // Leave `agent: None` so the launcher infers from argv[0] and uses `command_argv` // (our fake-agent.sh) as the full argv. With --agent set, the resolver appends // command as pass-through after the configured/default binary — not what this test @@ -1489,7 +1489,7 @@ fn fake_agent_command(temp: &Path, output: &Path) -> Vec { #[tokio::test] async fn dry_run_does_not_spawn_agent() { - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: None, openai_base_url: None, @@ -1550,7 +1550,7 @@ entrypoint = "acme.worker:create_plugin" ) .unwrap(); - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: Some(config_path), openai_base_url: None, diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index 3a17d4e5d..a6a11808c 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -1986,7 +1986,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { std::fs::create_dir_all(&empty_path).unwrap(); let _path = PathScope::set_isolated(&empty_path, &dir.path().join("home")); - let install_error = install(crate::configuration::InstallCommand { + let install_error = install(crate::configuration::InstallRequest { host: IntegrationHost::All, install_dir: Some(dir.path().join("install")), force: false, @@ -2000,7 +2000,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { "error was: {install_error}" ); - let uninstall_error = uninstall(crate::configuration::UninstallCommand { + let uninstall_error = uninstall(crate::configuration::UninstallRequest { host: IntegrationHost::All, install_dir: Some(dir.path().join("install")), dry_run: false, @@ -2032,7 +2032,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { ); assert_eq!( - install(crate::configuration::InstallCommand { + install(crate::configuration::InstallRequest { host: IntegrationHost::Codex, install_dir: Some(dir.path().join("dry-run-install")), force: false, diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index 6d116d412..201b54d3e 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -5,7 +5,7 @@ use clap::Parser; use std::ffi::OsString; use super::*; -use crate::configuration::{ +use crate::commands::arguments::{ CompletionsCommand, PluginsCommand, PluginsEditCommand, PluginsInspectCommand, PluginsListCommand, PluginsSubcommand, PluginsValidateCommand, PricingSubcommand, PricingValidateCommand, ServerArgs, diff --git a/crates/cli/tests/coverage/shared/bootstrap_tests.rs b/crates/cli/tests/coverage/shared/bootstrap_tests.rs index c024c9b14..c5383f2ed 100644 --- a/crates/cli/tests/coverage/shared/bootstrap_tests.rs +++ b/crates/cli/tests/coverage/shared/bootstrap_tests.rs @@ -216,7 +216,7 @@ fn persistent_gateway_resolution_keeps_server_configuration_in_one_spec() { ("USERPROFILE", None), ]); let bind = DEFAULT_BIND.parse().unwrap(); - let resolved = resolve_plugin_gateway(&ServerArgs::default(), bind).unwrap(); + let resolved = resolve_plugin_gateway(&GatewayOverrides::default(), bind).unwrap(); assert_eq!(resolved.gateway.bind(), bind); assert_eq!( diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index 5f91bebcf..a7f95803c 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -406,7 +406,7 @@ command = "hermes --yolo chat" "#, ) .unwrap(); - let command = RunCommand { + let command = RunOverrides { agent: None, config: Some(path), openai_base_url: None, @@ -441,7 +441,7 @@ command = "hermes --yolo chat" fn explicit_config_must_exist() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("missing-config.toml"); - let command = RunCommand { + let command = RunOverrides { agent: None, config: Some(path.clone()), openai_base_url: None, @@ -478,7 +478,7 @@ fn unreadable_config_errors_include_the_source_path() { let config_path = temp.path().join("config.toml"); std::fs::write(&config_path, "").unwrap(); std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o000)).unwrap(); - let command = RunCommand { + let command = RunOverrides { agent: None, config: Some(config_path.clone()), openai_base_url: None, @@ -535,7 +535,7 @@ fn legacy_observability_config_sections_fail_clearly() { ] { let path = temp.path().join(name); std::fs::write(&path, contents).unwrap(); - let command = RunCommand { + let command = RunOverrides { agent: None, config: Some(path), openai_base_url: None, @@ -588,7 +588,7 @@ mode = "overwrite" "#, ) .unwrap(); - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: Some(config_path), openai_base_url: None, @@ -1414,9 +1414,9 @@ config = { version = 1, components = [] } "#, ) .unwrap(); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -1435,7 +1435,7 @@ fn plugin_config_path_overrides_sibling_plugin_file() { std::fs::write(&config_path, "").unwrap(); std::fs::write(&sibling_path, "version = 1\n").unwrap(); std::fs::write(&override_path, "version = 2\n").unwrap(); - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: Some(config_path), openai_base_url: None, @@ -1467,7 +1467,7 @@ openai_base_url = "http://file-openai" "#, ) .unwrap(); - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: Some(path), openai_base_url: Some("http://cli-openai".into()), @@ -1497,12 +1497,12 @@ openai_base_url = "http://file-openai" "#, ) .unwrap(); - let server = ServerArgs { + let server = GatewayOverrides { config: Some(path), openai_base_url: Some("http://top-level-openai".into()), - ..ServerArgs::default() + ..GatewayOverrides::default() }; - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: None, openai_base_url: None, @@ -1527,7 +1527,7 @@ fn server_resolution_applies_all_server_overrides() { let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); let config_path = isolated_config_path(&temp); std::fs::write(&config_path, "").unwrap(); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(config_path), bind: Some("127.0.0.1:0".parse().unwrap()), openai_base_url: Some("http://cli-openai".into()), @@ -1564,10 +1564,10 @@ fn ordinary_server_ignores_managed_bootstrap_fingerprint_environment() { let config_path = isolated_config_path(&temp); std::fs::write(&config_path, "").unwrap(); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(config_path), bind: Some("127.0.0.1:0".parse().unwrap()), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let resolved = resolve_server_config(&args).unwrap(); @@ -1583,7 +1583,7 @@ fn ordinary_server_ignores_managed_bootstrap_fingerprint_environment() { ); scope.set_bootstrap_fingerprint(""); - let managed_args = ServerArgs { + let managed_args = GatewayOverrides { ready_file: Some(temp.path().join("managed.ready.json")), ..args }; @@ -1805,9 +1805,9 @@ fn persistent_server_resolution_excludes_project_config_and_fingerprints_credent ) .unwrap(); let _scope = PluginConfigDiscoveryScope::enter(&project, &xdg); - let args = ServerArgs { + let args = GatewayOverrides { bind: Some("127.0.0.1:47632".parse().unwrap()), - ..ServerArgs::default() + ..GatewayOverrides::default() }; unsafe { std::env::set_var("OPENAI_API_KEY", "credential-one") }; @@ -1845,15 +1845,15 @@ fn managed_bootstrap_canonicalizes_unset_and_zero_padded_default_idle_timeout() let xdg = temp.path().join("xdg"); std::fs::create_dir_all(&xdg).unwrap(); let scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); - let parent = resolve_persistent_server_config(&ServerArgs::default()).unwrap(); + let parent = resolve_persistent_server_config(&GatewayOverrides::default()).unwrap(); let expected = parent.bootstrap_fingerprint.unwrap(); scope.set_bootstrap_fingerprint(&expected); unsafe { std::env::set_var(PLUGIN_IDLE_TIMEOUT_ENV, "0300"); } - let child_args = ServerArgs { + let child_args = GatewayOverrides { ready_file: Some(temp.path().join("managed.ready.json")), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let child = resolve_server_config(&child_args).unwrap(); let active = active_dynamic_plugin_components(None, &child).unwrap(); @@ -1881,7 +1881,7 @@ fn plugin_launch_carries_effective_hook_limit_below_and_above_default() { ) .unwrap(); let launch = - crate::bootstrap::resolve_plugin_gateway(&ServerArgs::default(), bind).unwrap(); + crate::bootstrap::resolve_plugin_gateway(&GatewayOverrides::default(), bind).unwrap(); assert_eq!(launch.max_hook_payload_bytes, limit); } } @@ -2074,7 +2074,7 @@ fn persistent_hook_identity_authenticates_python_marker_without_rehashing_enviro .unwrap(); crate::plugins::lifecycle::reset_test_python_environment_digest_calls(); - let before = resolve_persistent_server_config(&ServerArgs::default()).unwrap(); + let before = resolve_persistent_server_config(&GatewayOverrides::default()).unwrap(); assert_eq!( crate::plugins::lifecycle::test_python_environment_digest_calls(), 0, @@ -2098,7 +2098,7 @@ fn persistent_hook_identity_authenticates_python_marker_without_rehashing_enviro b"fixture = 'mutated'\n", ) .unwrap(); - let after = resolve_persistent_server_config(&ServerArgs::default()).unwrap(); + let after = resolve_persistent_server_config(&GatewayOverrides::default()).unwrap(); assert_eq!( crate::plugins::lifecycle::test_python_environment_digest_calls(), 0, @@ -2143,9 +2143,9 @@ fn managed_server_rejects_config_and_artifact_changes_after_parent_resolution() let expected = persistent_bootstrap_fingerprint(&resolved, std::slice::from_ref(&active)).unwrap(); scope.set_bootstrap_fingerprint(&expected); - let args = ServerArgs { + let args = GatewayOverrides { ready_file: Some(temp.path().join("managed.ready.json")), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let identity = managed_bootstrap_identity(&args, &resolved, std::slice::from_ref(&active)) @@ -2221,7 +2221,7 @@ manifest = "plugins/acme/relay-plugin.toml" .set_len(MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1) .unwrap(); - let error = resolve_persistent_server_config(&ServerArgs::default()) + let error = resolve_persistent_server_config(&GatewayOverrides::default()) .unwrap_err() .to_string(); @@ -2258,7 +2258,7 @@ startup = "required" .set_len(MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1) .unwrap(); - let error = resolve_persistent_server_config(&ServerArgs::default()) + let error = resolve_persistent_server_config(&GatewayOverrides::default()) .unwrap_err() .to_string(); @@ -2280,9 +2280,9 @@ fn bootstrap_hmac_key_rejects_corrupt_persistent_state() { #[test] fn persistent_server_resolution_rejects_project_specific_flags() { - let args = ServerArgs { + let args = GatewayOverrides { config: Some(PathBuf::from("project-config.toml")), - ..ServerArgs::default() + ..GatewayOverrides::default() }; assert!( @@ -2315,9 +2315,9 @@ allowed = false ) .unwrap(); write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -2354,9 +2354,9 @@ startup = "required" .unwrap(); write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -2422,9 +2422,9 @@ attestation = "signature_required" .unwrap(); write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -2494,9 +2494,9 @@ fn server_resolution_fails_when_required_enabled_dynamic_plugin_has_wrong_truste .unwrap(); write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -2566,9 +2566,9 @@ fn server_resolution_fails_when_required_enabled_dynamic_plugin_has_malformed_si .unwrap(); write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -2631,9 +2631,9 @@ fn gateway_body_limit_file_values_must_be_nonzero() { ), ] { std::fs::write(&path, format!("[gateway]\n{field} = 0\n")).unwrap(); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(path.clone()), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -2648,7 +2648,7 @@ fn run_resolution_applies_all_run_overrides() { let temp = tempfile::tempdir().unwrap(); let config_path = isolated_config_path(&temp); std::fs::write(&config_path, "").unwrap(); - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: Some(config_path), openai_base_url: Some("http://run-openai".into()), @@ -2689,7 +2689,7 @@ allowed = false ) .unwrap(); write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: Some(config_path), openai_base_url: None, @@ -2713,9 +2713,9 @@ fn malformed_shared_config_reports_context() { let temp = tempfile::tempdir().unwrap(); let invalid_toml = temp.path().join("invalid.toml"); std::fs::write(&invalid_toml, "server = [").unwrap(); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(invalid_toml), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -2724,9 +2724,9 @@ fn malformed_shared_config_reports_context() { let invalid_shape = temp.path().join("invalid-shape.toml"); std::fs::write(&invalid_shape, "upstream = \"not-a-table\"").unwrap(); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(invalid_shape), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -2736,9 +2736,9 @@ fn malformed_shared_config_reports_context() { let plugin_config = temp.path().join("config-with-invalid-plugins.toml"); std::fs::write(&plugin_config, "").unwrap(); std::fs::write(temp.path().join("plugins.toml"), "version = [").unwrap(); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(plugin_config), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); diff --git a/crates/cli/tests/coverage/shared/installer_tests.rs b/crates/cli/tests/coverage/shared/installer_tests.rs index e21d79674..4595f235b 100644 --- a/crates/cli/tests/coverage/shared/installer_tests.rs +++ b/crates/cli/tests/coverage/shared/installer_tests.rs @@ -73,7 +73,7 @@ async fn transparent_hook_delivery_authenticates_the_wrapper_gateway() { }) .await .expect("wrapper gateway did not become healthy"); - let command = HookForwardCommand { + let command = HookForwardRequest { agent: CodingAgent::Codex, gateway_url: Some(gateway_url.clone()), generation_file: None, diff --git a/crates/cli/tests/coverage/shared/mcp_tests.rs b/crates/cli/tests/coverage/shared/mcp_tests.rs index 3ca2468f1..04a00c5cf 100644 --- a/crates/cli/tests/coverage/shared/mcp_tests.rs +++ b/crates/cli/tests/coverage/shared/mcp_tests.rs @@ -89,7 +89,7 @@ impl Drop for TransparentRunEnvironment { async fn transparent_mcp_requires_the_wrapper_gateway_url() { let _environment = TransparentRunEnvironment::without_gateway(); - let error = run(&crate::configuration::ServerArgs::default()) + let error = run(&crate::configuration::GatewayOverrides::default()) .await .unwrap_err() .to_string(); diff --git a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs index 6b294621e..642ffcb44 100644 --- a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs @@ -9,8 +9,9 @@ use std::{ use super::*; use crate::configuration::{ - PluginsAddCommand, PluginsDisableCommand, PluginsEnableCommand, PluginsInspectCommand, - PluginsListCommand, PluginsRemoveCommand, PluginsScopeArgs, PluginsValidateCommand, ServerArgs, + GatewayOverrides, PluginsAddCommand, PluginsDisableCommand, PluginsEnableCommand, + PluginsInspectCommand, PluginsListCommand, PluginsRemoveCommand, PluginsScopeArgs, + PluginsValidateCommand, }; use crate::error::PluginLifecycleFailureKind; use base64::Engine; @@ -622,7 +623,7 @@ fn tracked_native_plugin_example_satisfies_default_trust_policy() { }, path: plugin_dir, }, - &ServerArgs::default(), + &GatewayOverrides::default(), ) .unwrap(); @@ -652,7 +653,7 @@ fn tracked_native_plugin_example_rejects_tampered_artifact() { }, path: plugin_dir, }, - &ServerArgs::default(), + &GatewayOverrides::default(), ) .unwrap_err(); @@ -1759,7 +1760,7 @@ fn add_registers_dynamic_plugin_in_project_plugins_toml() { }, path: plugin_dir.clone(), }, - &crate::configuration::ServerArgs::default(), + &crate::configuration::GatewayOverrides::default(), ) .unwrap(); @@ -1798,7 +1799,7 @@ fn add_rejects_unreadable_declared_config_schema() { }, path: plugin_dir, }, - &ServerArgs::default(), + &GatewayOverrides::default(), ) .unwrap_err() .to_string(); @@ -1829,7 +1830,7 @@ fn validate_path_rejects_invalid_declared_config_schema() { target: plugin_dir.to_string_lossy().into_owned(), json: false, }, - &ServerArgs::default(), + &GatewayOverrides::default(), ) .unwrap_err() .to_string(); @@ -1854,7 +1855,7 @@ fn validate_id_checks_resolved_host_config_against_declared_schema() { "properties": {"port": {"type": "integer"}} }), ); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add( PluginsAddCommand { scope: PluginsScopeArgs { @@ -1900,7 +1901,7 @@ fn add_provisions_persists_and_removes_managed_python_environment() { std::fs::create_dir_all(&plugin_dir).unwrap(); write_python_dynamic_manifest(&plugin_dir, " acme.python "); let runner = FakePythonEnvironmentRunner::default(); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add_with_environment_runner( PluginsAddCommand { @@ -2060,7 +2061,7 @@ fn add_rolls_back_python_environment_when_installation_fails() { }, path: plugin_dir, }, - &ServerArgs::default(), + &GatewayOverrides::default(), &runner, ) .expect_err("pip failure should abort plugin registration"); @@ -2094,7 +2095,7 @@ fn enable_rejects_missing_managed_python_environment() { std::fs::create_dir_all(&plugin_dir).unwrap(); write_python_dynamic_manifest(&plugin_dir, "acme.python-missing"); let runner = FakePythonEnvironmentRunner::default(); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add_with_environment_runner( PluginsAddCommand { scope: PluginsScopeArgs { @@ -2153,7 +2154,7 @@ fn enable_rejects_python_environment_outside_managed_location() { std::fs::create_dir_all(&plugin_dir).unwrap(); write_python_dynamic_manifest(&plugin_dir, "acme.python-outside"); let runner = FakePythonEnvironmentRunner::default(); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add_with_environment_runner( PluginsAddCommand { scope: PluginsScopeArgs { @@ -2235,7 +2236,7 @@ fn add_requires_manifest_root_for_python_workers() { }, path: plugin_dir, }, - &ServerArgs::default(), + &GatewayOverrides::default(), &runner, ) .expect_err("Python plugins without manifest_root should fail"); @@ -2277,7 +2278,7 @@ fn add_rejects_python_entrypoint_module_that_is_not_integrity_checked_artifact() }, path: plugin_dir, }, - &ServerArgs::default(), + &GatewayOverrides::default(), &runner, ) .expect_err("an unsigned sibling module must not become the executed entrypoint"); @@ -2374,7 +2375,7 @@ fn remove_can_retry_after_guarded_environment_cleanup_failure() { std::fs::create_dir_all(&plugin_dir).unwrap(); write_python_dynamic_manifest(&plugin_dir, "acme.python-retry"); let runner = FakePythonEnvironmentRunner::default(); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add_with_environment_runner( PluginsAddCommand { scope: PluginsScopeArgs { @@ -2470,7 +2471,7 @@ fn active_dynamic_plugin_components_project_enabled_native_records_only() { let plugin_dir = temp.path().join("plugins").join("native"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_native_dynamic_manifest(&plugin_dir, "acme.native"); - let server = crate::configuration::ServerArgs::default(); + let server = crate::configuration::GatewayOverrides::default(); add( PluginsAddCommand { @@ -2517,7 +2518,7 @@ fn active_dynamic_plugin_components_accept_enabled_worker_records() { let plugin_dir = temp.path().join("plugins").join("worker"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.worker"); - let server = crate::configuration::ServerArgs::default(); + let server = crate::configuration::GatewayOverrides::default(); add( PluginsAddCommand { @@ -2560,7 +2561,7 @@ fn active_dynamic_plugin_components_accept_worker_records_without_manifest_ref() let plugin_dir = temp.path().join("plugins").join("worker"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.worker"); - let server = crate::configuration::ServerArgs::default(); + let server = crate::configuration::GatewayOverrides::default(); add( PluginsAddCommand { @@ -2622,7 +2623,7 @@ fn add_rejects_duplicate_dynamic_plugin_ids() { }, path: plugin_dir.clone(), }, - &crate::configuration::ServerArgs::default(), + &crate::configuration::GatewayOverrides::default(), ) .unwrap(); @@ -2634,7 +2635,7 @@ fn add_rejects_duplicate_dynamic_plugin_ids() { }, path: plugin_dir, }, - &crate::configuration::ServerArgs::default(), + &crate::configuration::GatewayOverrides::default(), ) .unwrap_err() .to_string(); @@ -2653,9 +2654,9 @@ fn add_rejects_scope_flags_when_explicit_config_is_set() { let config_path = config_dir.join("gateway.toml"); std::fs::write(&config_path, "").unwrap(); - let server = ServerArgs { + let server = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = add( @@ -2700,7 +2701,7 @@ allowed = false }, path: plugin_dir, }, - &crate::configuration::ServerArgs::default(), + &crate::configuration::GatewayOverrides::default(), ) .unwrap_err(); @@ -2774,7 +2775,7 @@ fn list_and_inspect_render_discovered_dynamic_plugins() { }, path: plugin_dir, }, - &crate::configuration::ServerArgs::default(), + &crate::configuration::GatewayOverrides::default(), ) .unwrap(); @@ -2846,7 +2847,7 @@ fn validate_renders_summary_for_path_and_id_targets() { }, path: plugin_dir, }, - &crate::configuration::ServerArgs::default(), + &crate::configuration::GatewayOverrides::default(), ) .unwrap(); @@ -2895,7 +2896,7 @@ fn validate_renders_summary_for_path_and_id_targets() { target: "missing.plugin".into(), json: false, }, - &crate::configuration::ServerArgs::default(), + &crate::configuration::GatewayOverrides::default(), ) .unwrap_err() .to_string(); @@ -2906,7 +2907,7 @@ fn validate_renders_summary_for_path_and_id_targets() { id: "missing.plugin".into(), json: false, }, - &crate::configuration::ServerArgs::default(), + &crate::configuration::GatewayOverrides::default(), ) .unwrap_err() .to_string(); @@ -2915,7 +2916,7 @@ fn validate_renders_summary_for_path_and_id_targets() { assert_eq!( list( PluginsListCommand::default(), - &crate::configuration::ServerArgs::default() + &crate::configuration::GatewayOverrides::default() ) .unwrap(), () @@ -2930,7 +2931,7 @@ fn enable_disable_and_remove_persist_lifecycle_state() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.guardrail"); - let server = crate::configuration::ServerArgs::default(); + let server = crate::configuration::GatewayOverrides::default(); add( PluginsAddCommand { @@ -3027,9 +3028,9 @@ fn add_with_explicit_config_uses_sibling_plugins_and_state_files() { let config_path = config_dir.join("gateway.toml"); std::fs::write(&config_path, "").unwrap(); - let server = ServerArgs { + let server = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; add( @@ -3144,7 +3145,7 @@ fn manually_configured_python_worker_cannot_enable_without_lifecycle_add() { PluginsEnableCommand { id: "acme.python-direct".into(), }, - &ServerArgs::default(), + &GatewayOverrides::default(), ) .expect_err("manually configured Python workers must not activate"); let (_, _, kind, code, message) = error @@ -3251,7 +3252,7 @@ fn hydrate_persists_updated_policy_and_error_state() { }, path: plugin_dir.clone(), }, - &ServerArgs::default(), + &GatewayOverrides::default(), ) .unwrap(); @@ -3516,7 +3517,7 @@ fn enable_refuses_dynamic_plugins_blocked_by_host_policy_and_persists_status() { std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.enable-blocked"); - let server = crate::configuration::ServerArgs::default(); + let server = crate::configuration::GatewayOverrides::default(); add( PluginsAddCommand { @@ -3597,7 +3598,7 @@ fn disable_succeeds_when_registered_plugin_manifest_is_unreadable() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.guardrail"); - let server = crate::configuration::ServerArgs::default(); + let server = crate::configuration::GatewayOverrides::default(); add( PluginsAddCommand { @@ -3646,7 +3647,7 @@ fn validate_marks_registered_plugins_invalid_when_host_policy_blocks_them() { std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.validate-blocked"); - let server = crate::configuration::ServerArgs::default(); + let server = crate::configuration::GatewayOverrides::default(); add( PluginsAddCommand { @@ -3789,7 +3790,7 @@ fn add_can_revive_tombstoned_records() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.revive"); - let server = crate::configuration::ServerArgs::default(); + let server = crate::configuration::GatewayOverrides::default(); add( PluginsAddCommand { @@ -3840,7 +3841,7 @@ fn json_helpers_emit_stable_success_and_failure_shapes() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.json"); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add( PluginsAddCommand { @@ -3974,7 +3975,7 @@ fn remove_tolerates_unreadable_non_target_manifest_entries() { std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&broken_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.guardrail"); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add( PluginsAddCommand { @@ -4101,7 +4102,7 @@ fn inspect_redacts_host_config_values() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.redacted"); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add( PluginsAddCommand { @@ -4181,7 +4182,7 @@ fn inspect_distinguishes_empty_host_config_from_missing_host_config() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.empty-config"); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add( PluginsAddCommand { From 13952d78e7ea3f041e78cfd8772a16a4eb90642c Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 21:33:24 -0400 Subject: [PATCH 025/102] refactor(cli): move descriptors into agent slices Signed-off-by: Will Killian --- crates/cli/src/agents/claude/mod.rs | 37 +++++++++ crates/cli/src/agents/codex/mod.rs | 33 ++++++++ crates/cli/src/agents/hermes/mod.rs | 39 +++++++++ crates/cli/src/agents/mod.rs | 121 +++------------------------- 4 files changed, 120 insertions(+), 110 deletions(-) create mode 100644 crates/cli/src/agents/claude/mod.rs create mode 100644 crates/cli/src/agents/codex/mod.rs diff --git a/crates/cli/src/agents/claude/mod.rs b/crates/cli/src/agents/claude/mod.rs new file mode 100644 index 000000000..949d751e3 --- /dev/null +++ b/crates/cli/src/agents/claude/mod.rs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use semver::Version; + +use super::AgentDescriptor; + +pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { + argument: "claude", + install_argument: "claude-code", + label: "Claude Code", + executable: "claude", + hook_path: "/hooks/claude-code", + version_product: "Claude Code", + minimum_version: (2, 1, 121), + hook_events: &[ + "SessionStart", + "UserPromptSubmit", + "UserPromptExpansion", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PermissionRequest", + "SubagentStart", + "SubagentStop", + "Notification", + "Stop", + "PreCompact", + "PostCompact", + "SessionEnd", + ], + direct_hook_entries: false, +}; + +pub(super) fn parse_version(raw: &str) -> Option { + Version::parse(raw.strip_suffix(" (Claude Code)")?).ok() +} diff --git a/crates/cli/src/agents/codex/mod.rs b/crates/cli/src/agents/codex/mod.rs new file mode 100644 index 000000000..f991bd833 --- /dev/null +++ b/crates/cli/src/agents/codex/mod.rs @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use semver::Version; + +use super::AgentDescriptor; + +pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { + argument: "codex", + install_argument: "codex", + label: "Codex", + executable: "codex", + hook_path: "/hooks/codex", + version_product: "codex-cli", + minimum_version: (0, 143, 0), + hook_events: &[ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "SubagentStart", + "SubagentStop", + "Stop", + "PreCompact", + "PostCompact", + ], + direct_hook_entries: false, +}; + +pub(super) fn parse_version(raw: &str) -> Option { + Version::parse(raw.strip_prefix("codex-cli ")?).ok() +} diff --git a/crates/cli/src/agents/hermes/mod.rs b/crates/cli/src/agents/hermes/mod.rs index 527f37b8c..0aec233fa 100644 --- a/crates/cli/src/agents/hermes/mod.rs +++ b/crates/cli/src/agents/hermes/mod.rs @@ -1,6 +1,45 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use semver::Version; + +use super::AgentDescriptor; + +pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { + argument: "hermes", + install_argument: "hermes", + label: "Hermes Agent", + executable: "hermes", + hook_path: "/hooks/hermes", + version_product: "Hermes Agent", + minimum_version: (0, 18, 2), + hook_events: &[ + "on_session_start", + "on_session_end", + "on_session_finalize", + "on_session_reset", + "pre_llm_call", + "post_llm_call", + "pre_api_request", + "post_api_request", + "api_request_error", + "pre_tool_call", + "post_tool_call", + "subagent_start", + "subagent_stop", + ], + direct_hook_entries: true, +}; + +pub(super) fn parse_version(raw: &str) -> Option { + Version::parse( + raw.strip_prefix("Hermes Agent v")? + .split_whitespace() + .next()?, + ) + .ok() +} + mod config; mod files; mod integration; diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 46bb3cfcd..ddc938013 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -3,6 +3,8 @@ //! Canonical coding-agent identity and compatibility policy. +pub(crate) mod claude; +pub(crate) mod codex; pub(crate) mod hermes; pub(crate) mod host; pub(crate) mod install; @@ -20,7 +22,7 @@ pub(crate) enum CodingAgent { } #[derive(Debug, Clone, Copy)] -struct AgentDescriptor { +pub(super) struct AgentDescriptor { argument: &'static str, install_argument: &'static str, label: &'static str, @@ -28,114 +30,18 @@ struct AgentDescriptor { hook_path: &'static str, version_product: &'static str, minimum_version: (u64, u64, u64), - version_format: VersionFormat, hook_events: &'static [&'static str], direct_hook_entries: bool, } -#[derive(Debug, Clone, Copy)] -enum VersionFormat { - Codex, - ClaudeCode, - Hermes, -} - -// Claude Code validates plugin hooks.json against a strict event-name whitelist. Every event in -// this descriptor must therefore exist in the prescribed minimum Claude Code release. -const CLAUDE_CODE_HOOK_EVENTS: &[&str] = &[ - "SessionStart", - "UserPromptSubmit", - "UserPromptExpansion", - "PreToolUse", - "PostToolUse", - "PostToolUseFailure", - "PermissionRequest", - "SubagentStart", - "SubagentStop", - "Notification", - "Stop", - "PreCompact", - "PostCompact", - "SessionEnd", -]; - -const CODEX_HOOK_EVENTS: &[&str] = &[ - "SessionStart", - "UserPromptSubmit", - "PreToolUse", - "PostToolUse", - "PermissionRequest", - "SubagentStart", - "SubagentStop", - "Stop", - "PreCompact", - "PostCompact", -]; - -const HERMES_HOOK_EVENTS: &[&str] = &[ - "on_session_start", - "on_session_end", - "on_session_finalize", - "on_session_reset", - "pre_llm_call", - "post_llm_call", - "pre_api_request", - "post_api_request", - // Observer-only failure telemetry closes failed provider attempts. - "api_request_error", - "pre_tool_call", - "post_tool_call", - "subagent_start", - "subagent_stop", -]; - -const CLAUDE_CODE: AgentDescriptor = AgentDescriptor { - argument: "claude", - install_argument: "claude-code", - label: "Claude Code", - executable: "claude", - hook_path: "/hooks/claude-code", - version_product: "Claude Code", - minimum_version: (2, 1, 121), - version_format: VersionFormat::ClaudeCode, - hook_events: CLAUDE_CODE_HOOK_EVENTS, - direct_hook_entries: false, -}; - -const CODEX: AgentDescriptor = AgentDescriptor { - argument: "codex", - install_argument: "codex", - label: "Codex", - executable: "codex", - hook_path: "/hooks/codex", - version_product: "codex-cli", - minimum_version: (0, 143, 0), - version_format: VersionFormat::Codex, - hook_events: CODEX_HOOK_EVENTS, - direct_hook_entries: false, -}; - -const HERMES: AgentDescriptor = AgentDescriptor { - argument: "hermes", - install_argument: "hermes", - label: "Hermes Agent", - executable: "hermes", - hook_path: "/hooks/hermes", - version_product: "Hermes Agent", - minimum_version: (0, 18, 2), - version_format: VersionFormat::Hermes, - hook_events: HERMES_HOOK_EVENTS, - direct_hook_entries: true, -}; - impl CodingAgent { pub(crate) const ALL: [Self; 3] = [Self::ClaudeCode, Self::Codex, Self::Hermes]; const fn descriptor(self) -> AgentDescriptor { match self { - Self::ClaudeCode => CLAUDE_CODE, - Self::Codex => CODEX, - Self::Hermes => HERMES, + Self::ClaudeCode => claude::DESCRIPTOR, + Self::Codex => codex::DESCRIPTOR, + Self::Hermes => hermes::DESCRIPTOR, } } @@ -210,16 +116,11 @@ impl CodingAgent { } fn parse_version(self, raw: &str) -> Option { - let descriptor = self.descriptor(); - let token = match descriptor.version_format { - VersionFormat::Codex => raw.strip_prefix("codex-cli ")?, - VersionFormat::ClaudeCode => raw.strip_suffix(" (Claude Code)")?, - VersionFormat::Hermes => raw - .strip_prefix("Hermes Agent v")? - .split_whitespace() - .next()?, - }; - Version::parse(token).ok() + match self { + Self::ClaudeCode => claude::parse_version(raw), + Self::Codex => codex::parse_version(raw), + Self::Hermes => hermes::parse_version(raw), + } } /// Infers a host from an executable basename. From b3bd6fded29c1b59d359a86b68e48c5b4c91ab8b Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 21:35:22 -0400 Subject: [PATCH 026/102] refactor(cli): isolate plugin lifecycle rendering Signed-off-by: Will Killian --- crates/cli/src/plugins/lifecycle.rs | 399 +------------------- crates/cli/src/plugins/lifecycle/render.rs | 405 +++++++++++++++++++++ 2 files changed, 408 insertions(+), 396 deletions(-) create mode 100644 crates/cli/src/plugins/lifecycle/render.rs diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index 6839fdbc0..2ce70ab59 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -34,6 +34,7 @@ use super::config_io::{ use super::schema::PluginConfigSchema; mod environment; +mod render; mod responses; mod state; mod target; @@ -45,6 +46,8 @@ use self::environment::{ read_environment_attestation, remove_managed_environment, validate_python_entrypoint_artifact, verify_environment_attestation, }; +use self::render::*; +pub(crate) use self::render::{render_generic_plugin_json_error, render_plugin_error}; use self::responses::{ ValidateResponseInput, failure, generic_failure, inspect_data, inspect_success, list_success, print_response_json, validate_success, @@ -2238,402 +2241,6 @@ fn required_startup_failure( None } -pub(crate) fn render_plugin_error( - error: &CliError, - json: bool, -) -> Result, CliError> { - let Some((command, target, kind, code, message)) = error.as_plugin_lifecycle_error_context() - else { - return Ok(None); - }; - - let exit_code = match kind { - PluginLifecycleFailureKind::Failed => ExitCode::from(1), - PluginLifecycleFailureKind::NotFound => ExitCode::from(2), - PluginLifecycleFailureKind::Refused => ExitCode::from(3), - }; - - if json { - print_response_json(&failure(command, target, kind, code, message))?; - } else { - eprintln!("{message}"); - } - Ok(Some(exit_code)) -} - -pub(crate) fn render_generic_plugin_json_error( - command: &'static str, - target: Option<&str>, - message: &str, -) -> Result { - print_response_json(&generic_failure(command, target, message))?; - Ok(ExitCode::from(1)) -} - -fn plugin_not_found( - command: &'static str, - target: Option, - message: impl Into, -) -> CliError { - CliError::PluginLifecycle { - command, - target, - kind: PluginLifecycleFailureKind::NotFound, - code: None, - message: message.into(), - } -} - -fn plugin_refused( - command: &'static str, - target: Option, - message: impl Into, -) -> CliError { - plugin_refused_with_code(command, target, "refused", message) -} - -fn plugin_refused_with_code( - command: &'static str, - target: Option, - code: &'static str, - message: impl Into, -) -> CliError { - CliError::PluginLifecycle { - command, - target, - kind: PluginLifecycleFailureKind::Refused, - code: Some(code), - message: message.into(), - } -} - -fn plugin_failed_with_code( - command: &'static str, - target: Option, - code: &'static str, - message: impl Into, -) -> CliError { - CliError::PluginLifecycle { - command, - target, - kind: PluginLifecycleFailureKind::Failed, - code: Some(code), - message: message.into(), - } -} - -fn trust_refusal_code(trust: &EvaluatedDynamicPluginTrust) -> &'static str { - trust.refusal_code().unwrap_or("refused") -} - -fn list_validation_state(record: &DynamicPluginRecord) -> DynamicPluginCheckState { - let validation = &record.status.validation; - if validation.manifest == DynamicPluginCheckState::Invalid - || validation.compatibility == DynamicPluginCheckState::Invalid - || validation.integrity == DynamicPluginCheckState::Invalid - || validation.environment == DynamicPluginCheckState::Invalid - || validation.authenticity == DynamicPluginCheckState::Invalid - || validation.policy_satisfied == DynamicPluginCheckState::Invalid - { - DynamicPluginCheckState::Invalid - } else if validation.manifest == DynamicPluginCheckState::Unknown - || validation.compatibility == DynamicPluginCheckState::Unknown - { - DynamicPluginCheckState::Unknown - } else { - DynamicPluginCheckState::Valid - } -} - -struct PluginListView<'a> { - records: &'a [ScopedDynamicPluginRecord], - host_config_by_id: &'a HashMap, -} - -impl fmt::Display for PluginListView<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let widths = PluginListWidths::from_records(self.records); - - write!( - f, - "{: Self { - Self { - id: column_width( - "ID", - records - .iter() - .map(|entry| entry.record.metadata.id.as_str()), - ), - scope: column_width( - "SCOPE", - records.iter().map(|entry| { - let scope: &'static str = entry.scope.into(); - scope - }), - ), - enabled: column_width( - "ENABLED", - records.iter().map(|entry| { - if entry.record.spec.enabled { - "true" - } else { - "false" - } - }), - ), - state: column_width( - "STATE", - records - .iter() - .map(|entry| lifecycle_state_label(&entry.record)), - ), - validation: column_width( - "VALIDATION", - records.iter().map(|entry| { - let validation: &'static str = list_validation_state(&entry.record).into(); - validation - }), - ), - policy: column_width( - "POLICY", - records.iter().map(|entry| { - let policy: &'static str = - entry.record.status.validation.policy_satisfied.into(); - policy - }), - ), - } - } -} - -fn column_width<'a>(header: &'static str, values: impl Iterator) -> usize { - values - .map(str::len) - .chain(std::iter::once(header.len())) - .max() - .unwrap_or(header.len()) -} - -struct PluginInspectView<'a> { - entry: &'a ScopedDynamicPluginRecord, - manifest: &'a DynamicPluginManifest, - manifest_ref: &'a str, - host_config: Option<&'a ResolvedDynamicPluginConfig>, -} - -impl fmt::Display for PluginInspectView<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let view = inspect_data( - self.entry, - self.manifest, - self.manifest_ref, - self.host_config, - ); - let yaml = serde_yaml::to_string(&view).map_err(|_| fmt::Error)?; - write!(f, "{}", yaml.trim_end()) - } -} - -struct PluginValidationSummaryView<'a> { - manifest: &'a DynamicPluginManifest, - manifest_ref: &'a str, - entry: Option<&'a ScopedDynamicPluginRecord>, - host_config: Option<&'a ResolvedDynamicPluginConfig>, - policy: &'a EvaluatedDynamicPluginHostPolicy, - trust: &'a EvaluatedDynamicPluginTrust, -} - -impl fmt::Display for PluginValidationSummaryView<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let environment = self - .entry - .map(|entry| entry.record.status.validation.environment) - .unwrap_or(DynamicPluginCheckState::Unknown); - if self.policy.policy_satisfied - && self.trust.is_satisfied() - && environment != DynamicPluginCheckState::Invalid - { - writeln!(f, "Dynamic plugin '{}' is valid.", self.manifest.plugin.id)?; - } else if self.policy.policy_satisfied - && self.trust.is_satisfied() - && environment == DynamicPluginCheckState::Invalid - { - writeln!( - f, - "Dynamic plugin '{}' manifest is valid, but its runtime environment is unavailable.", - self.manifest.plugin.id - )?; - } else if self.policy.policy_satisfied { - writeln!( - f, - "Dynamic plugin '{}' manifest is valid, but trust verification blocks it.", - self.manifest.plugin.id - )?; - } else { - writeln!( - f, - "Dynamic plugin '{}' manifest is valid, but host policy blocks it.", - self.manifest.plugin.id - )?; - } - writeln!(f, "kind: {}", self.manifest.plugin.kind)?; - writeln!( - f, - "policy_state: {}", - <&'static str>::from(self.policy.check_state()) - )?; - writeln!( - f, - "integrity_state: {}", - <&'static str>::from(self.trust.integrity) - )?; - writeln!( - f, - "environment_state: {}", - <&'static str>::from(environment) - )?; - writeln!( - f, - "authenticity_state: {}", - <&'static str>::from(self.trust.authenticity) - )?; - writeln!(f, "startup_class: {}", self.policy.startup_class)?; - writeln!(f, "attestation_mode: {}", self.policy.attestation_mode)?; - if let Some(failure) = self.policy.failure() { - writeln!( - f, - "policy_error: {}", - failure.display(&self.manifest.plugin.id) - )?; - } - if let Some(failure) = self.trust.failure() { - writeln!( - f, - "trust_error: {}", - failure.display(&self.manifest.plugin.id) - )?; - } - if let Some(entry) = self.entry { - writeln!(f, "manifest: {}", self.manifest_ref)?; - writeln!(f, "scope: {}", entry.scope)?; - writeln!(f, "lifecycle_state_path: {}", entry.state_path.display())?; - writeln!(f, "desired.enabled: {}", entry.record.spec.enabled)?; - write!(f, "host_config: {}", host_config_label(self.host_config))?; - } else { - write!(f, "manifest: {}", self.manifest_ref)?; - } - Ok(()) - } -} - -fn lifecycle_state_label(record: &DynamicPluginRecord) -> &'static str { - if record.is_tombstoned() { - "tombstoned" - } else { - record.status.runtime.state.into() - } -} - -fn host_config_label(host_config: Option<&ResolvedDynamicPluginConfig>) -> &'static str { - host_config - .map(|plugin| { - let status: &'static str = plugin.host_config_status().into(); - status - }) - .unwrap_or("absent") -} - -fn redacted_host_config_json(host_config: &ResolvedDynamicPluginConfig) -> Value { - if host_config.config.is_empty() && !host_config.has_explicit_config { - return Value::Null; - } - - Value::Object( - host_config - .config - .keys() - .cloned() - .map(|key| (key, Value::String("".into()))) - .collect(), - ) -} - -pub(super) fn inspect_load_data(record: &DynamicPluginRecord) -> Value { - match &record.load { - DynamicPluginLoadContract::Worker(load) => serde_json::json!({ - "runtime": load.runtime, - "entrypoint": load.entrypoint, - }), - DynamicPluginLoadContract::RustDynamic(load) => serde_json::json!({ - "library": load.library, - "symbol": load.symbol, - }), - } -} - -pub(super) fn inspect_compat_data(record: &DynamicPluginRecord) -> Value { - match &record.compatibility { - DynamicPluginCompatibility::Worker(compatibility) => serde_json::json!({ - "relay": compatibility.relay, - "worker_protocol": compatibility.worker_protocol, - }), - DynamicPluginCompatibility::RustDynamic(compatibility) => serde_json::json!({ - "relay": compatibility.relay, - "native_api": compatibility.native_api, - }), - } -} - #[cfg(test)] #[path = "../../tests/coverage/shared/plugins_lifecycle_tests.rs"] mod tests; diff --git a/crates/cli/src/plugins/lifecycle/render.rs b/crates/cli/src/plugins/lifecycle/render.rs new file mode 100644 index 000000000..4ffabd95e --- /dev/null +++ b/crates/cli/src/plugins/lifecycle/render.rs @@ -0,0 +1,405 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Human and machine-readable lifecycle result rendering. + +use super::*; + +pub(crate) fn render_plugin_error( + error: &CliError, + json: bool, +) -> Result, CliError> { + let Some((command, target, kind, code, message)) = error.as_plugin_lifecycle_error_context() + else { + return Ok(None); + }; + + let exit_code = match kind { + PluginLifecycleFailureKind::Failed => ExitCode::from(1), + PluginLifecycleFailureKind::NotFound => ExitCode::from(2), + PluginLifecycleFailureKind::Refused => ExitCode::from(3), + }; + + if json { + print_response_json(&failure(command, target, kind, code, message))?; + } else { + eprintln!("{message}"); + } + Ok(Some(exit_code)) +} + +pub(crate) fn render_generic_plugin_json_error( + command: &'static str, + target: Option<&str>, + message: &str, +) -> Result { + print_response_json(&generic_failure(command, target, message))?; + Ok(ExitCode::from(1)) +} + +pub(super) fn plugin_not_found( + command: &'static str, + target: Option, + message: impl Into, +) -> CliError { + CliError::PluginLifecycle { + command, + target, + kind: PluginLifecycleFailureKind::NotFound, + code: None, + message: message.into(), + } +} + +pub(super) fn plugin_refused( + command: &'static str, + target: Option, + message: impl Into, +) -> CliError { + plugin_refused_with_code(command, target, "refused", message) +} + +pub(super) fn plugin_refused_with_code( + command: &'static str, + target: Option, + code: &'static str, + message: impl Into, +) -> CliError { + CliError::PluginLifecycle { + command, + target, + kind: PluginLifecycleFailureKind::Refused, + code: Some(code), + message: message.into(), + } +} + +pub(super) fn plugin_failed_with_code( + command: &'static str, + target: Option, + code: &'static str, + message: impl Into, +) -> CliError { + CliError::PluginLifecycle { + command, + target, + kind: PluginLifecycleFailureKind::Failed, + code: Some(code), + message: message.into(), + } +} + +pub(super) fn trust_refusal_code(trust: &EvaluatedDynamicPluginTrust) -> &'static str { + trust.refusal_code().unwrap_or("refused") +} + +pub(super) fn list_validation_state(record: &DynamicPluginRecord) -> DynamicPluginCheckState { + let validation = &record.status.validation; + if validation.manifest == DynamicPluginCheckState::Invalid + || validation.compatibility == DynamicPluginCheckState::Invalid + || validation.integrity == DynamicPluginCheckState::Invalid + || validation.environment == DynamicPluginCheckState::Invalid + || validation.authenticity == DynamicPluginCheckState::Invalid + || validation.policy_satisfied == DynamicPluginCheckState::Invalid + { + DynamicPluginCheckState::Invalid + } else if validation.manifest == DynamicPluginCheckState::Unknown + || validation.compatibility == DynamicPluginCheckState::Unknown + { + DynamicPluginCheckState::Unknown + } else { + DynamicPluginCheckState::Valid + } +} + +pub(super) struct PluginListView<'a> { + pub(super) records: &'a [ScopedDynamicPluginRecord], + pub(super) host_config_by_id: &'a HashMap, +} + +impl fmt::Display for PluginListView<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let widths = PluginListWidths::from_records(self.records); + + write!( + f, + "{: Self { + Self { + id: column_width( + "ID", + records + .iter() + .map(|entry| entry.record.metadata.id.as_str()), + ), + scope: column_width( + "SCOPE", + records.iter().map(|entry| { + let scope: &'static str = entry.scope.into(); + scope + }), + ), + enabled: column_width( + "ENABLED", + records.iter().map(|entry| { + if entry.record.spec.enabled { + "true" + } else { + "false" + } + }), + ), + state: column_width( + "STATE", + records + .iter() + .map(|entry| lifecycle_state_label(&entry.record)), + ), + validation: column_width( + "VALIDATION", + records.iter().map(|entry| { + let validation: &'static str = list_validation_state(&entry.record).into(); + validation + }), + ), + policy: column_width( + "POLICY", + records.iter().map(|entry| { + let policy: &'static str = + entry.record.status.validation.policy_satisfied.into(); + policy + }), + ), + } + } +} + +pub(super) fn column_width<'a>( + header: &'static str, + values: impl Iterator, +) -> usize { + values + .map(str::len) + .chain(std::iter::once(header.len())) + .max() + .unwrap_or(header.len()) +} + +pub(super) struct PluginInspectView<'a> { + pub(super) entry: &'a ScopedDynamicPluginRecord, + pub(super) manifest: &'a DynamicPluginManifest, + pub(super) manifest_ref: &'a str, + pub(super) host_config: Option<&'a ResolvedDynamicPluginConfig>, +} + +impl fmt::Display for PluginInspectView<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let view = inspect_data( + self.entry, + self.manifest, + self.manifest_ref, + self.host_config, + ); + let yaml = serde_yaml::to_string(&view).map_err(|_| fmt::Error)?; + write!(f, "{}", yaml.trim_end()) + } +} + +pub(super) struct PluginValidationSummaryView<'a> { + pub(super) manifest: &'a DynamicPluginManifest, + pub(super) manifest_ref: &'a str, + pub(super) entry: Option<&'a ScopedDynamicPluginRecord>, + pub(super) host_config: Option<&'a ResolvedDynamicPluginConfig>, + pub(super) policy: &'a EvaluatedDynamicPluginHostPolicy, + pub(super) trust: &'a EvaluatedDynamicPluginTrust, +} + +impl fmt::Display for PluginValidationSummaryView<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let environment = self + .entry + .map(|entry| entry.record.status.validation.environment) + .unwrap_or(DynamicPluginCheckState::Unknown); + if self.policy.policy_satisfied + && self.trust.is_satisfied() + && environment != DynamicPluginCheckState::Invalid + { + writeln!(f, "Dynamic plugin '{}' is valid.", self.manifest.plugin.id)?; + } else if self.policy.policy_satisfied + && self.trust.is_satisfied() + && environment == DynamicPluginCheckState::Invalid + { + writeln!( + f, + "Dynamic plugin '{}' manifest is valid, but its runtime environment is unavailable.", + self.manifest.plugin.id + )?; + } else if self.policy.policy_satisfied { + writeln!( + f, + "Dynamic plugin '{}' manifest is valid, but trust verification blocks it.", + self.manifest.plugin.id + )?; + } else { + writeln!( + f, + "Dynamic plugin '{}' manifest is valid, but host policy blocks it.", + self.manifest.plugin.id + )?; + } + writeln!(f, "kind: {}", self.manifest.plugin.kind)?; + writeln!( + f, + "policy_state: {}", + <&'static str>::from(self.policy.check_state()) + )?; + writeln!( + f, + "integrity_state: {}", + <&'static str>::from(self.trust.integrity) + )?; + writeln!( + f, + "environment_state: {}", + <&'static str>::from(environment) + )?; + writeln!( + f, + "authenticity_state: {}", + <&'static str>::from(self.trust.authenticity) + )?; + writeln!(f, "startup_class: {}", self.policy.startup_class)?; + writeln!(f, "attestation_mode: {}", self.policy.attestation_mode)?; + if let Some(failure) = self.policy.failure() { + writeln!( + f, + "policy_error: {}", + failure.display(&self.manifest.plugin.id) + )?; + } + if let Some(failure) = self.trust.failure() { + writeln!( + f, + "trust_error: {}", + failure.display(&self.manifest.plugin.id) + )?; + } + if let Some(entry) = self.entry { + writeln!(f, "manifest: {}", self.manifest_ref)?; + writeln!(f, "scope: {}", entry.scope)?; + writeln!(f, "lifecycle_state_path: {}", entry.state_path.display())?; + writeln!(f, "desired.enabled: {}", entry.record.spec.enabled)?; + write!(f, "host_config: {}", host_config_label(self.host_config))?; + } else { + write!(f, "manifest: {}", self.manifest_ref)?; + } + Ok(()) + } +} + +pub(super) fn lifecycle_state_label(record: &DynamicPluginRecord) -> &'static str { + if record.is_tombstoned() { + "tombstoned" + } else { + record.status.runtime.state.into() + } +} + +pub(super) fn host_config_label(host_config: Option<&ResolvedDynamicPluginConfig>) -> &'static str { + host_config + .map(|plugin| { + let status: &'static str = plugin.host_config_status().into(); + status + }) + .unwrap_or("absent") +} + +pub(super) fn redacted_host_config_json(host_config: &ResolvedDynamicPluginConfig) -> Value { + if host_config.config.is_empty() && !host_config.has_explicit_config { + return Value::Null; + } + + Value::Object( + host_config + .config + .keys() + .cloned() + .map(|key| (key, Value::String("".into()))) + .collect(), + ) +} + +pub(super) fn inspect_load_data(record: &DynamicPluginRecord) -> Value { + match &record.load { + DynamicPluginLoadContract::Worker(load) => serde_json::json!({ + "runtime": load.runtime, + "entrypoint": load.entrypoint, + }), + DynamicPluginLoadContract::RustDynamic(load) => serde_json::json!({ + "library": load.library, + "symbol": load.symbol, + }), + } +} + +pub(super) fn inspect_compat_data(record: &DynamicPluginRecord) -> Value { + match &record.compatibility { + DynamicPluginCompatibility::Worker(compatibility) => serde_json::json!({ + "relay": compatibility.relay, + "worker_protocol": compatibility.worker_protocol, + }), + DynamicPluginCompatibility::RustDynamic(compatibility) => serde_json::json!({ + "relay": compatibility.relay, + "native_api": compatibility.native_api, + }), + } +} From baea3069e84f5745cd2a6866f347000f60ae6b20 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 21:37:16 -0400 Subject: [PATCH 027/102] refactor(cli): split gateway routing and responses Signed-off-by: Will Killian --- crates/cli/src/gateway/mod.rs | 302 ++--------------------------- crates/cli/src/gateway/response.rs | 94 +++++++++ crates/cli/src/gateway/routes.rs | 209 ++++++++++++++++++++ 3 files changed, 314 insertions(+), 291 deletions(-) create mode 100644 crates/cli/src/gateway/response.rs create mode 100644 crates/cli/src/gateway/routes.rs diff --git a/crates/cli/src/gateway/mod.rs b/crates/cli/src/gateway/mod.rs index 9fc4671ff..95a33b144 100644 --- a/crates/cli/src/gateway/mod.rs +++ b/crates/cli/src/gateway/mod.rs @@ -1,6 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +pub(crate) mod client; +mod response; +mod routes; + +use response::*; +use routes::*; + use std::error::Error; use std::sync::{Arc, Mutex}; @@ -32,6 +39,10 @@ use crate::error::CliError; use crate::server::AppState; use crate::sessions::{GatewayCallPrep, GatewaySessionFinish, LlmGatewayStart, SessionManager}; +#[cfg(test)] +#[path = "../../tests/coverage/shared/gateway_tests.rs"] +mod tests; + /// Proxies supported LLM API requests through NeMo Relay's managed execution pipeline. /// /// The gateway buffers the inbound body once, opens a managed LLM call against the resolved @@ -998,294 +1009,3 @@ pub(crate) async fn models( let bytes = upstream_response.bytes().await?; build_response(status, headers, Body::from(bytes)) } - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ProviderRoute { - OpenAiResponses, - OpenAiChatCompletions, - OpenAiModels, - AnthropicMessages, - AnthropicCountTokens, -} - -#[derive(Clone, Copy)] -struct ProviderForwarding { - route: ProviderRoute, - allow_environment_provider_auth: bool, -} - -impl ProviderForwarding { - fn new(route: ProviderRoute, allow_environment_provider_auth: bool) -> Self { - Self { - route, - allow_environment_provider_auth, - } - } -} - -impl ProviderRoute { - // Maps public gateway paths to known upstream provider routes. Unsupported paths return `None` - // so the caller can fail as a bad hook/gateway payload instead of constructing arbitrary URLs. - fn from_path(path: &str) -> Option { - match path { - "/responses" => Some(Self::OpenAiResponses), - "/v1/responses" => Some(Self::OpenAiResponses), - "/chat/completions" => Some(Self::OpenAiChatCompletions), - "/v1/chat/completions" => Some(Self::OpenAiChatCompletions), - "/models" => Some(Self::OpenAiModels), - "/v1/models" => Some(Self::OpenAiModels), - "/v1/messages" => Some(Self::AnthropicMessages), - "/v1/messages/count_tokens" => Some(Self::AnthropicCountTokens), - _ => None, - } - } - - const fn provider_surface(self) -> Option { - match self { - Self::OpenAiResponses => Some(ProviderSurface::OpenAIResponses), - Self::OpenAiChatCompletions => Some(ProviderSurface::OpenAIChat), - Self::AnthropicMessages => Some(ProviderSurface::AnthropicMessages), - Self::AnthropicCountTokens | Self::OpenAiModels => None, - } - } - - // Returns the provider route name recorded on managed LLM events. These names split OpenAI API - // variants because their request/response schemas differ even when they share a base URL, and - // they double as codec hints for ambiguous provider request shapes. - const fn name(self) -> &'static str { - self.alignment_route().name() - } - - // Builds the upstream URL by combining the configured provider base with the original path and - // query string. Trailing slashes are stripped from the base to avoid double-slash variants in - // configured enterprise or local proxy endpoints. - fn upstream_url( - self, - config: &crate::configuration::GatewayConfig, - path_and_query: &str, - ) -> String { - let base = match self { - Self::OpenAiResponses | Self::OpenAiChatCompletions | Self::OpenAiModels => { - config.openai_base_url.as_str() - } - Self::AnthropicMessages | Self::AnthropicCountTokens => { - config.anthropic_base_url.as_str() - } - }; - self.upstream_url_with_base(base, path_and_query) - } - - // Like `upstream_url` but with an explicit base URL. This keeps OpenAI `/v1` normalization in - // one place for configured public, enterprise, or local proxy bases. - fn upstream_url_with_base(self, base: &str, path_and_query: &str) -> String { - let base = base.trim_end_matches('/'); - let path_and_query = match self { - Self::OpenAiResponses | Self::OpenAiChatCompletions | Self::OpenAiModels => { - normalize_openai_path_for_base(base, path_and_query) - } - _ => path_and_query.to_string(), - }; - format!("{base}{path_and_query}") - } - - // Narrows gateway routing to the smaller taxonomy used by trace alignment. Keeping this - // conversion here prevents provider-specific alignment code from depending on gateway URL - // routing internals. - const fn alignment_route(self) -> GatewayRouteKind { - match self { - Self::OpenAiResponses => GatewayRouteKind::OpenAiResponses, - Self::OpenAiChatCompletions => GatewayRouteKind::OpenAiChatCompletions, - Self::OpenAiModels => GatewayRouteKind::OpenAiModels, - Self::AnthropicMessages => GatewayRouteKind::AnthropicMessages, - Self::AnthropicCountTokens => GatewayRouteKind::AnthropicCountTokens, - } - } -} - -fn normalize_openai_path_for_base(base: &str, path_and_query: &str) -> String { - match (base.ends_with("/v1"), path_and_query.starts_with("/v1/")) { - (true, true) => path_and_query - .strip_prefix("/v1") - .expect("path was checked to start with /v1") - .to_string(), - (false, false) => format!("/v1{path_and_query}"), - _ => path_and_query.to_string(), - } -} - -// Gives alignment adapters a chance to choose an agent-native upstream before default provider -// routing runs. Today this supports Codex ChatGPT auth; future harness fallbacks should stay in -// alignment rather than adding provider-shaped checks here. -fn gateway_upstream_url_override( - route: ProviderRoute, - headers: &HeaderMap, - path_and_query: &str, - allow_environment_provider_auth: bool, -) -> Option { - gateway_upstream_url_override_with_openai_key_state( - route, - headers, - path_and_query, - allow_environment_provider_auth && env_var_is_nonempty("OPENAI_API_KEY"), - ) -} - -fn gateway_upstream_url_override_with_openai_key_state( - route: ProviderRoute, - headers: &HeaderMap, - path_and_query: &str, - has_openai_replacement_key: bool, -) -> Option { - alignment::gateway_upstream_url_override( - headers, - route.alignment_route(), - path_and_query, - has_openai_replacement_key, - ) -} - -// Lets alignment adapters strip agent-native credentials only when the gateway can replace them -// with standard provider API keys. Whitespace-only env vars are treated as missing because -// forwarding an empty bearer value only replaces one authentication failure with another. -fn strip_replaceable_agent_auth_headers( - headers: &HeaderMap, - route: ProviderRoute, - allow_environment_provider_auth: bool, -) -> HeaderMap { - strip_replaceable_agent_auth_headers_with_openai_key_state( - headers, - route, - allow_environment_provider_auth && env_var_is_nonempty("OPENAI_API_KEY"), - ) -} - -fn strip_replaceable_agent_auth_headers_with_openai_key_state( - headers: &HeaderMap, - route: ProviderRoute, - has_openai_replacement_key: bool, -) -> HeaderMap { - alignment::gateway_forward_headers(headers, route.alignment_route(), has_openai_replacement_key) -} - -fn env_var_is_nonempty(name: &str) -> bool { - std::env::var(name) - .ok() - .filter(|value| !value.trim().is_empty()) - .is_some() -} - -// Delegates provider-specific session fallbacks to `alignment` so request construction stays -// generic and each coding-agent quirk has one documented adapter. -fn gateway_session_id(headers: &HeaderMap, body: &Value, route: ProviderRoute) -> Option { - alignment::gateway_session_id(headers, body, route.alignment_route()) -} - -fn gateway_subagent_id(headers: &HeaderMap) -> Option { - alignment::gateway_subagent_id(headers) -} - -// Keeps the gateway-facing helper local for tests while the generic extraction pattern lives in -// `alignment`. -fn gateway_identifier( - headers: &HeaderMap, - body: &Value, - header_name: &'static str, - body_paths: &[&[&str]], -) -> Option { - alignment::gateway_identifier(headers, body, header_name, body_paths) -} - -// Copies only non-sensitive, forwardable request headers into LLM request metadata. This preserves -// correlation headers while excluding credentials and hop-by-hop transport details. -fn observable_headers(headers: &HeaderMap) -> Map { - let mut output = Map::new(); - for (name, value) in headers { - if should_record_header(name) - && let Ok(value) = value.to_str() - { - output.insert(name.as_str().to_string(), json!(value)); - } - } - output -} - -// Copies upstream response headers except hop-by-hop transport headers that Axum/hyper must manage -// for the downstream connection. Multiple values are appended to preserve provider behavior. -// Content-Length is also dropped because the gateway re-encodes streaming responses and the -// upstream-reported length will not match the bytes the client sees. -fn response_headers(headers: &HeaderMap) -> HeaderMap { - let mut output = HeaderMap::new(); - for (name, value) in headers { - if !is_hop_by_hop(name) && name != http::header::CONTENT_LENGTH { - output.append(name.clone(), value.clone()); - } - } - output -} - -// Reconstructs an Axum response from upstream status, filtered headers, and the selected body. All -// builder errors are converted into gateway HTTP errors rather than panics. -fn build_response( - status: StatusCode, - headers: HeaderMap, - body: Body, -) -> Result, CliError> { - let mut builder = Response::builder().status(status); - for (name, value) in &headers { - builder = builder.header(name, value); - } - Ok(builder.body(body)?) -} - -// Allows provider request headers through unless they are transport-owned or must be recalculated -// for the forwarded body. Host and content length are intentionally excluded because reqwest sets -// them for the upstream connection. -fn should_forward_request_header(name: &HeaderName) -> bool { - !is_hop_by_hop(name) - && name != http::header::HOST - && name != http::header::CONTENT_LENGTH - && name.as_str() != BOOTSTRAP_CLIENT_TOKEN_HEADER - // Strip Accept-Encoding so upstreams return identity-encoded bodies; otherwise the - // observability capture (`output.value` on LLM spans, ATIF trajectory bodies) records - // gzip/br/zstd bytes that downstream consumers can't read. Bandwidth cost is paid only - // on the gateway-upstream hop. The client never asked for the encoding it would have - // received from upstream, so its decoders never trigger. - && name != http::header::ACCEPT_ENCODING -} - -// Allows headers into observability metadata only after removing credentials and provider API keys. -// The forwarding filter runs first so hop-by-hop transport headers are also excluded from recorded -// LLM request attributes. The credential blocklist covers the four canonical cases we see in -// practice: `Authorization` (most providers), `Cookie` (session credentials), `x-api-key` (OpenAI -// SDK and similar), `anthropic-api-key` (Anthropic), and the generic `api-key` alias used by some -// providers/proxies (e.g., Azure OpenAI). `HeaderName::as_str()` already returns the canonical -// lowercase form so string comparisons are case-insensitive by construction. -fn should_record_header(name: &HeaderName) -> bool { - should_forward_request_header(name) - && name != http::header::AUTHORIZATION - && name != http::header::COOKIE - && name.as_str() != "x-api-key" - && name.as_str() != "api-key" - && name.as_str() != "anthropic-api-key" -} - -// Identifies headers that describe a single transport hop and therefore must not be proxied across -// the client-gateway-upstream boundary. -fn is_hop_by_hop(name: &HeaderName) -> bool { - matches!( - name.as_str(), - "connection" - | "keep-alive" - | "proxy-authenticate" - | "proxy-authorization" - | "te" - | "trailer" - | "transfer-encoding" - | "upgrade" - ) -} - -pub(crate) mod client; -#[cfg(test)] -#[path = "../../tests/coverage/shared/gateway_tests.rs"] -mod tests; diff --git a/crates/cli/src/gateway/response.rs b/crates/cli/src/gateway/response.rs new file mode 100644 index 000000000..3dcbf8a51 --- /dev/null +++ b/crates/cli/src/gateway/response.rs @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Observable header policy and downstream response construction. + +use super::*; + +pub(super) fn observable_headers(headers: &HeaderMap) -> Map { + let mut output = Map::new(); + for (name, value) in headers { + if should_record_header(name) + && let Ok(value) = value.to_str() + { + output.insert(name.as_str().to_string(), json!(value)); + } + } + output +} + +// Copies upstream response headers except hop-by-hop transport headers that Axum/hyper must manage +// for the downstream connection. Multiple values are appended to preserve provider behavior. +// Content-Length is also dropped because the gateway re-encodes streaming responses and the +// upstream-reported length will not match the bytes the client sees. +pub(super) fn response_headers(headers: &HeaderMap) -> HeaderMap { + let mut output = HeaderMap::new(); + for (name, value) in headers { + if !is_hop_by_hop(name) && name != http::header::CONTENT_LENGTH { + output.append(name.clone(), value.clone()); + } + } + output +} + +// Reconstructs an Axum response from upstream status, filtered headers, and the selected body. All +// builder errors are converted into gateway HTTP errors rather than panics. +pub(super) fn build_response( + status: StatusCode, + headers: HeaderMap, + body: Body, +) -> Result, CliError> { + let mut builder = Response::builder().status(status); + for (name, value) in &headers { + builder = builder.header(name, value); + } + Ok(builder.body(body)?) +} + +// Allows provider request headers through unless they are transport-owned or must be recalculated +// for the forwarded body. Host and content length are intentionally excluded because reqwest sets +// them for the upstream connection. +pub(super) fn should_forward_request_header(name: &HeaderName) -> bool { + !is_hop_by_hop(name) + && name != http::header::HOST + && name != http::header::CONTENT_LENGTH + && name.as_str() != BOOTSTRAP_CLIENT_TOKEN_HEADER + // Strip Accept-Encoding so upstreams return identity-encoded bodies; otherwise the + // observability capture (`output.value` on LLM spans, ATIF trajectory bodies) records + // gzip/br/zstd bytes that downstream consumers can't read. Bandwidth cost is paid only + // on the gateway-upstream hop. The client never asked for the encoding it would have + // received from upstream, so its decoders never trigger. + && name != http::header::ACCEPT_ENCODING +} + +// Allows headers into observability metadata only after removing credentials and provider API keys. +// The forwarding filter runs first so hop-by-hop transport headers are also excluded from recorded +// LLM request attributes. The credential blocklist covers the four canonical cases we see in +// practice: `Authorization` (most providers), `Cookie` (session credentials), `x-api-key` (OpenAI +// SDK and similar), `anthropic-api-key` (Anthropic), and the generic `api-key` alias used by some +// providers/proxies (e.g., Azure OpenAI). `HeaderName::as_str()` already returns the canonical +// lowercase form so string comparisons are case-insensitive by construction. +pub(super) fn should_record_header(name: &HeaderName) -> bool { + should_forward_request_header(name) + && name != http::header::AUTHORIZATION + && name != http::header::COOKIE + && name.as_str() != "x-api-key" + && name.as_str() != "api-key" + && name.as_str() != "anthropic-api-key" +} + +// Identifies headers that describe a single transport hop and therefore must not be proxied across +// the client-gateway-upstream boundary. +pub(super) fn is_hop_by_hop(name: &HeaderName) -> bool { + matches!( + name.as_str(), + "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) +} diff --git a/crates/cli/src/gateway/routes.rs b/crates/cli/src/gateway/routes.rs new file mode 100644 index 000000000..e771e768b --- /dev/null +++ b/crates/cli/src/gateway/routes.rs @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Provider route classification and agent alignment policy. + +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ProviderRoute { + OpenAiResponses, + OpenAiChatCompletions, + OpenAiModels, + AnthropicMessages, + AnthropicCountTokens, +} + +#[derive(Clone, Copy)] +pub(super) struct ProviderForwarding { + pub(super) route: ProviderRoute, + pub(super) allow_environment_provider_auth: bool, +} + +impl ProviderForwarding { + pub(super) fn new(route: ProviderRoute, allow_environment_provider_auth: bool) -> Self { + Self { + route, + allow_environment_provider_auth, + } + } +} + +impl ProviderRoute { + // Maps public gateway paths to known upstream provider routes. Unsupported paths return `None` + // so the caller can fail as a bad hook/gateway payload instead of constructing arbitrary URLs. + pub(super) fn from_path(path: &str) -> Option { + match path { + "/responses" => Some(Self::OpenAiResponses), + "/v1/responses" => Some(Self::OpenAiResponses), + "/chat/completions" => Some(Self::OpenAiChatCompletions), + "/v1/chat/completions" => Some(Self::OpenAiChatCompletions), + "/models" => Some(Self::OpenAiModels), + "/v1/models" => Some(Self::OpenAiModels), + "/v1/messages" => Some(Self::AnthropicMessages), + "/v1/messages/count_tokens" => Some(Self::AnthropicCountTokens), + _ => None, + } + } + + pub(super) const fn provider_surface(self) -> Option { + match self { + Self::OpenAiResponses => Some(ProviderSurface::OpenAIResponses), + Self::OpenAiChatCompletions => Some(ProviderSurface::OpenAIChat), + Self::AnthropicMessages => Some(ProviderSurface::AnthropicMessages), + Self::AnthropicCountTokens | Self::OpenAiModels => None, + } + } + + // Returns the provider route name recorded on managed LLM events. These names split OpenAI API + // variants because their request/response schemas differ even when they share a base URL, and + // they double as codec hints for ambiguous provider request shapes. + pub(super) const fn name(self) -> &'static str { + self.alignment_route().name() + } + + // Builds the upstream URL by combining the configured provider base with the original path and + // query string. Trailing slashes are stripped from the base to avoid double-slash variants in + // configured enterprise or local proxy endpoints. + pub(super) fn upstream_url( + self, + config: &crate::configuration::GatewayConfig, + path_and_query: &str, + ) -> String { + let base = match self { + Self::OpenAiResponses | Self::OpenAiChatCompletions | Self::OpenAiModels => { + config.openai_base_url.as_str() + } + Self::AnthropicMessages | Self::AnthropicCountTokens => { + config.anthropic_base_url.as_str() + } + }; + self.upstream_url_with_base(base, path_and_query) + } + + // Like `upstream_url` but with an explicit base URL. This keeps OpenAI `/v1` normalization in + // one place for configured public, enterprise, or local proxy bases. + pub(super) fn upstream_url_with_base(self, base: &str, path_and_query: &str) -> String { + let base = base.trim_end_matches('/'); + let path_and_query = match self { + Self::OpenAiResponses | Self::OpenAiChatCompletions | Self::OpenAiModels => { + normalize_openai_path_for_base(base, path_and_query) + } + _ => path_and_query.to_string(), + }; + format!("{base}{path_and_query}") + } + + // Narrows gateway routing to the smaller taxonomy used by trace alignment. Keeping this + // conversion here prevents provider-specific alignment code from depending on gateway URL + // routing internals. + pub(super) const fn alignment_route(self) -> GatewayRouteKind { + match self { + Self::OpenAiResponses => GatewayRouteKind::OpenAiResponses, + Self::OpenAiChatCompletions => GatewayRouteKind::OpenAiChatCompletions, + Self::OpenAiModels => GatewayRouteKind::OpenAiModels, + Self::AnthropicMessages => GatewayRouteKind::AnthropicMessages, + Self::AnthropicCountTokens => GatewayRouteKind::AnthropicCountTokens, + } + } +} + +pub(super) fn normalize_openai_path_for_base(base: &str, path_and_query: &str) -> String { + match (base.ends_with("/v1"), path_and_query.starts_with("/v1/")) { + (true, true) => path_and_query + .strip_prefix("/v1") + .expect("path was checked to start with /v1") + .to_string(), + (false, false) => format!("/v1{path_and_query}"), + _ => path_and_query.to_string(), + } +} + +// Gives alignment adapters a chance to choose an agent-native upstream before default provider +// routing runs. Today this supports Codex ChatGPT auth; future harness fallbacks should stay in +// alignment rather than adding provider-shaped checks here. +pub(super) fn gateway_upstream_url_override( + route: ProviderRoute, + headers: &HeaderMap, + path_and_query: &str, + allow_environment_provider_auth: bool, +) -> Option { + gateway_upstream_url_override_with_openai_key_state( + route, + headers, + path_and_query, + allow_environment_provider_auth && env_var_is_nonempty("OPENAI_API_KEY"), + ) +} + +pub(super) fn gateway_upstream_url_override_with_openai_key_state( + route: ProviderRoute, + headers: &HeaderMap, + path_and_query: &str, + has_openai_replacement_key: bool, +) -> Option { + alignment::gateway_upstream_url_override( + headers, + route.alignment_route(), + path_and_query, + has_openai_replacement_key, + ) +} + +// Lets alignment adapters strip agent-native credentials only when the gateway can replace them +// with standard provider API keys. Whitespace-only env vars are treated as missing because +// forwarding an empty bearer value only replaces one authentication failure with another. +pub(super) fn strip_replaceable_agent_auth_headers( + headers: &HeaderMap, + route: ProviderRoute, + allow_environment_provider_auth: bool, +) -> HeaderMap { + strip_replaceable_agent_auth_headers_with_openai_key_state( + headers, + route, + allow_environment_provider_auth && env_var_is_nonempty("OPENAI_API_KEY"), + ) +} + +pub(super) fn strip_replaceable_agent_auth_headers_with_openai_key_state( + headers: &HeaderMap, + route: ProviderRoute, + has_openai_replacement_key: bool, +) -> HeaderMap { + alignment::gateway_forward_headers(headers, route.alignment_route(), has_openai_replacement_key) +} + +pub(super) fn env_var_is_nonempty(name: &str) -> bool { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + .is_some() +} + +// Delegates provider-specific session fallbacks to `alignment` so request construction stays +// generic and each coding-agent quirk has one documented adapter. +pub(super) fn gateway_session_id( + headers: &HeaderMap, + body: &Value, + route: ProviderRoute, +) -> Option { + alignment::gateway_session_id(headers, body, route.alignment_route()) +} + +pub(super) fn gateway_subagent_id(headers: &HeaderMap) -> Option { + alignment::gateway_subagent_id(headers) +} + +// Keeps the gateway-facing helper local for tests while the generic extraction pattern lives in +// `alignment`. +pub(super) fn gateway_identifier( + headers: &HeaderMap, + body: &Value, + header_name: &'static str, + body_paths: &[&[&str]], +) -> Option { + alignment::gateway_identifier(headers, body, header_name, body_paths) +} + +// Copies only non-sensitive, forwardable request headers into LLM request metadata. This preserves +// correlation headers while excluding credentials and hop-by-hop transport details. From d6bed7e73760879d5a0a1e9dbb933c8ef8fb55b2 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 21:38:11 -0400 Subject: [PATCH 028/102] refactor(cli): isolate session correlation policy Signed-off-by: Will Killian --- crates/cli/src/sessions/correlation.rs | 352 +++++++++++++++++++++++++ crates/cli/src/sessions/mod.rs | 350 +----------------------- 2 files changed, 356 insertions(+), 346 deletions(-) create mode 100644 crates/cli/src/sessions/correlation.rs diff --git a/crates/cli/src/sessions/correlation.rs b/crates/cli/src/sessions/correlation.rs new file mode 100644 index 000000000..210b71d91 --- /dev/null +++ b/crates/cli/src/sessions/correlation.rs @@ -0,0 +1,352 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! LLM/tool hint scoring and gateway ownership correlation. + +use super::*; + +pub(super) fn hint_match_score(hint: &LlmHintEvent, start: &LlmGatewayStart) -> u8 { + let mut score = 0; + if same_optional(hint.subagent_id.as_deref(), start.subagent_id.as_deref()) + || same_optional(hint.agent_id.as_deref(), start.subagent_id.as_deref()) + { + score += 8; + } + if same_optional( + hint.conversation_id.as_deref(), + start.conversation_id.as_deref(), + ) { + score += 4; + } + if same_optional( + hint.generation_id.as_deref(), + start.generation_id.as_deref(), + ) { + score += 4; + } + if same_optional(hint.request_id.as_deref(), start.request_id.as_deref()) { + score += 4; + } + if same_optional(hint.model.as_deref(), start.model_name.as_deref()) { + score += 1; + } + score +} + +// Extracts tool-call hints from common provider response shapes. These private hints let later +// hook-only tool events attach to the subagent that received the LLM response proposing the tool. +pub(super) fn tool_hints_from_llm_response( + response: &Value, + owner_subagent_id: Option, +) -> Vec { + let mut hints = Vec::new(); + collect_openai_chat_tool_hints(response, owner_subagent_id.as_deref(), &mut hints); + collect_openai_response_tool_hints(response, owner_subagent_id.as_deref(), &mut hints); + collect_anthropic_tool_hints(response, owner_subagent_id.as_deref(), &mut hints); + hints +} + +// Collects OpenAI Chat Completions `choices[].message.tool_calls[]` entries and preserves +// stringified function arguments as parsed JSON when possible. +pub(super) fn collect_openai_chat_tool_hints( + response: &Value, + owner_subagent_id: Option<&str>, + hints: &mut Vec, +) { + let Some(choices) = response.get("choices").and_then(Value::as_array) else { + return; + }; + for choice in choices { + let Some(tool_calls) = choice + .get("message") + .and_then(|message| message.get("tool_calls")) + .and_then(Value::as_array) + else { + continue; + }; + for call in tool_calls { + push_tool_hint( + hints, + call, + owner_subagent_id, + "openai_chat_tool_call", + &[&["id"][..], &["call_id"][..]], + &[&["function", "name"][..], &["name"][..]], + &[&["function", "arguments"][..], &["arguments"][..]], + ); + } + } +} + +// Collects OpenAI Responses output items where function-call data is usually direct on each item. +// Items without an id or name are ignored because they are too weak for ownership correlation. +pub(super) fn collect_openai_response_tool_hints( + response: &Value, + owner_subagent_id: Option<&str>, + hints: &mut Vec, +) { + let Some(output) = response.get("output").and_then(Value::as_array) else { + return; + }; + for item in output { + if item.get("type").and_then(Value::as_str) != Some("function_call") { + continue; + } + push_tool_hint( + hints, + item, + owner_subagent_id, + "openai_response_tool_call", + &[&["call_id"][..], &["id"][..]], + &[&["name"][..], &["tool_name"][..]], + &[&["arguments"][..], &["input"][..]], + ); + } +} + +// Collects Anthropic `tool_use` blocks from top-level or nested message content arrays. Other +// content block types are skipped so text and thinking blocks never become tool hints. +pub(super) fn collect_anthropic_tool_hints( + response: &Value, + owner_subagent_id: Option<&str>, + hints: &mut Vec, +) { + for content in [ + response.get("content"), + response + .get("message") + .and_then(|message| message.get("content")), + ] + .into_iter() + .flatten() + .filter_map(Value::as_array) + { + for block in content { + if json_string_at(block, &[&["type"][..]]).as_deref() == Some("tool_use") { + push_tool_hint( + hints, + block, + owner_subagent_id, + "anthropic_tool_use", + &[&["id"][..], &["tool_use_id"][..]], + &[&["name"][..], &["tool_name"][..]], + &[&["input"][..], &["arguments"][..]], + ); + } + } + } +} + +// Appends one provider tool hint when an object carries either a tool-call id or enough +// name-plus-argument data to disambiguate common tool names. Name-only and argument-only hints are +// skipped because they over-match across unrelated tools in parallel coding-agent sessions. +pub(super) fn push_tool_hint( + hints: &mut Vec, + object: &Value, + owner_subagent_id: Option<&str>, + source: &str, + id_paths: &[&[&str]], + name_paths: &[&[&str]], + argument_paths: &[&[&str]], +) { + let tool_call_id = json_string_at(object, id_paths); + let tool_name = json_string_at(object, name_paths); + let arguments = json_value_at(object, argument_paths) + .map(normalize_tool_arguments) + .unwrap_or(Value::Null); + if tool_call_id.is_none() && (tool_name.is_none() || arguments.is_null()) { + return; + } + hints.push(ToolHint { + tool_call_id, + tool_name, + subagent_id: owner_subagent_id.map(ToOwned::to_owned), + arguments, + source: source.to_string(), + }); +} + +// Scores how strongly a pending provider tool hint matches an observed hook event. A shared +// provider call id is strongest. Without an id match, require both tool name and exact arguments so +// repeated coding-agent tool names cannot claim unrelated hooks. +pub(super) fn tool_hint_match_score(hint: &ToolHint, event: &ToolEvent) -> u8 { + let mut score = 0; + let id_matches = same_optional( + hint.tool_call_id.as_deref(), + Some(event.tool_call_id.as_str()), + ); + let name_matches = same_optional(hint.tool_name.as_deref(), Some(event.tool_name.as_str())); + let arguments_match = !hint.arguments.is_null() + && !event.arguments.is_null() + && hint.arguments == event.arguments; + if id_matches { + score += 12; + } + if id_matches && name_matches { + score += 4; + } + if id_matches && arguments_match { + score += 1; + } + if !id_matches && name_matches && arguments_match { + score += 5; + } + score +} + +pub(super) fn same_optional(left: Option<&str>, right: Option<&str>) -> bool { + matches!((left, right), (Some(left), Some(right)) if left == right) +} + +pub(super) fn owner_status_teaches_request_affinity(status: &str) -> bool { + matches!( + status, + "explicit" + | "single_hint" + | "matched_hint" + | "active_subagent" + | "subagent_start" + | "request_affinity" + ) +} + +// Parses stringified tool arguments when providers encode them as JSON text. Non-JSON strings are +// preserved as strings so metadata still reflects what the provider actually returned. +pub(super) fn normalize_tool_arguments(arguments: Value) -> Value { + match arguments { + Value::String(raw) => serde_json::from_str(&raw).unwrap_or(Value::String(raw)), + value => value, + } +} + +// Adds correlation status and consumed-hint identifiers to the LLM event metadata. Caller metadata +// is merged first so correlation keys win when names collide. +pub(super) fn llm_correlation_metadata( + metadata: Value, + status: &str, + source: Option<&str>, + subagent_id: Option<&str>, + hint: Option<&LlmHintEvent>, +) -> Value { + let mut correlation = Map::new(); + correlation.insert("llm_correlation_status".into(), json!(status)); + if let Some(source) = source { + correlation.insert("llm_correlation_source".into(), json!(source)); + } + if let Some(subagent_id) = subagent_id { + correlation.insert("llm_correlation_subagent_id".into(), json!(subagent_id)); + } + if let Some(hint) = hint { + insert_optional( + &mut correlation, + "llm_correlation_conversation_id", + hint.conversation_id.as_deref(), + ); + insert_optional( + &mut correlation, + "llm_correlation_generation_id", + hint.generation_id.as_deref(), + ); + insert_optional( + &mut correlation, + "llm_correlation_request_id", + hint.request_id.as_deref(), + ); + insert_optional( + &mut correlation, + "llm_correlation_agent_type", + hint.agent_type.as_deref(), + ); + } + merge_metadata(metadata, Value::Object(correlation)) +} + +// Adds correlation metadata to tool spans created from hook events. Consumed hints preserve the +// provider-side tool id/name and extracted arguments so ambiguous or fallback ownership can be +// debugged from emitted events. +pub(super) fn tool_correlation_metadata( + metadata: Value, + status: &str, + source: Option<&str>, + subagent_id: Option<&str>, + hint: Option<&ToolHint>, +) -> Value { + let mut correlation = Map::new(); + correlation.insert("tool_correlation_status".into(), json!(status)); + if let Some(source) = source { + correlation.insert("tool_correlation_source".into(), json!(source)); + } + if let Some(subagent_id) = subagent_id { + correlation.insert("tool_correlation_subagent_id".into(), json!(subagent_id)); + } + if let Some(hint) = hint { + insert_optional( + &mut correlation, + "tool_correlation_tool_call_id", + hint.tool_call_id.as_deref(), + ); + insert_optional( + &mut correlation, + "tool_correlation_tool_name", + hint.tool_name.as_deref(), + ); + if !hint.arguments.is_null() { + correlation.insert("tool_correlation_arguments".into(), hint.arguments.clone()); + } + } + merge_metadata(metadata, Value::Object(correlation)) +} + +// Extracts the source agent kind from any normalized event variant so newly created sessions can +// inherit the correct agent identity before an explicit agent-start hook arrives. +pub(super) fn event_agent_kind(event: &NormalizedEvent) -> AgentKind { + match event { + NormalizedEvent::AgentStarted(event) + | NormalizedEvent::AgentEnded(event) + | NormalizedEvent::TurnEnded(event) + | NormalizedEvent::PromptSubmitted(event) + | NormalizedEvent::Compaction(event) + | NormalizedEvent::Notification(event) + | NormalizedEvent::HookMark(event) => event.agent_kind, + NormalizedEvent::LlmHint(event) => event.agent_kind, + NormalizedEvent::SubagentStarted(event) | NormalizedEvent::SubagentEnded(event) => { + event.agent_kind + } + NormalizedEvent::LlmStarted(event) | NormalizedEvent::LlmEnded(event) => event.agent_kind, + NormalizedEvent::ToolStarted(event) | NormalizedEvent::ToolEnded(event) => event.agent_kind, + } +} + +// Returns a session id only when exactly one session is active. Gateway requests without explicit +// session headers use this narrow fallback to avoid cross-correlating concurrent agents. +pub(super) fn single_active_session_id(sessions: &HashMap) -> Option { + (sessions.len() == 1) + .then(|| sessions.keys().next().cloned()) + .flatten() +} + +// Selects a gateway session without guessing between concurrent agents. An explicit session id or +// the sole active session is safe to retain. With no sessions, the stable synthetic root preserves +// pure-proxy continuity. When multiple sessions are active and the request carries no join key, +// isolate that request in a unique short-lived root instead of cross-correlating unrelated agents. +pub(super) fn gateway_session_for_call( + start: &LlmGatewayStart, + sessions: &HashMap, +) -> (String, GatewaySessionFinish) { + if let Some(session_id) = start.session_id.clone() { + return (session_id, GatewaySessionFinish::Retain); + } + if let Some(session_id) = single_active_session_id(sessions) { + return (session_id, GatewaySessionFinish::Retain); + } + if sessions.is_empty() { + return ( + format!("{}-gateway", AgentKind::Gateway.as_str()), + GatewaySessionFinish::Retain, + ); + } + ( + format!("gateway-isolated-{}", uuid::Uuid::now_v7()), + GatewaySessionFinish::Close, + ) +} diff --git a/crates/cli/src/sessions/mod.rs b/crates/cli/src/sessions/mod.rs index d82318850..c2ff1c057 100644 --- a/crates/cli/src/sessions/mod.rs +++ b/crates/cli/src/sessions/mod.rs @@ -30,6 +30,10 @@ use crate::agents::shared::alignment::{ }; use crate::configuration::{GatewayConfig, SessionConfig}; use crate::error::CliError; +mod correlation; + +use correlation::*; + use crate::events::{ AgentKind, LlmEvent, LlmHintEvent, NormalizedEvent, SessionEvent, SubagentEvent, ToolEvent, }; @@ -2256,352 +2260,6 @@ impl Session { // Scores how strongly a pending hint matches a gateway LLM request. Subagent/agent identity is // weighted highest, request/conversation/generation identifiers are equal, and model match is only // a low-confidence tie breaker. -fn hint_match_score(hint: &LlmHintEvent, start: &LlmGatewayStart) -> u8 { - let mut score = 0; - if same_optional(hint.subagent_id.as_deref(), start.subagent_id.as_deref()) - || same_optional(hint.agent_id.as_deref(), start.subagent_id.as_deref()) - { - score += 8; - } - if same_optional( - hint.conversation_id.as_deref(), - start.conversation_id.as_deref(), - ) { - score += 4; - } - if same_optional( - hint.generation_id.as_deref(), - start.generation_id.as_deref(), - ) { - score += 4; - } - if same_optional(hint.request_id.as_deref(), start.request_id.as_deref()) { - score += 4; - } - if same_optional(hint.model.as_deref(), start.model_name.as_deref()) { - score += 1; - } - score -} - -// Extracts tool-call hints from common provider response shapes. These private hints let later -// hook-only tool events attach to the subagent that received the LLM response proposing the tool. -fn tool_hints_from_llm_response( - response: &Value, - owner_subagent_id: Option, -) -> Vec { - let mut hints = Vec::new(); - collect_openai_chat_tool_hints(response, owner_subagent_id.as_deref(), &mut hints); - collect_openai_response_tool_hints(response, owner_subagent_id.as_deref(), &mut hints); - collect_anthropic_tool_hints(response, owner_subagent_id.as_deref(), &mut hints); - hints -} - -// Collects OpenAI Chat Completions `choices[].message.tool_calls[]` entries and preserves -// stringified function arguments as parsed JSON when possible. -fn collect_openai_chat_tool_hints( - response: &Value, - owner_subagent_id: Option<&str>, - hints: &mut Vec, -) { - let Some(choices) = response.get("choices").and_then(Value::as_array) else { - return; - }; - for choice in choices { - let Some(tool_calls) = choice - .get("message") - .and_then(|message| message.get("tool_calls")) - .and_then(Value::as_array) - else { - continue; - }; - for call in tool_calls { - push_tool_hint( - hints, - call, - owner_subagent_id, - "openai_chat_tool_call", - &[&["id"][..], &["call_id"][..]], - &[&["function", "name"][..], &["name"][..]], - &[&["function", "arguments"][..], &["arguments"][..]], - ); - } - } -} - -// Collects OpenAI Responses output items where function-call data is usually direct on each item. -// Items without an id or name are ignored because they are too weak for ownership correlation. -fn collect_openai_response_tool_hints( - response: &Value, - owner_subagent_id: Option<&str>, - hints: &mut Vec, -) { - let Some(output) = response.get("output").and_then(Value::as_array) else { - return; - }; - for item in output { - if item.get("type").and_then(Value::as_str) != Some("function_call") { - continue; - } - push_tool_hint( - hints, - item, - owner_subagent_id, - "openai_response_tool_call", - &[&["call_id"][..], &["id"][..]], - &[&["name"][..], &["tool_name"][..]], - &[&["arguments"][..], &["input"][..]], - ); - } -} - -// Collects Anthropic `tool_use` blocks from top-level or nested message content arrays. Other -// content block types are skipped so text and thinking blocks never become tool hints. -fn collect_anthropic_tool_hints( - response: &Value, - owner_subagent_id: Option<&str>, - hints: &mut Vec, -) { - for content in [ - response.get("content"), - response - .get("message") - .and_then(|message| message.get("content")), - ] - .into_iter() - .flatten() - .filter_map(Value::as_array) - { - for block in content { - if json_string_at(block, &[&["type"][..]]).as_deref() == Some("tool_use") { - push_tool_hint( - hints, - block, - owner_subagent_id, - "anthropic_tool_use", - &[&["id"][..], &["tool_use_id"][..]], - &[&["name"][..], &["tool_name"][..]], - &[&["input"][..], &["arguments"][..]], - ); - } - } - } -} - -// Appends one provider tool hint when an object carries either a tool-call id or enough -// name-plus-argument data to disambiguate common tool names. Name-only and argument-only hints are -// skipped because they over-match across unrelated tools in parallel coding-agent sessions. -fn push_tool_hint( - hints: &mut Vec, - object: &Value, - owner_subagent_id: Option<&str>, - source: &str, - id_paths: &[&[&str]], - name_paths: &[&[&str]], - argument_paths: &[&[&str]], -) { - let tool_call_id = json_string_at(object, id_paths); - let tool_name = json_string_at(object, name_paths); - let arguments = json_value_at(object, argument_paths) - .map(normalize_tool_arguments) - .unwrap_or(Value::Null); - if tool_call_id.is_none() && (tool_name.is_none() || arguments.is_null()) { - return; - } - hints.push(ToolHint { - tool_call_id, - tool_name, - subagent_id: owner_subagent_id.map(ToOwned::to_owned), - arguments, - source: source.to_string(), - }); -} - -// Scores how strongly a pending provider tool hint matches an observed hook event. A shared -// provider call id is strongest. Without an id match, require both tool name and exact arguments so -// repeated coding-agent tool names cannot claim unrelated hooks. -fn tool_hint_match_score(hint: &ToolHint, event: &ToolEvent) -> u8 { - let mut score = 0; - let id_matches = same_optional( - hint.tool_call_id.as_deref(), - Some(event.tool_call_id.as_str()), - ); - let name_matches = same_optional(hint.tool_name.as_deref(), Some(event.tool_name.as_str())); - let arguments_match = !hint.arguments.is_null() - && !event.arguments.is_null() - && hint.arguments == event.arguments; - if id_matches { - score += 12; - } - if id_matches && name_matches { - score += 4; - } - if id_matches && arguments_match { - score += 1; - } - if !id_matches && name_matches && arguments_match { - score += 5; - } - score -} - -fn same_optional(left: Option<&str>, right: Option<&str>) -> bool { - matches!((left, right), (Some(left), Some(right)) if left == right) -} - -fn owner_status_teaches_request_affinity(status: &str) -> bool { - matches!( - status, - "explicit" - | "single_hint" - | "matched_hint" - | "active_subagent" - | "subagent_start" - | "request_affinity" - ) -} - -// Parses stringified tool arguments when providers encode them as JSON text. Non-JSON strings are -// preserved as strings so metadata still reflects what the provider actually returned. -fn normalize_tool_arguments(arguments: Value) -> Value { - match arguments { - Value::String(raw) => serde_json::from_str(&raw).unwrap_or(Value::String(raw)), - value => value, - } -} - -// Adds correlation status and consumed-hint identifiers to the LLM event metadata. Caller metadata -// is merged first so correlation keys win when names collide. -fn llm_correlation_metadata( - metadata: Value, - status: &str, - source: Option<&str>, - subagent_id: Option<&str>, - hint: Option<&LlmHintEvent>, -) -> Value { - let mut correlation = Map::new(); - correlation.insert("llm_correlation_status".into(), json!(status)); - if let Some(source) = source { - correlation.insert("llm_correlation_source".into(), json!(source)); - } - if let Some(subagent_id) = subagent_id { - correlation.insert("llm_correlation_subagent_id".into(), json!(subagent_id)); - } - if let Some(hint) = hint { - insert_optional( - &mut correlation, - "llm_correlation_conversation_id", - hint.conversation_id.as_deref(), - ); - insert_optional( - &mut correlation, - "llm_correlation_generation_id", - hint.generation_id.as_deref(), - ); - insert_optional( - &mut correlation, - "llm_correlation_request_id", - hint.request_id.as_deref(), - ); - insert_optional( - &mut correlation, - "llm_correlation_agent_type", - hint.agent_type.as_deref(), - ); - } - merge_metadata(metadata, Value::Object(correlation)) -} - -// Adds correlation metadata to tool spans created from hook events. Consumed hints preserve the -// provider-side tool id/name and extracted arguments so ambiguous or fallback ownership can be -// debugged from emitted events. -fn tool_correlation_metadata( - metadata: Value, - status: &str, - source: Option<&str>, - subagent_id: Option<&str>, - hint: Option<&ToolHint>, -) -> Value { - let mut correlation = Map::new(); - correlation.insert("tool_correlation_status".into(), json!(status)); - if let Some(source) = source { - correlation.insert("tool_correlation_source".into(), json!(source)); - } - if let Some(subagent_id) = subagent_id { - correlation.insert("tool_correlation_subagent_id".into(), json!(subagent_id)); - } - if let Some(hint) = hint { - insert_optional( - &mut correlation, - "tool_correlation_tool_call_id", - hint.tool_call_id.as_deref(), - ); - insert_optional( - &mut correlation, - "tool_correlation_tool_name", - hint.tool_name.as_deref(), - ); - if !hint.arguments.is_null() { - correlation.insert("tool_correlation_arguments".into(), hint.arguments.clone()); - } - } - merge_metadata(metadata, Value::Object(correlation)) -} - -// Extracts the source agent kind from any normalized event variant so newly created sessions can -// inherit the correct agent identity before an explicit agent-start hook arrives. -fn event_agent_kind(event: &NormalizedEvent) -> AgentKind { - match event { - NormalizedEvent::AgentStarted(event) - | NormalizedEvent::AgentEnded(event) - | NormalizedEvent::TurnEnded(event) - | NormalizedEvent::PromptSubmitted(event) - | NormalizedEvent::Compaction(event) - | NormalizedEvent::Notification(event) - | NormalizedEvent::HookMark(event) => event.agent_kind, - NormalizedEvent::LlmHint(event) => event.agent_kind, - NormalizedEvent::SubagentStarted(event) | NormalizedEvent::SubagentEnded(event) => { - event.agent_kind - } - NormalizedEvent::LlmStarted(event) | NormalizedEvent::LlmEnded(event) => event.agent_kind, - NormalizedEvent::ToolStarted(event) | NormalizedEvent::ToolEnded(event) => event.agent_kind, - } -} - -// Returns a session id only when exactly one session is active. Gateway requests without explicit -// session headers use this narrow fallback to avoid cross-correlating concurrent agents. -fn single_active_session_id(sessions: &HashMap) -> Option { - (sessions.len() == 1) - .then(|| sessions.keys().next().cloned()) - .flatten() -} - -// Selects a gateway session without guessing between concurrent agents. An explicit session id or -// the sole active session is safe to retain. With no sessions, the stable synthetic root preserves -// pure-proxy continuity. When multiple sessions are active and the request carries no join key, -// isolate that request in a unique short-lived root instead of cross-correlating unrelated agents. -fn gateway_session_for_call( - start: &LlmGatewayStart, - sessions: &HashMap, -) -> (String, GatewaySessionFinish) { - if let Some(session_id) = start.session_id.clone() { - return (session_id, GatewaySessionFinish::Retain); - } - if let Some(session_id) = single_active_session_id(sessions) { - return (session_id, GatewaySessionFinish::Retain); - } - if sessions.is_empty() { - return ( - format!("{}-gateway", AgentKind::Gateway.as_str()), - GatewaySessionFinish::Retain, - ); - } - ( - format!("gateway-isolated-{}", uuid::Uuid::now_v7()), - GatewaySessionFinish::Close, - ) -} - #[cfg(test)] #[path = "../../tests/coverage/shared/session_tests.rs"] mod tests; From 86b36c3c31611b846b806455a95fe3d0fead5dd5 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 21:39:18 -0400 Subject: [PATCH 029/102] refactor(cli): isolate diagnostics rendering Signed-off-by: Will Killian --- crates/cli/src/diagnostics/mod.rs | 301 +------------------------- crates/cli/src/diagnostics/render.rs | 302 +++++++++++++++++++++++++++ 2 files changed, 306 insertions(+), 297 deletions(-) create mode 100644 crates/cli/src/diagnostics/render.rs diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index 7b824e5f9..af42b976f 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -9,6 +9,10 @@ //! - `DoctorReport` is the resulting pure data shape. //! - `format_human(&report)` / `format_json(&report)` render the report. +mod render; + +use render::*; + use std::path::{Path, PathBuf}; use std::time::Duration; @@ -1249,303 +1253,6 @@ fn home_dir() -> Option { } /// Aggregate exit code: 1 if any check is Fail, 0 otherwise. Warnings do not fail. -pub(crate) fn exit_code(report: &DoctorReport) -> u8 { - let any_fail = report - .observability - .iter() - .chain(report.completions.iter()) - .any(|c| matches!(c.status, Status::Fail)) - || report - .agents - .iter() - .any(|agent| matches!(agent.status, Status::Fail)) - || report.host_plugins.iter().any(|plugin| !plugin.ok()) - || matches!(report.configuration.workspace.status, Status::Fail) - || matches!(report.configuration.global.status, Status::Fail) - || matches!(report.configuration.system.status, Status::Fail) - || matches!(report.configuration.plugin_resolution.status, Status::Fail) - || matches!(report.configuration.resolution.status, Status::Fail); - u8::from(any_fail) -} - -// Returns true if any check in the report carries a `Warn` status. Used by the human footer to -// distinguish a fully-green report from one where everything passed but some checks issued -// warnings — both exit 0, but the wording shouldn't. -fn report_has_warn(report: &DoctorReport) -> bool { - report - .observability - .iter() - .chain(report.completions.iter()) - .any(|c| matches!(c.status, Status::Warn)) - || report - .agents - .iter() - .any(|agent| matches!(agent.status, Status::Warn)) - || report.host_plugins.iter().any(|plugin| !plugin.ok()) - || matches!(report.configuration.workspace.status, Status::Warn) - || matches!(report.configuration.global.status, Status::Warn) - || matches!(report.configuration.system.status, Status::Warn) - || matches!(report.configuration.plugin_resolution.status, Status::Warn) - || matches!(report.configuration.resolution.status, Status::Warn) -} - -/// Renders the doctor report in the fixed human-readable layout the design doc shows. Sections -/// stay in the same order across runs so users can diff across machines. The banner header lives -/// in `crate::banner::print_doctor_header` (called from `run_doctor` before this renders) so the -/// pure formatter stays banner-free for tests. -pub(crate) fn format_human(report: &DoctorReport) -> String { - let mut out = String::new(); - format_human_header(&mut out, report); - format_human_environment(&mut out, report); - format_human_configuration(&mut out, report); - format_human_plugin_configuration(&mut out, report); - format_human_agents(&mut out, report); - format_human_host_plugins(&mut out, report); - format_human_checks(&mut out, "Observability", &report.observability); - format_human_completion_checks(&mut out, &report.completions); - format_human_conclusion(&mut out, report); - out -} - -fn format_human_header(out: &mut String, report: &DoctorReport) { - out.push_str(&format!("\n NeMo Relay {}\n", report.binary_version)); - out.push_str(" ─────────────────────────────────────────────\n"); - if let Some(agent) = &report.target_agent { - out.push_str(&format!(" Target agent {agent}\n\n")); - } -} - -fn format_human_environment(out: &mut String, report: &DoctorReport) { - out.push_str(" Environment\n"); - out.push_str(&format!( - " OS {}\n", - report.environment.os.trim() - )); - out.push_str(&format!(" Arch {}\n", report.environment.arch)); - if let Some(shell) = &report.environment.shell { - out.push_str(&format!(" Shell {shell}\n")); - } - out.push('\n'); -} - -fn format_human_configuration(out: &mut String, report: &DoctorReport) { - out.push_str(" Configuration\n"); - out.push_str(&format!( - " Workspace {}\n", - format_layer(&report.configuration.workspace) - )); - out.push_str(&format!( - " Global {}\n", - format_layer(&report.configuration.global) - )); - out.push_str(&format!( - " System {}\n", - format_layer(&report.configuration.system) - )); - if !matches!(report.configuration.resolution.status, Status::Pass) { - out.push_str(&format!( - " Resolution {} {}\n", - format_status(report.configuration.resolution.status), - report.configuration.resolution.details - )); - } - if !report.configuration.configured_agents.is_empty() { - out.push_str(&format!( - " Agents {}\n", - report.configuration.configured_agents.join(", ") - )); - } - out.push('\n'); -} - -fn format_human_plugin_configuration(out: &mut String, report: &DoctorReport) { - out.push_str(" Plugin configuration\n"); - for plugin in &report.configuration.dynamic_plugins { - let config_suffix = if matches!( - plugin.host_config_status, - DynamicPluginHostConfigStatus::Present - ) { - "; host config" - } else { - "" - }; - out.push_str(&format!( - " Dynamic {} ({}){}\n", - plugin.plugin_id, plugin.manifest_ref, config_suffix - )); - } - if !report.configuration.plugin_configs.is_empty() { - for (index, layer) in report.configuration.plugin_configs.iter().enumerate() { - let label = if index == 0 { "Plugin files" } else { "" }; - out.push_str(&format!(" {label:<13}{}\n", format_layer(layer))); - } - } - out.push_str(&format!( - " Plugins {} {}\n", - format_status(report.configuration.plugin_resolution.status), - report.configuration.plugin_resolution.details - )); - for plugin in &report.configuration.dynamic_plugins { - for check in [ - dynamic_plugin_reference_check(plugin), - dynamic_plugin_host_config_check(plugin), - ] { - out.push_str(&format!( - " Dynamic {} {}\n", - format_status(check.status), - check.details - )); - } - } - out.push('\n'); -} - -fn format_human_agents(out: &mut String, report: &DoctorReport) { - out.push_str(" Agents detected\n"); - for agent in &report.agents { - let status = format_status(agent.status); - match &agent.path { - Some(path) => { - let version = agent.version.as_deref().unwrap_or("(unknown version)"); - out.push_str(&format!( - " {} {:<8} {}\n command {}\n path {}\n {}\n", - status, - agent.name, - version, - agent.command, - path.display(), - agent.annotation - )); - } - None => { - out.push_str(&format!( - " {} {:<8} not on $PATH\n command {}\n {}\n", - status, agent.name, agent.command, agent.annotation - )); - } - } - } - out.push('\n'); -} - -fn format_human_host_plugins(out: &mut String, report: &DoctorReport) { - out.push_str(" Persistent integrations\n"); - if report.host_plugins.is_empty() { - out.push_str(" · none installed; run `nemo-relay install ` to enable one\n"); - } else { - for plugin in &report.host_plugins { - out.push_str(&format!( - " {} {}\n", - if plugin.ok() { "✓" } else { "✗" }, - plugin.host - )); - for check in &plugin.checks { - out.push_str(&format!( - " {} {}: {}\n", - if check.ok { "✓" } else { "✗" }, - check.name, - check.details - )); - } - if !plugin.ok() { - out.push_str(&format!(" repair: {}\n", plugin.remediation)); - } - } - } - out.push('\n'); -} - -fn format_human_checks(out: &mut String, title: &str, checks: &[Check]) { - out.push_str(&format!(" {title}\n")); - for check in checks { - out.push_str(&format!(" {:<22} {}\n", check.name, check.details)); - } - out.push('\n'); -} - -fn format_human_completion_checks(out: &mut String, checks: &[Check]) { - out.push_str(" Completions\n"); - for check in checks { - out.push_str(&format!(" {}\n", check.details)); - } - out.push('\n'); -} - -fn format_human_conclusion(out: &mut String, report: &DoctorReport) { - if exit_code(report) == 0 { - if report_has_warn(report) { - out.push_str(" All checks passed, but some issued warnings; see details above.\n"); - } else { - out.push_str(" All checks passed.\n"); - } - } else { - out.push_str(" Some checks FAILED; see details above.\n"); - } -} - -fn format_layer(layer: &ConfigLayer) -> String { - let active = if layer.active { " (loaded)" } else { "" }; - format!("{} {}{}", layer.path.display(), layer.details, active) -} - -fn format_status(status: Status) -> &'static str { - match status { - Status::Pass => "✓", - Status::Warn => "!", - Status::Fail => "✗", - Status::Info => "·", - } -} - -/// Renders the doctor report as machine-readable JSON. Versioned via `schema_version` so -/// downstream consumers (CI dashboards, eval harnesses) can detect schema changes. -pub(crate) fn format_json(report: &DoctorReport) -> Result { - serde_json::to_string_pretty(report) - .map_err(|err| CliError::Config(format!("could not serialize doctor report: {err}"))) -} - -/// Runs `agents` — a thin wrapper over `collect_agents` that emits only the agent list. Shares -/// the same JSON schema as `doctor.agents` for consistency. -pub(crate) async fn agents_report() -> Vec { - let resolved = resolve_server_config(&GatewayOverrides::default()).unwrap_or_default(); - collect_agents(None, &resolved).await -} - -/// Renders the agents listing in human form. -pub(crate) fn format_agents_human(agents: &[AgentInfo]) -> String { - let mut out = String::new(); - out.push_str("\n Supported\n"); - for agent in agents { - out.push_str(&format!(" {}\n", agent.name)); - } - out.push('\n'); - out.push_str(" Detected on this machine\n"); - let detected: Vec<&AgentInfo> = agents.iter().filter(|a| a.path.is_some()).collect(); - if detected.is_empty() { - out.push_str(" (none)\n"); - } else { - for agent in detected { - let version = agent.version.as_deref().unwrap_or("(unknown version)"); - let path = agent - .path - .as_ref() - .map(|p| p.display().to_string()) - .unwrap_or_default(); - out.push_str(&format!( - " {} {:<8} {}\n {}\n {}\n", - format_status(agent.status), - agent.name, - version, - path, - agent.annotation - )); - } - } - out.push('\n'); - out -} - -/// Renders the agents listing as JSON. Same shape as `DoctorReport.agents`. pub(crate) fn format_agents_json(agents: &[AgentInfo]) -> Result { serde_json::to_string_pretty(agents) .map_err(|err| CliError::Config(format!("could not serialize agents report: {err}"))) diff --git a/crates/cli/src/diagnostics/render.rs b/crates/cli/src/diagnostics/render.rs new file mode 100644 index 000000000..8b2a50c0b --- /dev/null +++ b/crates/cli/src/diagnostics/render.rs @@ -0,0 +1,302 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Doctor and agent-report presentation. + +use super::*; + +pub(crate) fn exit_code(report: &DoctorReport) -> u8 { + let any_fail = report + .observability + .iter() + .chain(report.completions.iter()) + .any(|c| matches!(c.status, Status::Fail)) + || report + .agents + .iter() + .any(|agent| matches!(agent.status, Status::Fail)) + || report.host_plugins.iter().any(|plugin| !plugin.ok()) + || matches!(report.configuration.workspace.status, Status::Fail) + || matches!(report.configuration.global.status, Status::Fail) + || matches!(report.configuration.system.status, Status::Fail) + || matches!(report.configuration.plugin_resolution.status, Status::Fail) + || matches!(report.configuration.resolution.status, Status::Fail); + u8::from(any_fail) +} + +// Returns true if any check in the report carries a `Warn` status. Used by the human footer to +// distinguish a fully-green report from one where everything passed but some checks issued +// warnings — both exit 0, but the wording shouldn't. +pub(super) fn report_has_warn(report: &DoctorReport) -> bool { + report + .observability + .iter() + .chain(report.completions.iter()) + .any(|c| matches!(c.status, Status::Warn)) + || report + .agents + .iter() + .any(|agent| matches!(agent.status, Status::Warn)) + || report.host_plugins.iter().any(|plugin| !plugin.ok()) + || matches!(report.configuration.workspace.status, Status::Warn) + || matches!(report.configuration.global.status, Status::Warn) + || matches!(report.configuration.system.status, Status::Warn) + || matches!(report.configuration.plugin_resolution.status, Status::Warn) + || matches!(report.configuration.resolution.status, Status::Warn) +} + +/// Renders the doctor report in the fixed human-readable layout the design doc shows. Sections +/// stay in the same order across runs so users can diff across machines. The banner header lives +/// in `crate::banner::print_doctor_header` (called from `run_doctor` before this renders) so the +/// pure formatter stays banner-free for tests. +pub(crate) fn format_human(report: &DoctorReport) -> String { + let mut out = String::new(); + format_human_header(&mut out, report); + format_human_environment(&mut out, report); + format_human_configuration(&mut out, report); + format_human_plugin_configuration(&mut out, report); + format_human_agents(&mut out, report); + format_human_host_plugins(&mut out, report); + format_human_checks(&mut out, "Observability", &report.observability); + format_human_completion_checks(&mut out, &report.completions); + format_human_conclusion(&mut out, report); + out +} + +pub(super) fn format_human_header(out: &mut String, report: &DoctorReport) { + out.push_str(&format!("\n NeMo Relay {}\n", report.binary_version)); + out.push_str(" ─────────────────────────────────────────────\n"); + if let Some(agent) = &report.target_agent { + out.push_str(&format!(" Target agent {agent}\n\n")); + } +} + +pub(super) fn format_human_environment(out: &mut String, report: &DoctorReport) { + out.push_str(" Environment\n"); + out.push_str(&format!( + " OS {}\n", + report.environment.os.trim() + )); + out.push_str(&format!(" Arch {}\n", report.environment.arch)); + if let Some(shell) = &report.environment.shell { + out.push_str(&format!(" Shell {shell}\n")); + } + out.push('\n'); +} + +pub(super) fn format_human_configuration(out: &mut String, report: &DoctorReport) { + out.push_str(" Configuration\n"); + out.push_str(&format!( + " Workspace {}\n", + format_layer(&report.configuration.workspace) + )); + out.push_str(&format!( + " Global {}\n", + format_layer(&report.configuration.global) + )); + out.push_str(&format!( + " System {}\n", + format_layer(&report.configuration.system) + )); + if !matches!(report.configuration.resolution.status, Status::Pass) { + out.push_str(&format!( + " Resolution {} {}\n", + format_status(report.configuration.resolution.status), + report.configuration.resolution.details + )); + } + if !report.configuration.configured_agents.is_empty() { + out.push_str(&format!( + " Agents {}\n", + report.configuration.configured_agents.join(", ") + )); + } + out.push('\n'); +} + +pub(super) fn format_human_plugin_configuration(out: &mut String, report: &DoctorReport) { + out.push_str(" Plugin configuration\n"); + for plugin in &report.configuration.dynamic_plugins { + let config_suffix = if matches!( + plugin.host_config_status, + DynamicPluginHostConfigStatus::Present + ) { + "; host config" + } else { + "" + }; + out.push_str(&format!( + " Dynamic {} ({}){}\n", + plugin.plugin_id, plugin.manifest_ref, config_suffix + )); + } + if !report.configuration.plugin_configs.is_empty() { + for (index, layer) in report.configuration.plugin_configs.iter().enumerate() { + let label = if index == 0 { "Plugin files" } else { "" }; + out.push_str(&format!(" {label:<13}{}\n", format_layer(layer))); + } + } + out.push_str(&format!( + " Plugins {} {}\n", + format_status(report.configuration.plugin_resolution.status), + report.configuration.plugin_resolution.details + )); + for plugin in &report.configuration.dynamic_plugins { + for check in [ + dynamic_plugin_reference_check(plugin), + dynamic_plugin_host_config_check(plugin), + ] { + out.push_str(&format!( + " Dynamic {} {}\n", + format_status(check.status), + check.details + )); + } + } + out.push('\n'); +} + +pub(super) fn format_human_agents(out: &mut String, report: &DoctorReport) { + out.push_str(" Agents detected\n"); + for agent in &report.agents { + let status = format_status(agent.status); + match &agent.path { + Some(path) => { + let version = agent.version.as_deref().unwrap_or("(unknown version)"); + out.push_str(&format!( + " {} {:<8} {}\n command {}\n path {}\n {}\n", + status, + agent.name, + version, + agent.command, + path.display(), + agent.annotation + )); + } + None => { + out.push_str(&format!( + " {} {:<8} not on $PATH\n command {}\n {}\n", + status, agent.name, agent.command, agent.annotation + )); + } + } + } + out.push('\n'); +} + +pub(super) fn format_human_host_plugins(out: &mut String, report: &DoctorReport) { + out.push_str(" Persistent integrations\n"); + if report.host_plugins.is_empty() { + out.push_str(" · none installed; run `nemo-relay install ` to enable one\n"); + } else { + for plugin in &report.host_plugins { + out.push_str(&format!( + " {} {}\n", + if plugin.ok() { "✓" } else { "✗" }, + plugin.host + )); + for check in &plugin.checks { + out.push_str(&format!( + " {} {}: {}\n", + if check.ok { "✓" } else { "✗" }, + check.name, + check.details + )); + } + if !plugin.ok() { + out.push_str(&format!(" repair: {}\n", plugin.remediation)); + } + } + } + out.push('\n'); +} + +pub(super) fn format_human_checks(out: &mut String, title: &str, checks: &[Check]) { + out.push_str(&format!(" {title}\n")); + for check in checks { + out.push_str(&format!(" {:<22} {}\n", check.name, check.details)); + } + out.push('\n'); +} + +pub(super) fn format_human_completion_checks(out: &mut String, checks: &[Check]) { + out.push_str(" Completions\n"); + for check in checks { + out.push_str(&format!(" {}\n", check.details)); + } + out.push('\n'); +} + +pub(super) fn format_human_conclusion(out: &mut String, report: &DoctorReport) { + if exit_code(report) == 0 { + if report_has_warn(report) { + out.push_str(" All checks passed, but some issued warnings; see details above.\n"); + } else { + out.push_str(" All checks passed.\n"); + } + } else { + out.push_str(" Some checks FAILED; see details above.\n"); + } +} + +pub(super) fn format_layer(layer: &ConfigLayer) -> String { + let active = if layer.active { " (loaded)" } else { "" }; + format!("{} {}{}", layer.path.display(), layer.details, active) +} + +pub(super) fn format_status(status: Status) -> &'static str { + match status { + Status::Pass => "✓", + Status::Warn => "!", + Status::Fail => "✗", + Status::Info => "·", + } +} + +/// Renders the doctor report as machine-readable JSON. Versioned via `schema_version` so +/// downstream consumers (CI dashboards, eval harnesses) can detect schema changes. +pub(crate) fn format_json(report: &DoctorReport) -> Result { + serde_json::to_string_pretty(report) + .map_err(|err| CliError::Config(format!("could not serialize doctor report: {err}"))) +} + +/// Runs `agents` — a thin wrapper over `collect_agents` that emits only the agent list. Shares +/// the same JSON schema as `doctor.agents` for consistency. +pub(crate) async fn agents_report() -> Vec { + let resolved = resolve_server_config(&GatewayOverrides::default()).unwrap_or_default(); + collect_agents(None, &resolved).await +} + +/// Renders the agents listing in human form. +pub(crate) fn format_agents_human(agents: &[AgentInfo]) -> String { + let mut out = String::new(); + out.push_str("\n Supported\n"); + for agent in agents { + out.push_str(&format!(" {}\n", agent.name)); + } + out.push('\n'); + out.push_str(" Detected on this machine\n"); + let detected: Vec<&AgentInfo> = agents.iter().filter(|a| a.path.is_some()).collect(); + if detected.is_empty() { + out.push_str(" (none)\n"); + } else { + for agent in detected { + let version = agent.version.as_deref().unwrap_or("(unknown version)"); + let path = agent + .path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + out.push_str(&format!( + " {} {:<8} {}\n {}\n {}\n", + format_status(agent.status), + agent.name, + version, + path, + agent.annotation + )); + } + } + out.push('\n'); + out +} From 62d644040ffbe093c647b0fb9f3d59346d8ff4fb Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 21:40:22 -0400 Subject: [PATCH 030/102] refactor(cli): isolate filesystem locking Signed-off-by: Will Killian --- crates/cli/src/filesystem/locks.rs | 53 ++++++++++++++++++++++++++++++ crates/cli/src/filesystem/mod.rs | 50 +++------------------------- 2 files changed, 57 insertions(+), 46 deletions(-) create mode 100644 crates/cli/src/filesystem/locks.rs diff --git a/crates/cli/src/filesystem/locks.rs b/crates/cli/src/filesystem/locks.rs new file mode 100644 index 000000000..96fd0f672 --- /dev/null +++ b/crates/cli/src/filesystem/locks.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Nonblocking advisory file-lock primitives. + +use std::fs::File; +use std::io; + +use fs2::FileExt; + +/// Result of one nonblocking advisory-file-lock attempt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum LockAttempt { + Acquired, + Contended, +} + +/// Attempt an exclusive advisory lock without waiting. +pub(crate) fn try_lock_exclusive(file: &File) -> io::Result { + normalize_lock_attempt(FileExt::try_lock_exclusive(file)) +} + +/// Attempt a shared advisory lock without waiting. +pub(crate) fn try_lock_shared(file: &File) -> io::Result { + normalize_lock_attempt(FileExt::try_lock_shared(file)) +} + +/// Release an advisory lock acquired through the helpers above. +pub(crate) fn unlock_file(file: &File) -> io::Result<()> { + FileExt::unlock(file) +} + +fn normalize_lock_attempt(result: io::Result<()>) -> io::Result { + match result { + Ok(()) => Ok(LockAttempt::Acquired), + Err(error) if lock_is_contended(&error) => Ok(LockAttempt::Contended), + Err(error) => Err(error), + } +} + +fn lock_is_contended(error: &io::Error) -> bool { + if error.kind() == io::ErrorKind::WouldBlock { + return true; + } + #[cfg(windows)] + { + error.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION as i32) + } + #[cfg(not(windows))] + { + false + } +} diff --git a/crates/cli/src/filesystem/mod.rs b/crates/cli/src/filesystem/mod.rs index 75f4703dd..c5c3b76d6 100644 --- a/crates/cli/src/filesystem/mod.rs +++ b/crates/cli/src/filesystem/mod.rs @@ -3,58 +3,16 @@ //! Small, platform-aware filesystem primitives shared by CLI subsystems. +mod locks; + +pub(crate) use locks::{LockAttempt, try_lock_exclusive, try_lock_shared, unlock_file}; + use std::fs::{self, File, OpenOptions, Permissions}; use std::io::{self, Write}; use std::path::Path; #[cfg(test)] use std::path::PathBuf; -use fs2::FileExt; - -/// Result of one nonblocking advisory-file-lock attempt. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum LockAttempt { - Acquired, - Contended, -} - -/// Attempt an exclusive advisory lock without waiting. -pub(crate) fn try_lock_exclusive(file: &File) -> io::Result { - normalize_lock_attempt(FileExt::try_lock_exclusive(file)) -} - -/// Attempt a shared advisory lock without waiting. -pub(crate) fn try_lock_shared(file: &File) -> io::Result { - normalize_lock_attempt(FileExt::try_lock_shared(file)) -} - -/// Release an advisory lock acquired through the helpers above. -pub(crate) fn unlock_file(file: &File) -> io::Result<()> { - FileExt::unlock(file) -} - -fn normalize_lock_attempt(result: io::Result<()>) -> io::Result { - match result { - Ok(()) => Ok(LockAttempt::Acquired), - Err(error) if lock_is_contended(&error) => Ok(LockAttempt::Contended), - Err(error) => Err(error), - } -} - -fn lock_is_contended(error: &io::Error) -> bool { - if error.kind() == io::ErrorKind::WouldBlock { - return true; - } - #[cfg(windows)] - { - error.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION as i32) - } - #[cfg(not(windows))] - { - false - } -} - /// Atomically replace `path` with `bytes`, creating its parent directory when needed. pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { let permissions = fs::metadata(path) From 113ef1be5db53138404702e6b0af6cc125251960 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 21:42:47 -0400 Subject: [PATCH 031/102] refactor(cli): split hook encoding merging and responses Signed-off-by: Will Killian --- crates/cli/src/hooks/encoding.rs | 349 ++++++++++++++++++++ crates/cli/src/hooks/merging.rs | 77 +++++ crates/cli/src/hooks/mod.rs | 544 +------------------------------ crates/cli/src/hooks/response.rs | 119 +++++++ 4 files changed, 562 insertions(+), 527 deletions(-) create mode 100644 crates/cli/src/hooks/encoding.rs create mode 100644 crates/cli/src/hooks/merging.rs create mode 100644 crates/cli/src/hooks/response.rs diff --git a/crates/cli/src/hooks/encoding.rs b/crates/cli/src/hooks/encoding.rs new file mode 100644 index 000000000..24dd1165a --- /dev/null +++ b/crates/cli/src/hooks/encoding.rs @@ -0,0 +1,349 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Hook definition and portable command encoding. + +use super::*; + +pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value { + if agent.uses_direct_hook_entries() { + direct_hooks(agent.hook_events(), command) + } else { + grouped_hooks(agent.hook_events(), command) + } +} + +/// Canonical persistent hook command used by every supported host. +pub(crate) fn persistent_hook_forward_command( + relay: &Path, + agent: CodingAgent, + generation_file: &Path, + generation_token: &str, +) -> Result { + hook_command( + relay, + &persistent_hook_arguments(agent, generation_file, generation_token), + ) +} + +/// Canonical transparent hook command. It embeds the process-private dynamic gateway so hook hosts +/// that filter inherited environment variables cannot redirect delivery to the fixed endpoint. +pub(crate) fn transparent_hook_forward_command( + relay: &Path, + agent: CodingAgent, + gateway_url: &str, +) -> Result { + hook_command(relay, &transparent_hook_arguments(agent, gateway_url)) +} + +#[cfg(test)] +pub(crate) fn transparent_hook_forward_command_for_platform( + relay: &Path, + agent: CodingAgent, + gateway_url: &str, + windows: bool, +) -> String { + hook_command_for_platform( + relay, + &transparent_hook_arguments(agent, gateway_url), + windows, + ) +} + +#[cfg(test)] +pub(crate) fn persistent_hook_forward_command_for_platform( + relay: &Path, + agent: CodingAgent, + generation_file: &Path, + generation_token: &str, + windows: bool, +) -> String { + hook_command_for_platform( + relay, + &persistent_hook_arguments(agent, generation_file, generation_token), + windows, + ) +} + +pub(super) fn transparent_hook_arguments(agent: CodingAgent, gateway_url: &str) -> Vec { + vec![ + "hook-forward".into(), + agent.as_arg().into(), + "--gateway-url".into(), + gateway_url.into(), + "--transparent-run".into(), + ] +} + +pub(super) fn persistent_hook_arguments( + agent: CodingAgent, + generation_file: &Path, + generation_token: &str, +) -> Vec { + vec![ + "hook-forward".into(), + agent.as_arg().into(), + "--gateway-url".into(), + crate::bootstrap::DEFAULT_URL.into(), + "--generation-file".into(), + generation_file.display().to_string(), + "--generation-token".into(), + generation_token.into(), + ] +} + +pub(super) fn hook_command(relay: &Path, arguments: &[String]) -> Result { + #[cfg(windows)] + { + return encoded_windows_hook_command(&windows_powershell_launcher()?, relay, arguments); + } + #[cfg(not(windows))] + { + Ok(posix_hook_command(relay, arguments)) + } +} + +#[cfg(test)] +pub(super) fn hook_command_for_platform( + relay: &Path, + arguments: &[String], + windows: bool, +) -> String { + if windows { + return encoded_windows_hook_command( + "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", + relay, + arguments, + ) + .expect("test hook command must fit within the Windows command-line limit"); + } + posix_hook_command(relay, arguments) +} + +#[cfg(any(not(windows), test))] +pub(super) fn posix_hook_command(relay: &Path, arguments: &[String]) -> String { + std::iter::once(relay.display().to_string()) + .chain(arguments.iter().cloned()) + .map(|argument| crate::agents::host::shell_quote_arg_for_platform(&argument, false)) + .collect::>() + .join(" ") +} + +// `cmd.exe` accepts at most 8,191 characters. Leave room for `/C` and the executable path added +// by the hook host instead of generating a command that will be truncated at runtime. +#[cfg(any(windows, test))] +const MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS: usize = 8_000; + +/// Encode a native Relay invocation so Windows hook hosts can pass it through `cmd.exe /C` as one +/// argument without corrupting quotes in canonical paths. Windows PowerShell is part of the +/// supported Windows platform; it only launches the Rust binary and preserves its standard I/O. +#[cfg(any(windows, test))] +pub(crate) fn encoded_windows_hook_command( + powershell: &str, + relay: &Path, + arguments: &[String], +) -> Result { + const PREFIX: &str = "$ErrorActionPreference='Stop'; & "; + const SUFFIX: &str = "; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE"; + + let invocation = std::iter::once(relay.display().to_string()) + .chain(arguments.iter().cloned()) + .map(|argument| format!("'{}'", argument.replace('\'', "''"))) + .collect::>() + .join(" "); + let script = format!("{PREFIX}{invocation}{SUFFIX}"); + let bytes = script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); + let command = + format!("{powershell} -NoLogo -NoProfile -NonInteractive -EncodedCommand {encoded}"); + if command.encode_utf16().count() > MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS { + return Err(format!( + "generated Windows coding-agent hook command exceeds the {MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS}-character safety limit; shorten the Relay or plugin installation path" + )); + } + Ok(command) +} + +#[cfg(windows)] +pub(super) fn windows_powershell_launcher() -> Result { + let powershell = windows_powershell_path()?; + if !Path::new(&powershell).is_file() { + return Err(format!( + "trusted Windows PowerShell launcher is missing at {powershell}; install Windows PowerShell before configuring coding-agent hooks" + )); + } + Ok(powershell) +} + +#[cfg(windows)] +pub(super) fn windows_powershell_path() -> Result { + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW; + + let mut buffer = vec![0_u16; 260]; + let length = loop { + // SAFETY: `buffer` is writable for its declared length and remains live for the call. + let length = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) }; + if length == 0 { + return Err(format!( + "failed to resolve the trusted Windows system directory: {}", + std::io::Error::last_os_error() + )); + } + if (length as usize) < buffer.len() { + break length as usize; + } + buffer.resize(length as usize + 1, 0); + }; + let system = std::path::PathBuf::from(std::ffi::OsString::from_wide(&buffer[..length])); + let powershell = system.join("WindowsPowerShell/v1.0/powershell.exe"); + let powershell = powershell + .into_os_string() + .into_string() + .map_err(|_| "trusted Windows PowerShell path is not valid Unicode".to_string())? + .replace('\\', "/"); + if !safe_windows_launcher_token(&powershell) { + return Err(format!( + "trusted Windows PowerShell path {powershell} contains characters that cannot be represented safely in coding-agent hook commands" + )); + } + Ok(powershell) +} + +#[cfg(any(windows, test))] +pub(super) fn safe_windows_launcher_token(launcher: &str) -> bool { + !launcher.is_empty() + && launcher.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '/' | ':' | '.' | '_' | '-') + }) + && launcher + .to_ascii_lowercase() + .ends_with("/system32/windowspowershell/v1.0/powershell.exe") +} + +/// Decode only the exact PowerShell envelope emitted by [`encoded_windows_hook_command`]. +/// +/// Hermes uses this to migrate and replace Relay-owned hooks whose generation arguments change. +#[cfg(test)] +pub(crate) fn decode_windows_hook_command(command: &str) -> Option> { + const COMMAND_SEPARATOR: &str = " -NoLogo -NoProfile -NonInteractive -EncodedCommand "; + const SCRIPT_PREFIX: &str = "$ErrorActionPreference='Stop'; & "; + const SCRIPT_SUFFIX: &str = "; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE"; + + if command.encode_utf16().count() > MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS { + return None; + } + let (launcher, encoded) = command.split_once(COMMAND_SEPARATOR)?; + if !safe_windows_launcher_token(launcher) { + return None; + } + #[cfg(windows)] + if !launcher.eq_ignore_ascii_case(&windows_powershell_path().ok()?) { + return None; + } + if encoded.is_empty() || encoded.chars().any(char::is_whitespace) { + return None; + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + let pairs = bytes.chunks_exact(2); + if !pairs.remainder().is_empty() { + return None; + } + let script = String::from_utf16( + &pairs + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(), + ) + .ok()?; + let invocation = script + .strip_prefix(SCRIPT_PREFIX)? + .strip_suffix(SCRIPT_SUFFIX)?; + parse_powershell_single_quoted_arguments(invocation) +} + +#[cfg(test)] +pub(super) fn parse_powershell_single_quoted_arguments(mut raw: &str) -> Option> { + let mut arguments = Vec::new(); + while !raw.is_empty() { + raw = raw.strip_prefix('\'')?; + let mut argument = String::new(); + loop { + let quote = raw.find('\'')?; + argument.push_str(&raw[..quote]); + raw = &raw[quote + 1..]; + if let Some(rest) = raw.strip_prefix('\'') { + argument.push('\''); + raw = rest; + } else { + break; + } + } + arguments.push(argument); + if raw.is_empty() { + break; + } + raw = raw.strip_prefix(' ')?; + if raw.is_empty() { + return None; + } + } + (!arguments.is_empty()).then_some(arguments) +} + +pub(super) fn direct_hooks(events: &[&str], command: &str) -> Value { + let hooks: serde_json::Map = events + .iter() + .map(|event| { + ( + (*event).to_string(), + json!([{ + "command": command, + "timeout": 30 + }]), + ) + }) + .collect(); + json!({ "hooks": Value::Object(hooks) }) +} + +// Generates hook groups for Claude/Codex events and adds a wildcard matcher to tool events when +// the target agent requires matcher-scoped tool hooks. Non-tool events omit matchers so they fire +// for the full lifecycle. +pub(super) fn grouped_hooks(events: &[&str], command: &str) -> Value { + let hooks: serde_json::Map = events + .iter() + .map(|event| { + let mut group = serde_json::Map::new(); + if event_matches_tools(event) { + group.insert("matcher".into(), json!("*")); + } + group.insert( + "hooks".into(), + json!([{ + "type": "command", + "command": command, + "timeout": 30 + }]), + ); + ( + (*event).to_string(), + Value::Array(vec![Value::Object(group)]), + ) + }) + .collect(); + json!({ "hooks": Value::Object(hooks) }) +} + +// Identifies hook events that should receive wildcard tool matchers. The list includes current +// Claude/Codex spellings. +pub(crate) fn event_matches_tools(event: &str) -> bool { + matches!( + event, + "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "PermissionRequest" + ) +} diff --git a/crates/cli/src/hooks/merging.rs b/crates/cli/src/hooks/merging.rs new file mode 100644 index 000000000..bb66f17ff --- /dev/null +++ b/crates/cli/src/hooks/merging.rs @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Non-destructive lifecycle-hook configuration merging. + +use super::*; + +pub(crate) fn merge_hooks(existing: Value, generated: Value) -> Result { + let mut root = hook_config_root(existing)?; + let hooks = hooks_object_mut(&mut root)?; + let generated_hooks = generated_hooks_object(&generated)?; + for (event, groups) in generated_hooks { + merge_event_hook_groups(hooks, event, groups)?; + } + Ok(root) +} + +// Normalizes an existing hook config root. Missing files arrive as `Null`, valid JSON/YAML config +// roots remain objects, and other shapes are rejected before any write can occur. +pub(super) fn hook_config_root(existing: Value) -> Result { + match existing { + Value::Null => Ok(json!({})), + Value::Object(object) => Ok(Value::Object(object)), + _ => Err(CliError::Install( + "hook config must be a JSON object".into(), + )), + } +} + +// Returns the mutable `hooks` object from a config root, creating it when absent. A non-object +// `hooks` field is considered user config corruption and is not overwritten. +pub(super) fn hooks_object_mut( + root: &mut Value, +) -> Result<&mut serde_json::Map, CliError> { + root.as_object_mut() + .expect("root checked as object") + .entry("hooks") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| CliError::Install("hooks must be a JSON object".into())) +} + +// Validates generated hook shape before merging. Generated hooks are internal data, but checking +// here keeps test failures localized if an agent bundle generator regresses. +pub(super) fn generated_hooks_object( + generated: &Value, +) -> Result<&serde_json::Map, CliError> { + generated + .get("hooks") + .and_then(Value::as_object) + .ok_or_else(|| CliError::Install("generated hooks were malformed".into())) +} + +// Appends missing generated groups for one hook event. Equality comparison is exact so repeated +// writes are idempotent without trying to interpret vendor-specific hook group schemas. +pub(super) fn merge_event_hook_groups( + hooks: &mut serde_json::Map, + event: &str, + groups: &Value, +) -> Result<(), CliError> { + let groups = groups + .as_array() + .ok_or_else(|| CliError::Install("generated hook groups were malformed".into()))?; + let event_groups = hooks.entry(event.to_string()).or_insert_with(|| json!([])); + let event_groups = event_groups + .as_array_mut() + .ok_or_else(|| CliError::Install(format!("{event} hooks must be an array")))?; + for group in groups { + if !event_groups.iter().any(|existing| existing == group) { + event_groups.push(group.clone()); + } + } + Ok(()) +} + +// Validates optional JSON strings before they are embedded into hook-forward headers. Catches +// quoting/config mistakes at hook-fire time rather than after the request reaches the gateway. diff --git a/crates/cli/src/hooks/mod.rs b/crates/cli/src/hooks/mod.rs index 6372e26d8..0eb46c678 100644 --- a/crates/cli/src/hooks/mod.rs +++ b/crates/cli/src/hooks/mod.rs @@ -1,6 +1,23 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +//! Hook delivery, command encoding, generated definitions, and configuration merging. + +mod encoding; +mod merging; +mod response; + +#[cfg(test)] +pub(crate) use encoding::{ + decode_windows_hook_command, encoded_windows_hook_command, event_matches_tools, + persistent_hook_forward_command_for_platform, transparent_hook_forward_command_for_platform, +}; +pub(crate) use encoding::{ + generated_hooks, persistent_hook_forward_command, transparent_hook_forward_command, +}; +pub(crate) use merging::merge_hooks; +use response::*; + use std::io::Read; use std::path::Path; use std::time::{Duration, Instant}; @@ -324,533 +341,6 @@ async fn send_hook_forward_request( // Handles hook delivery results without changing agent control flow unless `--fail-closed` was // requested. Successful non-empty endpoint bodies are printed verbatim for the invoking hook API. -async fn handle_hook_forward_response( - response: Result, - fail_closed: bool, -) -> Result<(), CliError> { - match response { - Ok(response) => { - let status = response.status(); - let body = match read_hook_response(response).await { - Ok(body) => body, - Err(error) if fail_closed => return Err(error), - Err(error) => { - eprintln!("nemo-relay hook forward failed: {error}"); - return Ok(()); - } - }; - handle_hook_forward_status(status, body, fail_closed) - } - Err(error) => { - eprintln!("nemo-relay hook forward failed: {error}"); - if fail_closed { - Err(CliError::Upstream(error)) - } else { - Ok(()) - } - } - } -} - -fn handle_verified_hook_forward_response( - response: Result< - crate::gateway::client::VerifiedHttpResponse, - crate::gateway::client::VerifiedHttpError, - >, - fail_closed: bool, -) -> Result<(), CliError> { - match response { - Ok(response) => { - let status = reqwest::StatusCode::from_u16(response.status).map_err(|error| { - CliError::Install(format!( - "verified hook response had an invalid status: {error}" - )) - })?; - handle_hook_forward_status( - status, - String::from_utf8_lossy(&response.body).into_owned(), - fail_closed, - ) - } - Err(error) => { - eprintln!("nemo-relay hook forward failed: {error}"); - if fail_closed { - Err(CliError::Install(format!( - "verified hook forward failed: {error}" - ))) - } else { - Ok(()) - } - } - } -} - -fn handle_hook_forward_status( - status: reqwest::StatusCode, - body: String, - fail_closed: bool, -) -> Result<(), CliError> { - if !status.is_success() { - if let Some(reason) = guardrail_rejection_reason(&body) { - return Err(CliError::GuardrailRejected(reason)); - } - eprintln!("nemo-relay hook forward failed with HTTP {status}"); - if fail_closed { - return Err(CliError::Install(format!( - "hook forward failed with HTTP {status}" - ))); - } - return Ok(()); - } - if !body.is_empty() { - println!("{body}"); - } - Ok(()) -} - -async fn read_hook_response(response: reqwest::Response) -> Result { - let mut stream = response.bytes_stream(); - let mut body = Vec::new(); - while let Some(chunk) = stream.next().await { - let chunk = chunk?; - if body.len().saturating_add(chunk.len()) > MAX_HOOK_RESPONSE_BYTES { - return Err(CliError::Install(format!( - "hook forward response exceeds the {MAX_HOOK_RESPONSE_BYTES}-byte limit" - ))); - } - body.extend_from_slice(&chunk); - } - Ok(String::from_utf8_lossy(&body).into_owned()) -} - -fn guardrail_rejection_reason(body: &str) -> Option { - let value: Value = serde_json::from_str(body).ok()?; - let error = value.get("error")?; - (error.get("type").and_then(Value::as_str) == Some("nemo_relay_guardrail_rejected")) - .then(|| { - error - .get("reason") - .and_then(Value::as_str) - .or_else(|| error.get("message").and_then(Value::as_str)) - .map(ToOwned::to_owned) - }) - .flatten() -} - -/// Generates native hook configuration for the selected agent. -/// -/// The returned value always has a top-level `hooks` object. Claude/Codex use command hook -/// groups with optional tool matchers, while Hermes uses direct command entries. -pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value { - if agent.uses_direct_hook_entries() { - direct_hooks(agent.hook_events(), command) - } else { - grouped_hooks(agent.hook_events(), command) - } -} - -/// Canonical persistent hook command used by every supported host. -pub(crate) fn persistent_hook_forward_command( - relay: &Path, - agent: CodingAgent, - generation_file: &Path, - generation_token: &str, -) -> Result { - hook_command( - relay, - &persistent_hook_arguments(agent, generation_file, generation_token), - ) -} - -/// Canonical transparent hook command. It embeds the process-private dynamic gateway so hook hosts -/// that filter inherited environment variables cannot redirect delivery to the fixed endpoint. -pub(crate) fn transparent_hook_forward_command( - relay: &Path, - agent: CodingAgent, - gateway_url: &str, -) -> Result { - hook_command(relay, &transparent_hook_arguments(agent, gateway_url)) -} - -#[cfg(test)] -pub(crate) fn transparent_hook_forward_command_for_platform( - relay: &Path, - agent: CodingAgent, - gateway_url: &str, - windows: bool, -) -> String { - hook_command_for_platform( - relay, - &transparent_hook_arguments(agent, gateway_url), - windows, - ) -} - -#[cfg(test)] -pub(crate) fn persistent_hook_forward_command_for_platform( - relay: &Path, - agent: CodingAgent, - generation_file: &Path, - generation_token: &str, - windows: bool, -) -> String { - hook_command_for_platform( - relay, - &persistent_hook_arguments(agent, generation_file, generation_token), - windows, - ) -} - -fn transparent_hook_arguments(agent: CodingAgent, gateway_url: &str) -> Vec { - vec![ - "hook-forward".into(), - agent.as_arg().into(), - "--gateway-url".into(), - gateway_url.into(), - "--transparent-run".into(), - ] -} - -fn persistent_hook_arguments( - agent: CodingAgent, - generation_file: &Path, - generation_token: &str, -) -> Vec { - vec![ - "hook-forward".into(), - agent.as_arg().into(), - "--gateway-url".into(), - crate::bootstrap::DEFAULT_URL.into(), - "--generation-file".into(), - generation_file.display().to_string(), - "--generation-token".into(), - generation_token.into(), - ] -} - -fn hook_command(relay: &Path, arguments: &[String]) -> Result { - #[cfg(windows)] - { - return encoded_windows_hook_command(&windows_powershell_launcher()?, relay, arguments); - } - #[cfg(not(windows))] - { - Ok(posix_hook_command(relay, arguments)) - } -} - -#[cfg(test)] -fn hook_command_for_platform(relay: &Path, arguments: &[String], windows: bool) -> String { - if windows { - return encoded_windows_hook_command( - "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", - relay, - arguments, - ) - .expect("test hook command must fit within the Windows command-line limit"); - } - posix_hook_command(relay, arguments) -} - -#[cfg(any(not(windows), test))] -fn posix_hook_command(relay: &Path, arguments: &[String]) -> String { - std::iter::once(relay.display().to_string()) - .chain(arguments.iter().cloned()) - .map(|argument| crate::agents::host::shell_quote_arg_for_platform(&argument, false)) - .collect::>() - .join(" ") -} - -// `cmd.exe` accepts at most 8,191 characters. Leave room for `/C` and the executable path added -// by the hook host instead of generating a command that will be truncated at runtime. -#[cfg(any(windows, test))] -const MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS: usize = 8_000; - -/// Encode a native Relay invocation so Windows hook hosts can pass it through `cmd.exe /C` as one -/// argument without corrupting quotes in canonical paths. Windows PowerShell is part of the -/// supported Windows platform; it only launches the Rust binary and preserves its standard I/O. -#[cfg(any(windows, test))] -fn encoded_windows_hook_command( - powershell: &str, - relay: &Path, - arguments: &[String], -) -> Result { - const PREFIX: &str = "$ErrorActionPreference='Stop'; & "; - const SUFFIX: &str = "; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE"; - - let invocation = std::iter::once(relay.display().to_string()) - .chain(arguments.iter().cloned()) - .map(|argument| format!("'{}'", argument.replace('\'', "''"))) - .collect::>() - .join(" "); - let script = format!("{PREFIX}{invocation}{SUFFIX}"); - let bytes = script - .encode_utf16() - .flat_map(u16::to_le_bytes) - .collect::>(); - let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - let command = - format!("{powershell} -NoLogo -NoProfile -NonInteractive -EncodedCommand {encoded}"); - if command.encode_utf16().count() > MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS { - return Err(format!( - "generated Windows coding-agent hook command exceeds the {MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS}-character safety limit; shorten the Relay or plugin installation path" - )); - } - Ok(command) -} - -#[cfg(windows)] -fn windows_powershell_launcher() -> Result { - let powershell = windows_powershell_path()?; - if !Path::new(&powershell).is_file() { - return Err(format!( - "trusted Windows PowerShell launcher is missing at {powershell}; install Windows PowerShell before configuring coding-agent hooks" - )); - } - Ok(powershell) -} - -#[cfg(windows)] -fn windows_powershell_path() -> Result { - use std::os::windows::ffi::OsStringExt; - use windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW; - - let mut buffer = vec![0_u16; 260]; - let length = loop { - // SAFETY: `buffer` is writable for its declared length and remains live for the call. - let length = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) }; - if length == 0 { - return Err(format!( - "failed to resolve the trusted Windows system directory: {}", - std::io::Error::last_os_error() - )); - } - if (length as usize) < buffer.len() { - break length as usize; - } - buffer.resize(length as usize + 1, 0); - }; - let system = std::path::PathBuf::from(std::ffi::OsString::from_wide(&buffer[..length])); - let powershell = system.join("WindowsPowerShell/v1.0/powershell.exe"); - let powershell = powershell - .into_os_string() - .into_string() - .map_err(|_| "trusted Windows PowerShell path is not valid Unicode".to_string())? - .replace('\\', "/"); - if !safe_windows_launcher_token(&powershell) { - return Err(format!( - "trusted Windows PowerShell path {powershell} contains characters that cannot be represented safely in coding-agent hook commands" - )); - } - Ok(powershell) -} - -#[cfg(any(windows, test))] -fn safe_windows_launcher_token(launcher: &str) -> bool { - !launcher.is_empty() - && launcher.chars().all(|character| { - character.is_ascii_alphanumeric() || matches!(character, '/' | ':' | '.' | '_' | '-') - }) - && launcher - .to_ascii_lowercase() - .ends_with("/system32/windowspowershell/v1.0/powershell.exe") -} - -/// Decode only the exact PowerShell envelope emitted by [`encoded_windows_hook_command`]. -/// -/// Hermes uses this to migrate and replace Relay-owned hooks whose generation arguments change. -#[cfg(test)] -pub(crate) fn decode_windows_hook_command(command: &str) -> Option> { - const COMMAND_SEPARATOR: &str = " -NoLogo -NoProfile -NonInteractive -EncodedCommand "; - const SCRIPT_PREFIX: &str = "$ErrorActionPreference='Stop'; & "; - const SCRIPT_SUFFIX: &str = "; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE"; - - if command.encode_utf16().count() > MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS { - return None; - } - let (launcher, encoded) = command.split_once(COMMAND_SEPARATOR)?; - if !safe_windows_launcher_token(launcher) { - return None; - } - #[cfg(windows)] - if !launcher.eq_ignore_ascii_case(&windows_powershell_path().ok()?) { - return None; - } - if encoded.is_empty() || encoded.chars().any(char::is_whitespace) { - return None; - } - let bytes = base64::engine::general_purpose::STANDARD - .decode(encoded) - .ok()?; - let pairs = bytes.chunks_exact(2); - if !pairs.remainder().is_empty() { - return None; - } - let script = String::from_utf16( - &pairs - .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) - .collect::>(), - ) - .ok()?; - let invocation = script - .strip_prefix(SCRIPT_PREFIX)? - .strip_suffix(SCRIPT_SUFFIX)?; - parse_powershell_single_quoted_arguments(invocation) -} - -#[cfg(test)] -fn parse_powershell_single_quoted_arguments(mut raw: &str) -> Option> { - let mut arguments = Vec::new(); - while !raw.is_empty() { - raw = raw.strip_prefix('\'')?; - let mut argument = String::new(); - loop { - let quote = raw.find('\'')?; - argument.push_str(&raw[..quote]); - raw = &raw[quote + 1..]; - if let Some(rest) = raw.strip_prefix('\'') { - argument.push('\''); - raw = rest; - } else { - break; - } - } - arguments.push(argument); - if raw.is_empty() { - break; - } - raw = raw.strip_prefix(' ')?; - if raw.is_empty() { - return None; - } - } - (!arguments.is_empty()).then_some(arguments) -} - -fn direct_hooks(events: &[&str], command: &str) -> Value { - let hooks: serde_json::Map = events - .iter() - .map(|event| { - ( - (*event).to_string(), - json!([{ - "command": command, - "timeout": 30 - }]), - ) - }) - .collect(); - json!({ "hooks": Value::Object(hooks) }) -} - -// Generates hook groups for Claude/Codex events and adds a wildcard matcher to tool events when -// the target agent requires matcher-scoped tool hooks. Non-tool events omit matchers so they fire -// for the full lifecycle. -fn grouped_hooks(events: &[&str], command: &str) -> Value { - let hooks: serde_json::Map = events - .iter() - .map(|event| { - let mut group = serde_json::Map::new(); - if event_matches_tools(event) { - group.insert("matcher".into(), json!("*")); - } - group.insert( - "hooks".into(), - json!([{ - "type": "command", - "command": command, - "timeout": 30 - }]), - ); - ( - (*event).to_string(), - Value::Array(vec![Value::Object(group)]), - ) - }) - .collect(); - json!({ "hooks": Value::Object(hooks) }) -} - -// Identifies hook events that should receive wildcard tool matchers. The list includes current -// Claude/Codex spellings. -fn event_matches_tools(event: &str) -> bool { - matches!( - event, - "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "PermissionRequest" - ) -} - -/// Merges generated hook groups into an existing hook configuration without duplicating groups. -/// -/// Missing files are represented by `Null` and become empty objects. Existing non-object roots, -/// non-object `hooks`, non-array event hooks, or malformed generated hooks fail closed because -/// writing through those shapes would corrupt user configuration. -pub(crate) fn merge_hooks(existing: Value, generated: Value) -> Result { - let mut root = hook_config_root(existing)?; - let hooks = hooks_object_mut(&mut root)?; - let generated_hooks = generated_hooks_object(&generated)?; - for (event, groups) in generated_hooks { - merge_event_hook_groups(hooks, event, groups)?; - } - Ok(root) -} - -// Normalizes an existing hook config root. Missing files arrive as `Null`, valid JSON/YAML config -// roots remain objects, and other shapes are rejected before any write can occur. -fn hook_config_root(existing: Value) -> Result { - match existing { - Value::Null => Ok(json!({})), - Value::Object(object) => Ok(Value::Object(object)), - _ => Err(CliError::Install( - "hook config must be a JSON object".into(), - )), - } -} - -// Returns the mutable `hooks` object from a config root, creating it when absent. A non-object -// `hooks` field is considered user config corruption and is not overwritten. -fn hooks_object_mut(root: &mut Value) -> Result<&mut serde_json::Map, CliError> { - root.as_object_mut() - .expect("root checked as object") - .entry("hooks") - .or_insert_with(|| json!({})) - .as_object_mut() - .ok_or_else(|| CliError::Install("hooks must be a JSON object".into())) -} - -// Validates generated hook shape before merging. Generated hooks are internal data, but checking -// here keeps test failures localized if an agent bundle generator regresses. -fn generated_hooks_object(generated: &Value) -> Result<&serde_json::Map, CliError> { - generated - .get("hooks") - .and_then(Value::as_object) - .ok_or_else(|| CliError::Install("generated hooks were malformed".into())) -} - -// Appends missing generated groups for one hook event. Equality comparison is exact so repeated -// writes are idempotent without trying to interpret vendor-specific hook group schemas. -fn merge_event_hook_groups( - hooks: &mut serde_json::Map, - event: &str, - groups: &Value, -) -> Result<(), CliError> { - let groups = groups - .as_array() - .ok_or_else(|| CliError::Install("generated hook groups were malformed".into()))?; - let event_groups = hooks.entry(event.to_string()).or_insert_with(|| json!([])); - let event_groups = event_groups - .as_array_mut() - .ok_or_else(|| CliError::Install(format!("{event} hooks must be an array")))?; - for group in groups { - if !event_groups.iter().any(|existing| existing == group) { - event_groups.push(group.clone()); - } - } - Ok(()) -} - -// Validates optional JSON strings before they are embedded into hook-forward headers. Catches -// quoting/config mistakes at hook-fire time rather than after the request reaches the gateway. fn validate_optional_json(name: &str, value: Option<&str>) -> Result<(), CliError> { if let Some(value) = value { serde_json::from_str::(value) diff --git a/crates/cli/src/hooks/response.rs b/crates/cli/src/hooks/response.rs new file mode 100644 index 000000000..36058892a --- /dev/null +++ b/crates/cli/src/hooks/response.rs @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway response handling for forwarded lifecycle hooks. + +use super::*; + +pub(super) async fn handle_hook_forward_response( + response: Result, + fail_closed: bool, +) -> Result<(), CliError> { + match response { + Ok(response) => { + let status = response.status(); + let body = match read_hook_response(response).await { + Ok(body) => body, + Err(error) if fail_closed => return Err(error), + Err(error) => { + eprintln!("nemo-relay hook forward failed: {error}"); + return Ok(()); + } + }; + handle_hook_forward_status(status, body, fail_closed) + } + Err(error) => { + eprintln!("nemo-relay hook forward failed: {error}"); + if fail_closed { + Err(CliError::Upstream(error)) + } else { + Ok(()) + } + } + } +} + +pub(super) fn handle_verified_hook_forward_response( + response: Result< + crate::gateway::client::VerifiedHttpResponse, + crate::gateway::client::VerifiedHttpError, + >, + fail_closed: bool, +) -> Result<(), CliError> { + match response { + Ok(response) => { + let status = reqwest::StatusCode::from_u16(response.status).map_err(|error| { + CliError::Install(format!( + "verified hook response had an invalid status: {error}" + )) + })?; + handle_hook_forward_status( + status, + String::from_utf8_lossy(&response.body).into_owned(), + fail_closed, + ) + } + Err(error) => { + eprintln!("nemo-relay hook forward failed: {error}"); + if fail_closed { + Err(CliError::Install(format!( + "verified hook forward failed: {error}" + ))) + } else { + Ok(()) + } + } + } +} + +pub(super) fn handle_hook_forward_status( + status: reqwest::StatusCode, + body: String, + fail_closed: bool, +) -> Result<(), CliError> { + if !status.is_success() { + if let Some(reason) = guardrail_rejection_reason(&body) { + return Err(CliError::GuardrailRejected(reason)); + } + eprintln!("nemo-relay hook forward failed with HTTP {status}"); + if fail_closed { + return Err(CliError::Install(format!( + "hook forward failed with HTTP {status}" + ))); + } + return Ok(()); + } + if !body.is_empty() { + println!("{body}"); + } + Ok(()) +} + +pub(super) async fn read_hook_response(response: reqwest::Response) -> Result { + let mut stream = response.bytes_stream(); + let mut body = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + if body.len().saturating_add(chunk.len()) > MAX_HOOK_RESPONSE_BYTES { + return Err(CliError::Install(format!( + "hook forward response exceeds the {MAX_HOOK_RESPONSE_BYTES}-byte limit" + ))); + } + body.extend_from_slice(&chunk); + } + Ok(String::from_utf8_lossy(&body).into_owned()) +} + +pub(super) fn guardrail_rejection_reason(body: &str) -> Option { + let value: Value = serde_json::from_str(body).ok()?; + let error = value.get("error")?; + (error.get("type").and_then(Value::as_str) == Some("nemo_relay_guardrail_rejected")) + .then(|| { + error + .get("reason") + .and_then(Value::as_str) + .or_else(|| error.get("message").and_then(Value::as_str)) + .map(ToOwned::to_owned) + }) + .flatten() +} From 4d9aa18eb587a67f1d6f049ae8f2288589b11252 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 21:45:23 -0400 Subject: [PATCH 032/102] refactor(cli): remove horizontal agent host facade Signed-off-by: Will Killian --- crates/cli/src/agents/claude/host.rs | 18 +- crates/cli/src/agents/claude/mod.rs | 2 + crates/cli/src/agents/codex/app_server.rs | 30 +-- crates/cli/src/agents/codex/host.rs | 108 ++++----- crates/cli/src/agents/codex/mod.rs | 3 + crates/cli/src/agents/hermes/config.rs | 2 +- crates/cli/src/agents/hermes/integration.rs | 4 +- crates/cli/src/agents/host.rs | 226 ------------------ crates/cli/src/agents/install/host.rs | 4 +- crates/cli/src/agents/install/setup.rs | 2 +- crates/cli/src/agents/mod.rs | 212 +++++++++++++++- crates/cli/src/agents/shared/host.rs | 30 +-- crates/cli/src/agents/shared/mod.rs | 1 + crates/cli/src/bootstrap/mod.rs | 2 +- crates/cli/src/hooks/encoding.rs | 2 +- crates/cli/src/process/launcher.rs | 4 +- .../coverage/agents/plugin_install_tests.rs | 2 +- 17 files changed, 321 insertions(+), 331 deletions(-) delete mode 100644 crates/cli/src/agents/host.rs diff --git a/crates/cli/src/agents/claude/host.rs b/crates/cli/src/agents/claude/host.rs index 91cfa68b8..3fba4bcb1 100644 --- a/crates/cli/src/agents/claude/host.rs +++ b/crates/cli/src/agents/claude/host.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; -use super::shared::{ +use crate::agents::shared::host::{ FileSnapshot, backup, backup_path, home_dir, read_json_object, remove_backup, restore_file_snapshot, snapshot_optional_file, write_json, }; @@ -39,7 +39,7 @@ pub(crate) fn restore_claude_setup(snapshot: &ClaudeSetupSnapshot) -> Result<(), } } -pub(super) fn enable_claude_provider(gateway_url: &str) -> Result<(), String> { +pub(crate) fn enable_claude_provider(gateway_url: &str) -> Result<(), String> { let path = claude_settings_path()?; let mut settings = read_json_object(&path)?; if settings.get("env").is_some_and(|env| !env.is_object()) { @@ -66,7 +66,7 @@ pub(super) fn enable_claude_provider(gateway_url: &str) -> Result<(), String> { Ok(()) } -pub(super) fn restore_claude_provider(gateway_url: &str) -> Result<(), String> { +pub(crate) fn restore_claude_provider(gateway_url: &str) -> Result<(), String> { let path = claude_settings_path()?; let backup = backup_path(&path); if !backup.exists() { @@ -96,7 +96,7 @@ pub(super) fn restore_claude_provider(gateway_url: &str) -> Result<(), String> { Ok(()) } -pub(super) fn json_env_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> { +pub(crate) fn json_env_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> { value .get("env") .and_then(Value::as_object) @@ -104,7 +104,7 @@ pub(super) fn json_env_string<'a>(value: &'a Value, key: &str) -> Option<&'a str .and_then(Value::as_str) } -pub(super) fn remove_json_env_string(value: &mut Value, key: &str) -> Result { +pub(crate) fn remove_json_env_string(value: &mut Value, key: &str) -> Result { let Some(object) = value.as_object_mut() else { return Err("Claude settings must be a JSON object".into()); }; @@ -121,7 +121,7 @@ pub(super) fn remove_json_env_string(value: &mut Value, key: &str) -> Result Result<(), String> { +pub(crate) fn backup_claude_settings(path: &Path, replace_existing: bool) -> Result<(), String> { let backup_file = backup_path(path); if backup_file.exists() && !replace_existing { return Ok(()); @@ -171,11 +171,11 @@ pub(super) fn backup_claude_settings(path: &Path, replace_existing: bool) -> Res } } -pub(super) fn claude_settings_path() -> Result { +pub(crate) fn claude_settings_path() -> Result { Ok(home_dir()?.join(".claude").join("settings.json")) } -pub(super) fn claude_settings_base_url() -> Option { +pub(crate) fn claude_settings_base_url() -> Option { let path = claude_settings_path().ok()?; let value = read_json_object(&path).ok()?; value diff --git a/crates/cli/src/agents/claude/mod.rs b/crates/cli/src/agents/claude/mod.rs index 949d751e3..cf1bb6338 100644 --- a/crates/cli/src/agents/claude/mod.rs +++ b/crates/cli/src/agents/claude/mod.rs @@ -5,6 +5,8 @@ use semver::Version; use super::AgentDescriptor; +pub(super) mod host; + pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { argument: "claude", install_argument: "claude-code", diff --git a/crates/cli/src/agents/codex/app_server.rs b/crates/cli/src/agents/codex/app_server.rs index 8ff5f59b0..3f6d1ca7c 100644 --- a/crates/cli/src/agents/codex/app_server.rs +++ b/crates/cli/src/agents/codex/app_server.rs @@ -17,28 +17,28 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub(super) struct CodexHookMetadata { - pub(super) key: String, - pub(super) event_name: String, - pub(super) handler_type: String, - pub(super) command: Option, - pub(super) source_path: String, - pub(super) source: String, +pub(crate) struct CodexHookMetadata { + pub(crate) key: String, + pub(crate) event_name: String, + pub(crate) handler_type: String, + pub(crate) command: Option, + pub(crate) source_path: String, + pub(crate) source: String, #[serde(default)] - pub(super) plugin_id: Option, - pub(super) enabled: bool, - pub(super) current_hash: String, - pub(super) trust_status: String, + pub(crate) plugin_id: Option, + pub(crate) enabled: bool, + pub(crate) current_hash: String, + pub(crate) trust_status: String, } -pub(super) trait CodexHooksClient { +pub(crate) trait CodexHooksClient { fn list_hooks(&mut self, cwd: &Path) -> Result, String>; fn trust_hooks(&mut self, hooks: &[CodexHookMetadata]) -> Result<(), String>; fn clear_hook_trust(&mut self, keys: &[String]) -> Result<(), String>; fn restore_hook_trust(&mut self, state: &[(String, Option)]) -> Result<(), String>; } -pub(super) struct CodexAppServerClient { +pub(crate) struct CodexAppServerClient { child: Child, stdin: ChildStdin, messages: Receiver>, @@ -46,7 +46,7 @@ pub(super) struct CodexAppServerClient { } impl CodexAppServerClient { - pub(super) fn start() -> Result { + pub(crate) fn start() -> Result { let mut command = codex_app_server_command(); let mut child = command .stdin(Stdio::piped()) @@ -220,7 +220,7 @@ impl Drop for CodexAppServerClient { } } -pub(super) fn hook_state_key_path(key: &str) -> String { +pub(crate) fn hook_state_key_path(key: &str) -> String { let quoted = serde_json::to_string(key).expect("serializing a string cannot fail"); format!("hooks.state.{quoted}") } diff --git a/crates/cli/src/agents/codex/host.rs b/crates/cli/src/agents/codex/host.rs index 57a55e9d5..570bc777e 100644 --- a/crates/cli/src/agents/codex/host.rs +++ b/crates/cli/src/agents/codex/host.rs @@ -19,16 +19,16 @@ use crate::hooks::generated_hooks; #[cfg(test)] use crate::hooks::merge_hooks; -use super::codex_app_server::{CodexAppServerClient, CodexHookMetadata, CodexHooksClient}; -use super::shared::{ +use super::app_server::{CodexAppServerClient, CodexHookMetadata, CodexHooksClient}; +use crate::agents::shared::host::{ FileSnapshot, atomic_write, atomic_write_private, backup, backup_path, current_exe, ensure_table, home_dir, portable_executable_path, read_json_object, remove_backup, restore_file_snapshot, shell_quote, shell_quote_arg_for_platform, snapshot_optional_file, write_json, }; -pub(super) const CODEX_PLUGIN_ID: &str = RELAY_PLUGIN_ID; -pub(super) const CODEX_PLUGIN_HOOK_KEY_PREFIX: &str = +pub(crate) const CODEX_PLUGIN_ID: &str = RELAY_PLUGIN_ID; +pub(crate) const CODEX_PLUGIN_HOOK_KEY_PREFIX: &str = "nemo-relay-plugin@nemo-relay-local:hooks/hooks.json:"; pub(crate) struct CodexSetupSnapshot { @@ -81,14 +81,14 @@ pub(crate) fn restore_codex_setup(snapshot: &CodexSetupSnapshot) -> Result<(), S } #[cfg(test)] -pub(super) fn install_codex( +pub(crate) fn install_codex( gateway_url: &str, plugin_hooks_path: &Path, ) -> Result { install_codex_with_generation(gateway_url, plugin_hooks_path, None) } -pub(super) fn install_codex_with_generation( +pub(crate) fn install_codex_with_generation( gateway_url: &str, plugin_hooks_path: &Path, generation_token: Option<&str>, @@ -106,7 +106,7 @@ pub(super) fn install_codex_with_generation( ) } -pub(super) fn install_codex_with_trust( +pub(crate) fn install_codex_with_trust( gateway_url: &str, expected_command: &str, trust_hooks: F, @@ -141,7 +141,7 @@ where Ok(ExitCode::SUCCESS) } -pub(super) fn uninstall_codex( +pub(crate) fn uninstall_codex( installed_gateway_url: &str, _plugin_hooks_path: &Path, ) -> Result { @@ -149,7 +149,7 @@ pub(super) fn uninstall_codex( uninstall_codex_with_client(installed_gateway_url, Some(&mut client)) } -pub(super) fn uninstall_codex_with_client( +pub(crate) fn uninstall_codex_with_client( installed_gateway_url: &str, client: Option<&mut dyn CodexHooksClient>, ) -> Result { @@ -182,7 +182,7 @@ pub(super) fn uninstall_codex_with_client( } #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub(super) struct CodexHookTrustReport { +pub(crate) struct CodexHookTrustReport { trusted: Vec, untrusted: Vec, modified: Vec, @@ -192,7 +192,7 @@ pub(super) struct CodexHookTrustReport { } impl CodexHookTrustReport { - pub(super) fn ready(&self) -> bool { + pub(crate) fn ready(&self) -> bool { self.untrusted.is_empty() && self.modified.is_empty() && self.disabled.is_empty() @@ -201,7 +201,7 @@ impl CodexHookTrustReport { && !self.trusted.is_empty() } - pub(super) fn to_json(&self) -> Value { + pub(crate) fn to_json(&self) -> Value { json!({ "trusted": self.trusted, "untrusted": self.untrusted, @@ -212,7 +212,7 @@ impl CodexHookTrustReport { }) } - pub(super) fn summary(&self) -> String { + pub(crate) fn summary(&self) -> String { format!( "untrusted={}, modified={}, disabled={}, missing required={}, duplicate required={}", self.untrusted.len(), @@ -224,7 +224,7 @@ impl CodexHookTrustReport { } } -pub(super) fn empty_codex_hook_trust_report() -> CodexHookTrustReport { +pub(crate) fn empty_codex_hook_trust_report() -> CodexHookTrustReport { CodexHookTrustReport { missing_required: CodingAgent::Codex .hook_events() @@ -235,13 +235,13 @@ pub(super) fn empty_codex_hook_trust_report() -> CodexHookTrustReport { } } -pub(super) fn codex_hook_trust_report( +pub(crate) fn codex_hook_trust_report( plugin_hooks_path: &Path, ) -> Result { codex_hook_trust_report_with_generation(plugin_hooks_path, None) } -pub(super) fn codex_hook_trust_report_with_generation( +pub(crate) fn codex_hook_trust_report_with_generation( plugin_hooks_path: &Path, generation_token: Option<&str>, ) -> Result { @@ -253,7 +253,7 @@ pub(super) fn codex_hook_trust_report_with_generation( codex_hook_trust_report_with_client(&mut client, &home, &expected_command) } -pub(super) fn codex_hook_trust_report_with_client( +pub(crate) fn codex_hook_trust_report_with_client( client: &mut dyn CodexHooksClient, cwd: &Path, expected_command: &str, @@ -262,7 +262,7 @@ pub(super) fn codex_hook_trust_report_with_client( Ok(codex_hook_trust_report_for(&hooks)) } -pub(super) fn auto_trust_codex_hooks( +pub(crate) fn auto_trust_codex_hooks( client: &mut dyn CodexHooksClient, cwd: &Path, config_path: &Path, @@ -410,7 +410,7 @@ fn clear_and_verify_hook_trust( } } -pub(super) fn configured_hook_trust_keys(config_path: &Path) -> Result, String> { +pub(crate) fn configured_hook_trust_keys(config_path: &Path) -> Result, String> { let raw = read_optional_text(config_path)?; let config = toml::from_str::(&raw) .map_err(|error| format!("invalid TOML in {}: {error}", config_path.display()))?; @@ -568,7 +568,7 @@ fn restore_hook_trust_after_failure( } #[cfg(test)] -pub(super) fn expected_plugin_hook_command(plugin_hooks_path: &Path) -> Result { +pub(crate) fn expected_plugin_hook_command(plugin_hooks_path: &Path) -> Result { expected_plugin_hook_command_with_token(plugin_hooks_path, None) } @@ -626,7 +626,7 @@ fn validate_plugin_hooks(path: &Path, expected_command: &str) -> Result<(), Stri } } -pub(super) fn codex_hook_trust_report_for(hooks: &[CodexHookMetadata]) -> CodexHookTrustReport { +pub(crate) fn codex_hook_trust_report_for(hooks: &[CodexHookMetadata]) -> CodexHookTrustReport { let mut report = CodexHookTrustReport::default(); for hook in hooks { match hook.trust_status.as_str() { @@ -708,14 +708,14 @@ fn restore_codex_install_snapshots(snapshots: &[FileSnapshot]) -> Result<(), Str } } -pub(super) fn prepare_codex_config(path: &Path) -> Result<(), String> { +pub(crate) fn prepare_codex_config(path: &Path) -> Result<(), String> { let raw = read_optional_text(path)?; raw.parse::() .map(|_| ()) .map_err(|error| format!("invalid TOML in {}: {error}", path.display())) } -pub(super) fn install_codex_config(path: &Path, gateway_url: &str) -> Result<(), String> { +pub(crate) fn install_codex_config(path: &Path, gateway_url: &str) -> Result<(), String> { let challenge = BootstrapChallengeKey::load().map_err(|error| error.to_string())?; let client_token = challenge.client_token(); let raw = read_optional_text(path)?; @@ -1087,7 +1087,7 @@ fn codex_provider_has_only_generated_fields(doc: &DocumentMut) -> bool { }) } -pub(super) fn codex_provider_header<'a>(doc: &'a DocumentMut, name: &str) -> Option<&'a TomlValue> { +pub(crate) fn codex_provider_header<'a>(doc: &'a DocumentMut, name: &str) -> Option<&'a TomlValue> { let headers = doc .get("model_providers") .and_then(Item::as_table) @@ -1105,7 +1105,7 @@ pub(super) fn codex_provider_header<'a>(doc: &'a DocumentMut, name: &str) -> Opt }) } -pub(super) fn read_optional_text(path: &Path) -> Result { +pub(crate) fn read_optional_text(path: &Path) -> Result { match fs::read_to_string(path) { Ok(raw) => Ok(raw), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), @@ -1113,7 +1113,7 @@ pub(super) fn read_optional_text(path: &Path) -> Result { } } -pub(super) fn uninstall_codex_config( +pub(crate) fn uninstall_codex_config( path: &Path, gateway_url: &str, preserve_hooks: bool, @@ -1205,7 +1205,7 @@ fn remove_codex_config_without_backup( } } -pub(super) fn remove_legacy_codex_hooks(path: &Path) -> Result<(), String> { +pub(crate) fn remove_legacy_codex_hooks(path: &Path) -> Result<(), String> { if !path.exists() { return Ok(()); } @@ -1221,7 +1221,7 @@ pub(super) fn remove_legacy_codex_hooks(path: &Path) -> Result<(), String> { } #[cfg(test)] -pub(super) fn install_codex_hooks(path: &Path, gateway_url: &str) -> Result<(), String> { +pub(crate) fn install_codex_hooks(path: &Path, gateway_url: &str) -> Result<(), String> { let relay = current_exe()?; let command = codex_hook_command(gateway_url); let generated = generated_hooks(CodingAgent::Codex, &command); @@ -1245,7 +1245,7 @@ pub(super) fn install_codex_hooks(path: &Path, gateway_url: &str) -> Result<(), atomic_write(path, &output) } -pub(super) fn uninstall_codex_hooks(path: &Path, _gateway_url: &str) -> Result { +pub(crate) fn uninstall_codex_hooks(path: &Path, _gateway_url: &str) -> Result { if !path.exists() { return Ok(false); } @@ -1257,7 +1257,7 @@ pub(super) fn uninstall_codex_hooks(path: &Path, _gateway_url: &str) -> Result, @@ -1330,7 +1330,7 @@ fn legacy_relay_hook_command(command: &str) -> bool { } #[cfg(test)] -pub(super) fn hook_config_contains_generated_groups(existing: &Value, generated: &Value) -> bool { +pub(crate) fn hook_config_contains_generated_groups(existing: &Value, generated: &Value) -> bool { let Some(generated_hooks) = generated.get("hooks").and_then(Value::as_object) else { return false; }; @@ -1344,7 +1344,7 @@ pub(super) fn hook_config_contains_generated_groups(existing: &Value, generated: } #[cfg(test)] -pub(super) fn generated_event_contains_group(config: &Value, event: &str, group: &Value) -> bool { +pub(crate) fn generated_event_contains_group(config: &Value, event: &str, group: &Value) -> bool { config .get("hooks") .and_then(Value::as_object) @@ -1353,7 +1353,7 @@ pub(super) fn generated_event_contains_group(config: &Value, event: &str, group: .is_some_and(|groups| groups.iter().any(|existing| existing == group)) } -pub(super) fn hook_config_has_hook_groups(config: &Value) -> bool { +pub(crate) fn hook_config_has_hook_groups(config: &Value) -> bool { config .get("hooks") .and_then(Value::as_object) @@ -1364,7 +1364,7 @@ pub(super) fn hook_config_has_hook_groups(config: &Value) -> bool { }) } -pub(super) fn codex_config_doc_has_managed_install(doc: &DocumentMut, gateway_url: &str) -> bool { +pub(crate) fn codex_config_doc_has_managed_install(doc: &DocumentMut, gateway_url: &str) -> bool { doc.get("model_provider") .and_then(Item::as_value) .and_then(|value| value.as_str()) @@ -1374,7 +1374,7 @@ pub(super) fn codex_config_doc_has_managed_install(doc: &DocumentMut, gateway_ur } #[cfg(test)] -pub(super) fn codex_provider_gateway_url(path: &Path) -> Option { +pub(crate) fn codex_provider_gateway_url(path: &Path) -> Option { let raw = fs::read_to_string(path).ok()?; let doc = raw.parse::().ok()?; doc.get("model_providers") @@ -1387,7 +1387,7 @@ pub(super) fn codex_provider_gateway_url(path: &Path) -> Option { .map(ToOwned::to_owned) } -pub(super) fn restore_top_level_item(doc: &mut DocumentMut, backup: &DocumentMut, key: &str) { +pub(crate) fn restore_top_level_item(doc: &mut DocumentMut, backup: &DocumentMut, key: &str) { if let Some(item) = backup.as_table().get(key).cloned() { doc.as_table_mut().insert(key, item); } else { @@ -1395,7 +1395,7 @@ pub(super) fn restore_top_level_item(doc: &mut DocumentMut, backup: &DocumentMut } } -pub(super) fn restore_top_level_item_if_str( +pub(crate) fn restore_top_level_item_if_str( doc: &mut DocumentMut, backup: &DocumentMut, key: &str, @@ -1413,7 +1413,7 @@ fn top_level_item_is_str(doc: &DocumentMut, key: &str, expected: &str) -> bool { == Some(expected) } -pub(super) fn restore_table_item( +pub(crate) fn restore_table_item( doc: &mut DocumentMut, backup: &DocumentMut, table: &str, @@ -1431,7 +1431,7 @@ pub(super) fn restore_table_item( } } -pub(super) fn restore_table_item_if_bool( +pub(crate) fn restore_table_item_if_bool( doc: &mut DocumentMut, backup: &DocumentMut, table: &str, @@ -1449,7 +1449,7 @@ pub(super) fn restore_table_item_if_bool( } } -pub(super) fn codex_provider_item_is_managed(doc: &DocumentMut, gateway_url: &str) -> bool { +pub(crate) fn codex_provider_item_is_managed(doc: &DocumentMut, gateway_url: &str) -> bool { doc.get("model_providers") .and_then(Item::as_table) .and_then(|providers| providers.get("nemo-relay-openai")) @@ -1457,7 +1457,7 @@ pub(super) fn codex_provider_item_is_managed(doc: &DocumentMut, gateway_url: &st .is_some_and(|provider| codex_provider_table_is_managed_for_gateway(provider, gateway_url)) } -pub(super) fn codex_provider_table_is_managed_for_gateway( +pub(crate) fn codex_provider_table_is_managed_for_gateway( provider: &Table, gateway_url: &str, ) -> bool { @@ -1488,7 +1488,7 @@ pub(super) fn codex_provider_table_is_managed_for_gateway( == Some(false) } -pub(super) fn feature_hooks_enabled(doc: &DocumentMut) -> Option { +pub(crate) fn feature_hooks_enabled(doc: &DocumentMut) -> Option { doc.get("features") .and_then(Item::as_table) .and_then(|table| table.get("hooks")) @@ -1496,7 +1496,7 @@ pub(super) fn feature_hooks_enabled(doc: &DocumentMut) -> Option { .and_then(|value| value.as_bool()) } -pub(super) fn remove_empty_table(doc: &mut DocumentMut, key: &str) { +pub(crate) fn remove_empty_table(doc: &mut DocumentMut, key: &str) { let is_empty = doc .get(key) .and_then(Item::as_table) @@ -1506,7 +1506,7 @@ pub(super) fn remove_empty_table(doc: &mut DocumentMut, key: &str) { } } -pub(super) fn remove_table_item_if_bool( +pub(crate) fn remove_table_item_if_bool( doc: &mut DocumentMut, table: &str, key: &str, @@ -1524,7 +1524,7 @@ pub(super) fn remove_table_item_if_bool( } } -pub(super) fn codex_provider_installed(gateway_url: &str) -> bool { +pub(crate) fn codex_provider_installed(gateway_url: &str) -> bool { let Ok(path) = codex_home_dir().map(|home| home.join("config.toml")) else { return false; }; @@ -1541,7 +1541,7 @@ pub(super) fn codex_provider_installed(gateway_url: &str) -> bool { && codex_provider_client_token(&doc).is_some_and(|token| key.verify_client_token(token)) } -pub(super) fn codex_provider_client_token(doc: &DocumentMut) -> Option<&str> { +pub(crate) fn codex_provider_client_token(doc: &DocumentMut) -> Option<&str> { doc.get("model_providers") .and_then(Item::as_table) .and_then(|providers| providers.get("nemo-relay-openai")) @@ -1552,11 +1552,11 @@ pub(super) fn codex_provider_client_token(doc: &DocumentMut) -> Option<&str> { .and_then(TomlValue::as_str) } -pub(super) fn codex_hooks_installed(path: &Path) -> Result { +pub(crate) fn codex_hooks_installed(path: &Path) -> Result { codex_hooks_installed_with_generation(path, None) } -pub(super) fn codex_hooks_installed_with_generation( +pub(crate) fn codex_hooks_installed_with_generation( path: &Path, generation_token: Option<&str>, ) -> Result { @@ -1568,21 +1568,21 @@ pub(super) fn codex_hooks_installed_with_generation( Ok(value == generated) } -pub(super) fn codex_home_dir() -> Result { +pub(crate) fn codex_home_dir() -> Result { if let Some(path) = env::var_os("CODEX_HOME").filter(|path| !path.is_empty()) { return Ok(PathBuf::from(path)); } Ok(home_dir()?.join(".codex")) } -pub(super) fn codex_hook_command(gateway_url: &str) -> String { +pub(crate) fn codex_hook_command(gateway_url: &str) -> String { format!( "nemo-relay hook-forward codex --gateway-url {}", shell_quote_arg_for_platform(gateway_url, cfg!(windows)) ) } -pub(super) fn codex_plugin_hook_command( +pub(crate) fn codex_plugin_hook_command( relay: &Path, generation: &Path, generation_token: &str, @@ -1596,7 +1596,7 @@ pub(super) fn codex_plugin_hook_command( } #[cfg(test)] -pub(super) fn codex_plugin_hook_command_for_platform( +pub(crate) fn codex_plugin_hook_command_for_platform( relay: &Path, generation: &Path, generation_token: &str, @@ -1611,10 +1611,10 @@ pub(super) fn codex_plugin_hook_command_for_platform( ) } -pub(super) fn legacy_codex_hook_command(relay: &Path) -> String { +pub(crate) fn legacy_codex_hook_command(relay: &Path) -> String { format!("{} plugin-shim hook codex", shell_quote(relay)) } -pub(super) fn legacy_named_codex_hook_command() -> &'static str { +pub(crate) fn legacy_named_codex_hook_command() -> &'static str { "nemo-relay plugin-shim hook codex" } diff --git a/crates/cli/src/agents/codex/mod.rs b/crates/cli/src/agents/codex/mod.rs index f991bd833..e983d7c0e 100644 --- a/crates/cli/src/agents/codex/mod.rs +++ b/crates/cli/src/agents/codex/mod.rs @@ -5,6 +5,9 @@ use semver::Version; use super::AgentDescriptor; +pub(super) mod app_server; +pub(super) mod host; + pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { argument: "codex", install_argument: "codex", diff --git a/crates/cli/src/agents/hermes/config.rs b/crates/cli/src/agents/hermes/config.rs index 9ef20c962..d76a9956a 100644 --- a/crates/cli/src/agents/hermes/config.rs +++ b/crates/cli/src/agents/hermes/config.rs @@ -270,7 +270,7 @@ fn legacy_owned_command(root: &Value, relay: &Path) -> Result, Cl fn legacy_command_uses_relay(command: &str, relay: &Path) -> bool { let relay = relay.to_string_lossy(); - let quoted = crate::agents::host::shell_quote_arg_for_platform(&relay, cfg!(windows)); + let quoted = crate::agents::shell_quote_arg_for_platform(&relay, cfg!(windows)); [relay.as_ref(), quoted.as_str()].into_iter().any(|prefix| { command.strip_prefix(prefix).is_some_and(|arguments| { [" hook-forward hermes", " plugin-shim hook hermes"] diff --git a/crates/cli/src/agents/hermes/integration.rs b/crates/cli/src/agents/hermes/integration.rs index 3ea1823e3..7ddd30707 100644 --- a/crates/cli/src/agents/hermes/integration.rs +++ b/crates/cli/src/agents/hermes/integration.rs @@ -41,7 +41,7 @@ pub(crate) fn user_config_path(default_home: &Path) -> PathBuf { pub(crate) fn install_persistent(config: &Path, relay: &Path) -> Result, CliError> { let relay = relay.canonicalize().unwrap_or_else(|_| relay.to_path_buf()); - let relay = crate::agents::host::portable_executable_path(relay); + let relay = crate::agents::portable_executable_path(relay); if !relay_is_executable(&relay) { return Err(CliError::Install(format!( "nemo-relay executable is missing or not executable at {}", @@ -104,7 +104,7 @@ fn retire_generation_before_gateway_stop( .invalidate_for_replacement() .map_err(CliError::Install)?; } - if let Err(error) = crate::agents::host::stop_plugin_gateway() { + if let Err(error) = crate::agents::stop_plugin_gateway() { if let Some(retirement) = retirement.as_mut() && let Err(restore_error) = retirement.restore_after_rollback() { diff --git a/crates/cli/src/agents/host.rs b/crates/cli/src/agents/host.rs deleted file mode 100644 index adcb3b0ee..000000000 --- a/crates/cli/src/agents/host.rs +++ /dev/null @@ -1,226 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Transactional host configuration for packaged coding-agent plugins. - -#[path = "claude/host.rs"] -mod claude; -#[path = "codex/host.rs"] -mod codex; -#[path = "codex/app_server.rs"] -mod codex_app_server; -#[path = "shared/host.rs"] -mod shared; - -pub(crate) use claude::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; -pub(crate) use codex::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; -pub(crate) use shared::portable_executable_path; -pub(crate) use shared::shell_quote_arg_for_platform; -#[cfg(test)] -pub(crate) use shared::strip_windows_verbatim_prefix; - -use std::path::Path; - -use serde_json::{Value, json}; - -use claude::claude_settings_base_url; -use codex::{ - codex_hook_trust_report, codex_hook_trust_report_with_generation, codex_hooks_installed, - codex_hooks_installed_with_generation, codex_provider_installed, empty_codex_hook_trust_report, - install_codex_with_generation, uninstall_codex, -}; -use shared::{current_exe, healthz, print_check, print_info}; - -use crate::configuration::CodingAgent; - -#[cfg(test)] -pub(super) use crate::bootstrap::DEFAULT_URL; - -pub(crate) fn install_codex_plugin_with_generation( - gateway_url: &str, - plugin_root: &Path, - generation_token: Option<&str>, -) -> Result<(), String> { - install_codex_with_generation( - gateway_url, - &plugin_root.join("hooks").join("hooks.json"), - generation_token, - ) - .map(|_| ()) -} - -pub(crate) fn stop_plugin_gateway() -> Result<(), String> { - crate::bootstrap::state::stop_owned_and_reset(crate::bootstrap::DEFAULT_URL) -} - -pub(crate) fn uninstall_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Result<(), String> { - uninstall_codex(gateway_url, &plugin_root.join("hooks").join("hooks.json")).map(|_| ()) -} - -pub(crate) fn enable_claude_provider(gateway_url: &str) -> Result<(), String> { - claude::enable_claude_provider(gateway_url) -} - -pub(crate) fn restore_claude_provider(gateway_url: &str) -> Result<(), String> { - claude::restore_claude_provider(gateway_url) -} - -pub(crate) fn doctor_plugin( - agent: CodingAgent, - gateway_url: &str, - plugin_root: &Path, -) -> Result<(), String> { - doctor_plugin_with_generation(agent, gateway_url, plugin_root, None) -} - -pub(crate) fn doctor_plugin_with_generation( - agent: CodingAgent, - gateway_url: &str, - plugin_root: &Path, - generation_token: Option<&str>, -) -> Result<(), String> { - if doctor_ok( - agent, - gateway_url, - Some(&plugin_root.join("hooks").join("hooks.json")), - generation_token, - )? { - Ok(()) - } else { - Err(format!("{} plugin doctor checks failed", agent.as_arg())) - } -} - -pub(crate) fn doctor_plugin_json( - agent: CodingAgent, - gateway_url: &str, - plugin_root: &Path, -) -> Result { - let plugin_binary = current_exe().ok().is_some_and(|path| path.exists()); - let sidecar_running = healthz(gateway_url); - let (checks, ok, codex_trust) = match agent { - CodingAgent::ClaudeCode => { - let provider = claude_settings_base_url().as_deref() == Some(gateway_url); - ( - json!({ - "plugin_binary": plugin_binary, - "sidecar_running": sidecar_running, - "claude_provider_routing": provider - }), - plugin_binary && provider, - None, - ) - } - CodingAgent::Codex => { - let plugin_hooks_path = plugin_root.join("hooks").join("hooks.json"); - let provider = codex_provider_installed(gateway_url); - let hooks = codex_hooks_installed(&plugin_hooks_path)?; - let trust = if hooks { - codex_hook_trust_report(&plugin_hooks_path)? - } else { - empty_codex_hook_trust_report() - }; - let hooks_trusted = trust.ready(); - ( - json!({ - "plugin_binary": plugin_binary, - "sidecar_running": sidecar_running, - "codex_provider_alias": provider, - "codex_hooks": hooks, - "codex_hooks_trusted": hooks_trusted - }), - plugin_binary && provider && hooks && hooks_trusted, - Some(trust), - ) - } - other => { - return Err(format!( - "plugin doctor supports claude and codex, got {}", - other.as_arg() - )); - } - }; - let mut report = json!({ - "ok": ok, - "sidecar_health": if sidecar_running { - "running" - } else { - "not_running_mcp_start" - }, - "checks": checks - }); - if let Some(trust) = codex_trust { - report["codex_hook_trust"] = trust.to_json(); - } - Ok(report) -} - -fn doctor_ok( - agent: CodingAgent, - gateway_url: &str, - plugin_hooks_path: Option<&Path>, - generation_token: Option<&str>, -) -> Result { - let mut ok = true; - ok &= print_check( - "plugin binary", - current_exe().ok().is_some_and(|path| path.exists()), - ); - if healthz(gateway_url) { - print_info("sidecar health", "running"); - } else { - print_info( - "sidecar health", - "not running; the plugin MCP starts it when the host launches", - ); - } - match agent { - CodingAgent::ClaudeCode => { - ok &= print_check( - "claude provider routing", - claude_settings_base_url().as_deref() == Some(gateway_url), - ); - } - CodingAgent::Codex => { - let plugin_hooks_path = plugin_hooks_path - .ok_or_else(|| "Codex plugin hooks path is required for doctor".to_string())?; - let provider = codex_provider_installed(gateway_url); - let hooks = codex_hooks_installed_with_generation(plugin_hooks_path, generation_token)?; - ok &= print_check("codex provider alias", provider); - ok &= print_check("codex hooks", hooks); - let trust = if hooks { - codex_hook_trust_report_with_generation(plugin_hooks_path, generation_token)? - } else { - empty_codex_hook_trust_report() - }; - ok &= print_check("codex hooks trusted and enabled", trust.ready()); - if !trust.ready() { - print_info("codex hook trust", &trust.summary()); - } - } - other => { - return Err(format!( - "plugin doctor supports claude and codex, got {}", - other.as_arg() - )); - } - } - Ok(ok) -} - -#[cfg(test)] -use crate::bootstrap::*; -#[cfg(test)] -use crate::hooks::generated_hooks; -#[cfg(test)] -use claude::*; -#[cfg(test)] -use codex::*; -#[cfg(test)] -use codex_app_server::*; -#[cfg(test)] -use shared::*; - -#[cfg(test)] -#[path = "../../tests/coverage/agents/plugin_host_tests.rs"] -mod tests; diff --git a/crates/cli/src/agents/install/host.rs b/crates/cli/src/agents/install/host.rs index 3f060750b..e415613d1 100644 --- a/crates/cli/src/agents/install/host.rs +++ b/crates/cli/src/agents/install/host.rs @@ -298,7 +298,7 @@ pub(super) fn require_relay( runner .current_executable() .map(|path| path.canonicalize().unwrap_or(path)) - .map(crate::agents::host::portable_executable_path) + .map(crate::agents::portable_executable_path) } pub(super) fn validate_relay_hook_forward( @@ -449,7 +449,7 @@ pub(super) fn format_command(program: &str, args: &[String]) -> String { "$ {}", parts .iter() - .map(|part| { crate::agents::host::shell_quote_arg_for_platform(part, cfg!(windows)) }) + .map(|part| { crate::agents::shell_quote_arg_for_platform(part, cfg!(windows)) }) .collect::>() .join(" ") ) diff --git a/crates/cli/src/agents/install/setup.rs b/crates/cli/src/agents/install/setup.rs index 081c92d15..93a439ec2 100644 --- a/crates/cli/src/agents/install/setup.rs +++ b/crates/cli/src/agents/install/setup.rs @@ -3,7 +3,7 @@ //! Host setup, restore, and doctor delegation. -use crate::agents::host; +use crate::agents as host; use crate::configuration::{CodingAgent, IntegrationHost}; use serde_json::Value; use std::path::Path; diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index ddc938013..43ae30cc3 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -6,7 +6,6 @@ pub(crate) mod claude; pub(crate) mod codex; pub(crate) mod hermes; -pub(crate) mod host; pub(crate) mod install; pub(crate) mod shared; @@ -147,6 +146,217 @@ impl CodingAgent { } } +pub(crate) use claude::host::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; +pub(crate) use codex::host::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; +pub(crate) use shared::host::portable_executable_path; +pub(crate) use shared::host::shell_quote_arg_for_platform; +#[cfg(test)] +pub(crate) use shared::host::strip_windows_verbatim_prefix; + +use std::path::Path; + +use serde_json::{Value, json}; + +use claude::host::claude_settings_base_url; +use codex::host::{ + codex_hook_trust_report, codex_hook_trust_report_with_generation, codex_hooks_installed, + codex_hooks_installed_with_generation, codex_provider_installed, empty_codex_hook_trust_report, + install_codex_with_generation, uninstall_codex, +}; +use shared::host::{current_exe, healthz, print_check, print_info}; + +#[cfg(test)] +pub(super) use crate::bootstrap::DEFAULT_URL; + +pub(crate) fn install_codex_plugin_with_generation( + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, +) -> Result<(), String> { + install_codex_with_generation( + gateway_url, + &plugin_root.join("hooks").join("hooks.json"), + generation_token, + ) + .map(|_| ()) +} + +pub(crate) fn stop_plugin_gateway() -> Result<(), String> { + crate::bootstrap::state::stop_owned_and_reset(crate::bootstrap::DEFAULT_URL) +} + +pub(crate) fn uninstall_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Result<(), String> { + uninstall_codex(gateway_url, &plugin_root.join("hooks").join("hooks.json")).map(|_| ()) +} + +pub(crate) fn enable_claude_provider(gateway_url: &str) -> Result<(), String> { + claude::host::enable_claude_provider(gateway_url) +} + +pub(crate) fn restore_claude_provider(gateway_url: &str) -> Result<(), String> { + claude::host::restore_claude_provider(gateway_url) +} + +pub(crate) fn doctor_plugin( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, +) -> Result<(), String> { + doctor_plugin_with_generation(agent, gateway_url, plugin_root, None) +} + +pub(crate) fn doctor_plugin_with_generation( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, +) -> Result<(), String> { + if doctor_ok( + agent, + gateway_url, + Some(&plugin_root.join("hooks").join("hooks.json")), + generation_token, + )? { + Ok(()) + } else { + Err(format!("{} plugin doctor checks failed", agent.as_arg())) + } +} + +pub(crate) fn doctor_plugin_json( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, +) -> Result { + let plugin_binary = current_exe().ok().is_some_and(|path| path.exists()); + let sidecar_running = healthz(gateway_url); + let (checks, ok, codex_trust) = match agent { + CodingAgent::ClaudeCode => { + let provider = claude_settings_base_url().as_deref() == Some(gateway_url); + ( + json!({ + "plugin_binary": plugin_binary, + "sidecar_running": sidecar_running, + "claude_provider_routing": provider + }), + plugin_binary && provider, + None, + ) + } + CodingAgent::Codex => { + let plugin_hooks_path = plugin_root.join("hooks").join("hooks.json"); + let provider = codex_provider_installed(gateway_url); + let hooks = codex_hooks_installed(&plugin_hooks_path)?; + let trust = if hooks { + codex_hook_trust_report(&plugin_hooks_path)? + } else { + empty_codex_hook_trust_report() + }; + let hooks_trusted = trust.ready(); + ( + json!({ + "plugin_binary": plugin_binary, + "sidecar_running": sidecar_running, + "codex_provider_alias": provider, + "codex_hooks": hooks, + "codex_hooks_trusted": hooks_trusted + }), + plugin_binary && provider && hooks && hooks_trusted, + Some(trust), + ) + } + other => { + return Err(format!( + "plugin doctor supports claude and codex, got {}", + other.as_arg() + )); + } + }; + let mut report = json!({ + "ok": ok, + "sidecar_health": if sidecar_running { + "running" + } else { + "not_running_mcp_start" + }, + "checks": checks + }); + if let Some(trust) = codex_trust { + report["codex_hook_trust"] = trust.to_json(); + } + Ok(report) +} + +fn doctor_ok( + agent: CodingAgent, + gateway_url: &str, + plugin_hooks_path: Option<&Path>, + generation_token: Option<&str>, +) -> Result { + let mut ok = true; + ok &= print_check( + "plugin binary", + current_exe().ok().is_some_and(|path| path.exists()), + ); + if healthz(gateway_url) { + print_info("sidecar health", "running"); + } else { + print_info( + "sidecar health", + "not running; the plugin MCP starts it when the host launches", + ); + } + match agent { + CodingAgent::ClaudeCode => { + ok &= print_check( + "claude provider routing", + claude_settings_base_url().as_deref() == Some(gateway_url), + ); + } + CodingAgent::Codex => { + let plugin_hooks_path = plugin_hooks_path + .ok_or_else(|| "Codex plugin hooks path is required for doctor".to_string())?; + let provider = codex_provider_installed(gateway_url); + let hooks = codex_hooks_installed_with_generation(plugin_hooks_path, generation_token)?; + ok &= print_check("codex provider alias", provider); + ok &= print_check("codex hooks", hooks); + let trust = if hooks { + codex_hook_trust_report_with_generation(plugin_hooks_path, generation_token)? + } else { + empty_codex_hook_trust_report() + }; + ok &= print_check("codex hooks trusted and enabled", trust.ready()); + if !trust.ready() { + print_info("codex hook trust", &trust.summary()); + } + } + other => { + return Err(format!( + "plugin doctor supports claude and codex, got {}", + other.as_arg() + )); + } + } + Ok(ok) +} + +#[cfg(test)] +use crate::bootstrap::*; +#[cfg(test)] +use crate::hooks::generated_hooks; +#[cfg(test)] +use claude::host::*; +#[cfg(test)] +use codex::app_server::*; +#[cfg(test)] +use codex::host::*; +#[cfg(test)] +use shared::host::*; + +#[cfg(test)] +#[path = "../../tests/coverage/agents/plugin_host_tests.rs"] +mod host_tests; + #[cfg(test)] #[path = "../../tests/coverage/agents/coding_agent_tests.rs"] mod tests; diff --git a/crates/cli/src/agents/shared/host.rs b/crates/cli/src/agents/shared/host.rs index 15725eeff..fe474ed6a 100644 --- a/crates/cli/src/agents/shared/host.rs +++ b/crates/cli/src/agents/shared/host.rs @@ -10,13 +10,13 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; use toml_edit::{DocumentMut, Item, Table}; -pub(super) use crate::bootstrap::current_exe; -pub(super) use crate::filesystem::{ +pub(crate) use crate::bootstrap::current_exe; +pub(crate) use crate::filesystem::{ atomic_write, atomic_write_private, atomic_write_with_permissions, }; #[cfg(windows)] use crate::filesystem::{atomic_write_with_windows_dacl, read_windows_dacl}; -pub(super) use crate::gateway::client::healthz; +pub(crate) use crate::gateway::client::healthz; pub(crate) fn shell_quote(path: &Path) -> String { shell_quote_for_platform(path, cfg!(windows)) @@ -73,14 +73,14 @@ fn cmd_quote_arg(raw: &str) -> String { } } -pub(super) fn ensure_table<'a>(doc: &'a mut DocumentMut, name: &str) -> &'a mut Table { +pub(crate) fn ensure_table<'a>(doc: &'a mut DocumentMut, name: &str) -> &'a mut Table { if !doc.as_table().contains_key(name) || !doc[name].is_table() { doc[name] = Item::Table(Table::new()); } doc[name].as_table_mut().expect("table was just inserted") } -pub(super) fn read_json_object(path: &Path) -> Result { +pub(crate) fn read_json_object(path: &Path) -> Result { if !path.exists() { return Ok(json!({})); } @@ -95,13 +95,13 @@ pub(super) fn read_json_object(path: &Path) -> Result { } } -pub(super) fn write_json(path: &Path, value: &Value) -> Result<(), String> { +pub(crate) fn write_json(path: &Path, value: &Value) -> Result<(), String> { let mut bytes = serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?; bytes.push(b'\n'); atomic_write(path, &bytes) } -pub(super) fn backup(path: &Path) -> Result<(), String> { +pub(crate) fn backup(path: &Path) -> Result<(), String> { let backup = backup_path(path); if backup.exists() { return Ok(()); @@ -118,7 +118,7 @@ pub(super) fn backup(path: &Path) -> Result<(), String> { Ok(()) } -pub(super) fn remove_backup(path: &Path) -> Result<(), String> { +pub(crate) fn remove_backup(path: &Path) -> Result<(), String> { let backup = backup_path(path); match fs::remove_file(&backup) { Ok(()) => Ok(()), @@ -127,7 +127,7 @@ pub(super) fn remove_backup(path: &Path) -> Result<(), String> { } } -pub(super) fn backup_path(path: &Path) -> PathBuf { +pub(crate) fn backup_path(path: &Path) -> PathBuf { let mut extension = path .extension() .and_then(|value| value.to_str()) @@ -141,23 +141,23 @@ pub(super) fn backup_path(path: &Path) -> PathBuf { path.with_extension(extension) } -pub(super) fn home_dir() -> Result { +pub(crate) fn home_dir() -> Result { env::var_os("HOME") .or_else(|| env::var_os("USERPROFILE")) .map(PathBuf::from) .ok_or_else(|| "cannot determine home directory (set HOME or USERPROFILE)".into()) } -pub(super) fn print_check(label: &str, ok: bool) -> bool { +pub(crate) fn print_check(label: &str, ok: bool) -> bool { println!("{} {label}", if ok { "ok" } else { "missing" }); ok } -pub(super) fn print_info(label: &str, message: &str) { +pub(crate) fn print_info(label: &str, message: &str) { println!("info {label}: {message}"); } -pub(super) struct FileSnapshot { +pub(crate) struct FileSnapshot { path: PathBuf, bytes: Option>, permissions: Option, @@ -165,7 +165,7 @@ pub(super) struct FileSnapshot { dacl: Option>, } -pub(super) fn snapshot_optional_file(path: &Path) -> Result { +pub(crate) fn snapshot_optional_file(path: &Path) -> Result { match fs::read(path) { Ok(bytes) => Ok(FileSnapshot { path: path.to_path_buf(), @@ -190,7 +190,7 @@ pub(super) fn snapshot_optional_file(path: &Path) -> Result Result<(), String> { +pub(crate) fn restore_file_snapshot(snapshot: &FileSnapshot) -> Result<(), String> { if let Some(bytes) = snapshot.bytes.as_deref() { #[cfg(windows)] if let Some(dacl) = snapshot.dacl.as_deref() { diff --git a/crates/cli/src/agents/shared/mod.rs b/crates/cli/src/agents/shared/mod.rs index db9b6978d..336ed9437 100644 --- a/crates/cli/src/agents/shared/mod.rs +++ b/crates/cli/src/agents/shared/mod.rs @@ -5,3 +5,4 @@ pub(crate) mod adapters; pub(crate) mod alignment; +pub(super) mod host; diff --git a/crates/cli/src/bootstrap/mod.rs b/crates/cli/src/bootstrap/mod.rs index 60a026e7c..e8e874107 100644 --- a/crates/cli/src/bootstrap/mod.rs +++ b/crates/cli/src/bootstrap/mod.rs @@ -499,7 +499,7 @@ pub(super) fn relay_binary() -> Result { current_exe() } -pub(super) fn current_exe() -> Result { +pub(crate) fn current_exe() -> Result { env::current_exe().map_err(|error| format!("failed to resolve current executable: {error}")) } diff --git a/crates/cli/src/hooks/encoding.rs b/crates/cli/src/hooks/encoding.rs index 24dd1165a..6f544b0d5 100644 --- a/crates/cli/src/hooks/encoding.rs +++ b/crates/cli/src/hooks/encoding.rs @@ -124,7 +124,7 @@ pub(super) fn hook_command_for_platform( pub(super) fn posix_hook_command(relay: &Path, arguments: &[String]) -> String { std::iter::once(relay.display().to_string()) .chain(arguments.iter().cloned()) - .map(|argument| crate::agents::host::shell_quote_arg_for_platform(&argument, false)) + .map(|argument| crate::agents::shell_quote_arg_for_platform(&argument, false)) .collect::>() .join(" ") } diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index 3003c1340..ff697ae8b 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -1189,7 +1189,7 @@ fn push_status_border( fn transparent_hook_executable() -> PathBuf { std::env::current_exe() .map(|path| path.canonicalize().unwrap_or(path)) - .map(crate::agents::host::portable_executable_path) + .map(crate::agents::portable_executable_path) .unwrap_or_else(|_| PathBuf::from("nemo-relay")) } @@ -1296,7 +1296,7 @@ fn populate_hermes_overlay( }; let relay = std::env::current_exe() .map(|path| path.canonicalize().unwrap_or(path)) - .map(crate::agents::host::portable_executable_path) + .map(crate::agents::portable_executable_path) .unwrap_or_else(|_| PathBuf::from("nemo-relay")); let contents = crate::agents::hermes::transparent_config(&existing, &relay, gateway_url)?; std::fs::write(overlay.join("config.yaml"), contents)?; diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index a6a11808c..cd0d46556 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -20,7 +20,7 @@ use super::host::{ validate_relay_mcp, }; use super::*; -use crate::agents::host::strip_windows_verbatim_prefix; +use crate::agents::strip_windows_verbatim_prefix; use crate::configuration::CodingAgent; const OPERATION_LOCK_HELPER_DIR_ENV: &str = "NEMO_RELAY_TEST_OPERATION_LOCK_DIR"; From 3df3363f93f380bb17d16c80b6d2664e333c26f0 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 21:47:21 -0400 Subject: [PATCH 033/102] refactor(cli): isolate plugin schema secret handling Signed-off-by: Will Killian --- crates/cli/src/plugins/schema.rs | 646 +--------------------- crates/cli/src/plugins/schema/secrets.rs | 651 +++++++++++++++++++++++ 2 files changed, 655 insertions(+), 642 deletions(-) create mode 100644 crates/cli/src/plugins/schema/secrets.rs diff --git a/crates/cli/src/plugins/schema.rs b/crates/cli/src/plugins/schema.rs index faaad0171..5e52f39d7 100644 --- a/crates/cli/src/plugins/schema.rs +++ b/crates/cli/src/plugins/schema.rs @@ -3,6 +3,10 @@ //! Static JSON Schema loading and editor metadata for dynamic plugins. +mod secrets; + +use secrets::*; + use std::cmp::Ordering; use std::collections::{BTreeMap, HashSet}; use std::fs; @@ -1305,648 +1309,6 @@ fn find_anchor<'a>(schema: &'a Value, anchor: &str) -> Option<&'a Value> { } } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -enum SecretSegment { - Property(String), - Any, - Pattern(SecretPropertyPattern), - UnmatchedProperties(SecretUnmatchedProperties), - Index(usize), - Tail(usize), -} - -#[derive(Debug, Clone)] -struct SecretPropertyPattern { - source: String, - matcher: regex::Regex, -} - -impl PartialEq for SecretPropertyPattern { - fn eq(&self, other: &Self) -> bool { - self.source == other.source - } -} - -impl Eq for SecretPropertyPattern {} - -impl PartialOrd for SecretPropertyPattern { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for SecretPropertyPattern { - fn cmp(&self, other: &Self) -> Ordering { - self.source.cmp(&other.source) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct SecretUnmatchedProperties { - properties: Vec, - patterns: Vec, -} - -impl SecretUnmatchedProperties { - fn matches(&self, property: &str) -> bool { - self.properties - .binary_search_by(|candidate| candidate.as_str().cmp(property)) - .is_err() - && !self - .patterns - .iter() - .any(|pattern| pattern_matches(pattern, property)) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct SecretPattern(Vec); - -impl SecretPattern { - fn redact(&self, value: &mut Value, offset: usize) { - self.visit_matching_values(value, offset, &mut |value| { - // A configuration can contain a schema-invalid value before validation. Once the - // schema marks this path as secret, its runtime type must not determine whether it - // is safe to display. Null remains visible because it represents an unset nullable - // secret and carries no payload. - if !value.is_null() { - *value = Value::String(REDACTED.to_owned()); - } - }); - } - - fn redact_for_edit( - &self, - value: &mut Value, - offset: usize, - secrets: &mut SecretEditValues, - occupied: &HashSet, - next_token: &mut usize, - ) { - self.visit_matching_values(value, offset, &mut |value| { - // Tokenize invalid values too, both to keep raw editing safe and to preserve the - // original value if the user leaves it unchanged. - if value.is_null() - || value - .as_str() - .is_some_and(|candidate| secrets.contains_key(candidate)) - { - return; - } - let token = next_secret_token(secrets, occupied, next_token); - secrets.insert( - token.clone(), - SecretEditValue { - value: value.clone(), - pattern: self.clone(), - }, - ); - *value = Value::String(token); - }); - } - - fn visit_matching_values( - &self, - value: &mut Value, - offset: usize, - visit: &mut impl FnMut(&mut Value), - ) { - if offset == self.0.len() { - visit(value); - return; - } - match &self.0[offset] { - SecretSegment::Property(property) => { - if let Some(child) = value.get_mut(property) { - self.visit_matching_values(child, offset + 1, visit); - } - } - SecretSegment::Any => match value { - Value::Object(object) => { - for child in object.values_mut() { - self.visit_matching_values(child, offset + 1, visit); - } - } - Value::Array(values) => { - for child in values { - self.visit_matching_values(child, offset + 1, visit); - } - } - _ => {} - }, - SecretSegment::Pattern(pattern) => { - if let Value::Object(object) = value { - for (key, child) in object { - if pattern_matches(pattern, key) { - self.visit_matching_values(child, offset + 1, visit); - } - } - } - } - SecretSegment::UnmatchedProperties(selector) => { - if let Value::Object(object) = value { - for (key, child) in object { - if selector.matches(key) { - self.visit_matching_values(child, offset + 1, visit); - } - } - } - } - SecretSegment::Index(index) => { - if let Some(child) = value.get_mut(*index) { - self.visit_matching_values(child, offset + 1, visit); - } - } - SecretSegment::Tail(start) => { - if let Value::Array(values) = value { - for child in values.iter_mut().skip(*start) { - self.visit_matching_values(child, offset + 1, visit); - } - } - } - } - } - - fn applies_below(&self, path: &[String]) -> bool { - self.0.len() >= path.len() - && self - .0 - .iter() - .zip(path) - .all(|(segment, property)| match segment { - SecretSegment::Property(expected) => expected == property, - SecretSegment::Any => true, - SecretSegment::Pattern(pattern) => pattern_matches(pattern, property), - SecretSegment::UnmatchedProperties(selector) => selector.matches(property), - SecretSegment::Index(index) => property.parse::() == Ok(*index), - SecretSegment::Tail(start) => { - property.parse::().is_ok_and(|index| index >= *start) - } - }) - } - - fn matches_instance_path(&self, path: &[SecretInstanceSegment]) -> bool { - self.0.len() == path.len() - && self - .0 - .iter() - .zip(path) - .all(|(pattern, instance)| match (pattern, instance) { - ( - SecretSegment::Property(expected), - SecretInstanceSegment::Property(actual), - ) => expected == actual, - (SecretSegment::Any, _) => true, - (SecretSegment::Pattern(pattern), SecretInstanceSegment::Property(actual)) => { - pattern_matches(pattern, actual) - } - ( - SecretSegment::UnmatchedProperties(selector), - SecretInstanceSegment::Property(actual), - ) => selector.matches(actual), - (SecretSegment::Index(expected), SecretInstanceSegment::Index(actual)) => { - expected == actual - } - (SecretSegment::Tail(start), SecretInstanceSegment::Index(actual)) => { - actual >= start - } - _ => false, - }) - } -} - -#[derive(Debug, Clone)] -enum SecretInstanceSegment { - Property(String), - Index(usize), -} - -fn pattern_matches(pattern: &SecretPropertyPattern, property: &str) -> bool { - pattern.matcher.is_match(property) -} - -fn collect_string_values(value: &Value, output: &mut HashSet) { - match value { - Value::String(value) => { - output.insert(value.clone()); - } - Value::Array(values) => { - for value in values { - collect_string_values(value, output); - } - } - Value::Object(values) => { - for value in values.values() { - collect_string_values(value, output); - } - } - _ => {} - } -} - -fn next_secret_token( - secrets: &SecretEditValues, - occupied: &HashSet, - next_token: &mut usize, -) -> String { - loop { - let token = format!("{EDIT_REDACTED_PREFIX}{}>", *next_token); - *next_token += 1; - if !secrets.contains_key(&token) && !occupied.contains(&token) { - return token; - } - } -} - -fn restore_secret_tokens(value: &Value, secrets: &SecretEditValues) -> Result { - fn restore( - value: &Value, - secrets: &SecretEditValues, - path: &mut Vec, - used_tokens: &mut HashSet, - ) -> Result { - match value { - Value::String(value) => match secrets.get(value) { - None => Ok(Value::String(value.clone())), - Some(secret) if !secret.pattern.matches_instance_path(path) => Err(format!( - "token '{value}' may only appear at its original schema-declared secret location" - )), - Some(_) if !used_tokens.insert(value.clone()) => { - Err(format!("token '{value}' may only appear once")) - } - Some(secret) => Ok(secret.value.clone()), - }, - Value::Array(values) => { - let mut restored = Vec::with_capacity(values.len()); - for (index, value) in values.iter().enumerate() { - path.push(SecretInstanceSegment::Index(index)); - restored.push(restore(value, secrets, path, used_tokens)?); - path.pop(); - } - Ok(Value::Array(restored)) - } - Value::Object(values) => { - let mut restored = Map::with_capacity(values.len()); - for (key, value) in values { - path.push(SecretInstanceSegment::Property(key.clone())); - restored.insert(key.clone(), restore(value, secrets, path, used_tokens)?); - path.pop(); - } - Ok(Value::Object(restored)) - } - value => Ok(value.clone()), - } - } - - restore(value, secrets, &mut Vec::new(), &mut HashSet::new()) -} - -fn discover_secret_patterns( - root: &Value, - schema: &Value, - instance_path: &[SecretSegment], - reference_stack: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let mut references = reference_stack.clone(); - let mut reference_chain = Vec::new(); - resolve_schema_chain(root, schema, &mut references, &mut reference_chain) - .map_err(|error| format!("secret schema reference could not be resolved: {error}"))?; - if classify_write_only_chain(&reference_chain)? { - output.push(SecretPattern(instance_path.to_vec())); - return Ok(()); - } - - // Draft 2020-12 treats `$ref` as an applicator, so sibling keywords remain active. Walk - // every node recorded during resolution instead of only the final target; otherwise a - // sibling `properties` subtree can contain writeOnly fields that never get redacted. - for effective_schema in reference_chain { - if let Some(object) = effective_schema.as_object() { - discover_secret_patterns_in_object(root, object, instance_path, &references, output)?; - } - } - Ok(()) -} - -fn discover_secret_patterns_in_object( - root: &Value, - object: &Map, - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let properties = object.get("properties").and_then(Value::as_object); - discover_named_secret_patterns(root, properties, instance_path, references, output)?; - let pattern_schemas = collect_secret_pattern_schemas(object)?; - discover_additional_secret_patterns( - root, - object, - properties, - &pattern_schemas, - instance_path, - references, - output, - )?; - discover_pattern_property_secret_patterns( - root, - pattern_schemas, - instance_path, - references, - output, - )?; - discover_item_secret_patterns(root, object, instance_path, references, output)?; - discover_prefix_item_secret_patterns(root, object, instance_path, references, output)?; - discover_all_of_secret_patterns(root, object, instance_path, references, output)?; - reject_array_applicator_secret_patterns(root, object, instance_path, references)?; - reject_value_applicator_secret_patterns( - root, - object, - &["if", "then", "else", "not"], - instance_path, - references, - )?; - discover_contains_secret_patterns(root, object, instance_path, references, output)?; - reject_value_applicator_secret_patterns( - root, - object, - &["unevaluatedProperties", "unevaluatedItems"], - instance_path, - references, - )?; - reject_object_applicator_secret_patterns( - root, - object, - &["dependentSchemas", "dependencies"], - instance_path, - references, - )?; - Ok(()) -} - -fn discover_named_secret_patterns( - root: &Value, - properties: Option<&Map>, - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let Some(properties) = properties else { - return Ok(()); - }; - for (property, child_schema) in properties { - let mut child_path = instance_path.to_vec(); - child_path.push(SecretSegment::Property(property.clone())); - discover_secret_patterns(root, child_schema, &child_path, references, output)?; - } - Ok(()) -} - -fn collect_secret_pattern_schemas( - object: &Map, -) -> Result, String> { - let Some(patterns) = object.get("patternProperties").and_then(Value::as_object) else { - return Ok(Vec::new()); - }; - let mut pattern_schemas = Vec::new(); - for (pattern, child_schema) in patterns { - let matcher = regex::Regex::new(pattern).map_err(|error| { - format!("unsupported patternProperties expression {pattern:?}: {error}") - })?; - pattern_schemas.push(( - SecretPropertyPattern { - source: pattern.clone(), - matcher, - }, - child_schema, - )); - } - pattern_schemas.sort_by(|(left, _), (right, _)| left.cmp(right)); - Ok(pattern_schemas) -} - -fn discover_additional_secret_patterns( - root: &Value, - object: &Map, - properties: Option<&Map>, - pattern_schemas: &[(SecretPropertyPattern, &Value)], - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let Some(additional) = object.get("additionalProperties") else { - return Ok(()); - }; - if !additional.is_object() { - return Ok(()); - } - let mut excluded_properties = properties - .into_iter() - .flat_map(|properties| properties.keys().cloned()) - .collect::>(); - excluded_properties.sort(); - let mut child_path = instance_path.to_vec(); - child_path.push(SecretSegment::UnmatchedProperties( - SecretUnmatchedProperties { - properties: excluded_properties, - patterns: pattern_schemas - .iter() - .map(|(pattern, _)| pattern.clone()) - .collect(), - }, - )); - discover_secret_patterns(root, additional, &child_path, references, output) -} - -fn discover_pattern_property_secret_patterns( - root: &Value, - pattern_schemas: Vec<(SecretPropertyPattern, &Value)>, - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - for (pattern, child_schema) in pattern_schemas { - let mut child_path = instance_path.to_vec(); - child_path.push(SecretSegment::Pattern(pattern)); - discover_secret_patterns(root, child_schema, &child_path, references, output)?; - } - Ok(()) -} - -fn discover_item_secret_patterns( - root: &Value, - object: &Map, - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let Some(items) = object.get("items") else { - return Ok(()); - }; - if items.is_object() { - let mut child_path = instance_path.to_vec(); - let segment = object - .get("prefixItems") - .and_then(Value::as_array) - .map_or(SecretSegment::Any, |prefix_items| { - SecretSegment::Tail(prefix_items.len()) - }); - child_path.push(segment); - return discover_secret_patterns(root, items, &child_path, references, output); - } - let Some(tuple_items) = items.as_array() else { - return Ok(()); - }; - for (index, child_schema) in tuple_items.iter().enumerate() { - let mut child_path = instance_path.to_vec(); - child_path.push(SecretSegment::Index(index)); - discover_secret_patterns(root, child_schema, &child_path, references, output)?; - } - if let Some(additional_items) = object.get("additionalItems") - && additional_items.is_object() - { - let mut child_path = instance_path.to_vec(); - child_path.push(SecretSegment::Tail(tuple_items.len())); - discover_secret_patterns(root, additional_items, &child_path, references, output)?; - } - Ok(()) -} - -fn discover_prefix_item_secret_patterns( - root: &Value, - object: &Map, - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let Some(prefix_items) = object.get("prefixItems").and_then(Value::as_array) else { - return Ok(()); - }; - for (index, child_schema) in prefix_items.iter().enumerate() { - let mut child_path = instance_path.to_vec(); - child_path.push(SecretSegment::Index(index)); - discover_secret_patterns(root, child_schema, &child_path, references, output)?; - } - Ok(()) -} - -fn discover_all_of_secret_patterns( - root: &Value, - object: &Map, - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let Some(branches) = object.get("allOf").and_then(Value::as_array) else { - return Ok(()); - }; - for branch in branches { - discover_secret_patterns(root, branch, instance_path, references, output)?; - } - Ok(()) -} - -fn reject_array_applicator_secret_patterns( - root: &Value, - object: &Map, - instance_path: &[SecretSegment], - references: &HashSet, -) -> Result<(), String> { - for keyword in ["anyOf", "oneOf"] { - let Some(branches) = object.get(keyword).and_then(Value::as_array) else { - continue; - }; - for branch in branches { - reject_write_only_under_applicator(root, keyword, branch, instance_path, references)?; - } - } - Ok(()) -} - -fn reject_value_applicator_secret_patterns( - root: &Value, - object: &Map, - keywords: &[&str], - instance_path: &[SecretSegment], - references: &HashSet, -) -> Result<(), String> { - for keyword in keywords { - if let Some(branch) = object.get(*keyword) - && branch.is_object() - { - reject_write_only_under_applicator(root, keyword, branch, instance_path, references)?; - } - } - Ok(()) -} - -fn discover_contains_secret_patterns( - root: &Value, - object: &Map, - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let Some(contains) = object.get("contains") else { - return Ok(()); - }; - if !contains.is_object() { - return Ok(()); - } - let mut child_path = instance_path.to_vec(); - child_path.push(SecretSegment::Any); - discover_secret_patterns(root, contains, &child_path, references, output) -} - -fn reject_object_applicator_secret_patterns( - root: &Value, - object: &Map, - keywords: &[&str], - instance_path: &[SecretSegment], - references: &HashSet, -) -> Result<(), String> { - for keyword in keywords { - let Some(branches) = object.get(*keyword).and_then(Value::as_object) else { - continue; - }; - for branch in branches.values().filter(|branch| branch.is_object()) { - reject_write_only_under_applicator(root, keyword, branch, instance_path, references)?; - } - } - Ok(()) -} - -fn reject_write_only_under_applicator( - root: &Value, - keyword: &str, - schema: &Value, - instance_path: &[SecretSegment], - references: &HashSet, -) -> Result<(), String> { - let mut nested_patterns = Vec::new(); - discover_secret_patterns( - root, - schema, - instance_path, - references, - &mut nested_patterns, - )?; - if nested_patterns.is_empty() { - Ok(()) - } else { - Err(format!( - "writeOnly fields under '{keyword}' are not supported for secret redaction" - )) - } -} - -fn push_pointer(pointer: &str, segment: &str) -> String { - format!("{pointer}/{}", escape_pointer(segment)) -} - -fn escape_pointer(segment: &str) -> String { - segment.replace('~', "~0").replace('/', "~1") -} - #[cfg(test)] #[path = "../../tests/coverage/shared/plugins_schema_tests.rs"] mod tests; diff --git a/crates/cli/src/plugins/schema/secrets.rs b/crates/cli/src/plugins/schema/secrets.rs new file mode 100644 index 000000000..af608cadc --- /dev/null +++ b/crates/cli/src/plugins/schema/secrets.rs @@ -0,0 +1,651 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Write-only secret discovery, redaction, and edit-token restoration. + +use super::*; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum SecretSegment { + Property(String), + Any, + Pattern(SecretPropertyPattern), + UnmatchedProperties(SecretUnmatchedProperties), + Index(usize), + Tail(usize), +} + +#[derive(Debug, Clone)] +pub(super) struct SecretPropertyPattern { + pub(super) source: String, + matcher: regex::Regex, +} + +impl PartialEq for SecretPropertyPattern { + fn eq(&self, other: &Self) -> bool { + self.source == other.source + } +} + +impl Eq for SecretPropertyPattern {} + +impl PartialOrd for SecretPropertyPattern { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for SecretPropertyPattern { + fn cmp(&self, other: &Self) -> Ordering { + self.source.cmp(&other.source) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct SecretUnmatchedProperties { + properties: Vec, + patterns: Vec, +} + +impl SecretUnmatchedProperties { + pub(super) fn matches(&self, property: &str) -> bool { + self.properties + .binary_search_by(|candidate| candidate.as_str().cmp(property)) + .is_err() + && !self + .patterns + .iter() + .any(|pattern| pattern_matches(pattern, property)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct SecretPattern(pub(super) Vec); + +impl SecretPattern { + pub(super) fn redact(&self, value: &mut Value, offset: usize) { + self.visit_matching_values(value, offset, &mut |value| { + // A configuration can contain a schema-invalid value before validation. Once the + // schema marks this path as secret, its runtime type must not determine whether it + // is safe to display. Null remains visible because it represents an unset nullable + // secret and carries no payload. + if !value.is_null() { + *value = Value::String(REDACTED.to_owned()); + } + }); + } + + pub(super) fn redact_for_edit( + &self, + value: &mut Value, + offset: usize, + secrets: &mut SecretEditValues, + occupied: &HashSet, + next_token: &mut usize, + ) { + self.visit_matching_values(value, offset, &mut |value| { + // Tokenize invalid values too, both to keep raw editing safe and to preserve the + // original value if the user leaves it unchanged. + if value.is_null() + || value + .as_str() + .is_some_and(|candidate| secrets.contains_key(candidate)) + { + return; + } + let token = next_secret_token(secrets, occupied, next_token); + secrets.insert( + token.clone(), + SecretEditValue { + value: value.clone(), + pattern: self.clone(), + }, + ); + *value = Value::String(token); + }); + } + + pub(super) fn visit_matching_values( + &self, + value: &mut Value, + offset: usize, + visit: &mut impl FnMut(&mut Value), + ) { + if offset == self.0.len() { + visit(value); + return; + } + match &self.0[offset] { + SecretSegment::Property(property) => { + if let Some(child) = value.get_mut(property) { + self.visit_matching_values(child, offset + 1, visit); + } + } + SecretSegment::Any => match value { + Value::Object(object) => { + for child in object.values_mut() { + self.visit_matching_values(child, offset + 1, visit); + } + } + Value::Array(values) => { + for child in values { + self.visit_matching_values(child, offset + 1, visit); + } + } + _ => {} + }, + SecretSegment::Pattern(pattern) => { + if let Value::Object(object) = value { + for (key, child) in object { + if pattern_matches(pattern, key) { + self.visit_matching_values(child, offset + 1, visit); + } + } + } + } + SecretSegment::UnmatchedProperties(selector) => { + if let Value::Object(object) = value { + for (key, child) in object { + if selector.matches(key) { + self.visit_matching_values(child, offset + 1, visit); + } + } + } + } + SecretSegment::Index(index) => { + if let Some(child) = value.get_mut(*index) { + self.visit_matching_values(child, offset + 1, visit); + } + } + SecretSegment::Tail(start) => { + if let Value::Array(values) = value { + for child in values.iter_mut().skip(*start) { + self.visit_matching_values(child, offset + 1, visit); + } + } + } + } + } + + pub(super) fn applies_below(&self, path: &[String]) -> bool { + self.0.len() >= path.len() + && self + .0 + .iter() + .zip(path) + .all(|(segment, property)| match segment { + SecretSegment::Property(expected) => expected == property, + SecretSegment::Any => true, + SecretSegment::Pattern(pattern) => pattern_matches(pattern, property), + SecretSegment::UnmatchedProperties(selector) => selector.matches(property), + SecretSegment::Index(index) => property.parse::() == Ok(*index), + SecretSegment::Tail(start) => { + property.parse::().is_ok_and(|index| index >= *start) + } + }) + } + + pub(super) fn matches_instance_path(&self, path: &[SecretInstanceSegment]) -> bool { + self.0.len() == path.len() + && self + .0 + .iter() + .zip(path) + .all(|(pattern, instance)| match (pattern, instance) { + ( + SecretSegment::Property(expected), + SecretInstanceSegment::Property(actual), + ) => expected == actual, + (SecretSegment::Any, _) => true, + (SecretSegment::Pattern(pattern), SecretInstanceSegment::Property(actual)) => { + pattern_matches(pattern, actual) + } + ( + SecretSegment::UnmatchedProperties(selector), + SecretInstanceSegment::Property(actual), + ) => selector.matches(actual), + (SecretSegment::Index(expected), SecretInstanceSegment::Index(actual)) => { + expected == actual + } + (SecretSegment::Tail(start), SecretInstanceSegment::Index(actual)) => { + actual >= start + } + _ => false, + }) + } +} + +#[derive(Debug, Clone)] +pub(super) enum SecretInstanceSegment { + Property(String), + Index(usize), +} + +pub(super) fn pattern_matches(pattern: &SecretPropertyPattern, property: &str) -> bool { + pattern.matcher.is_match(property) +} + +pub(super) fn collect_string_values(value: &Value, output: &mut HashSet) { + match value { + Value::String(value) => { + output.insert(value.clone()); + } + Value::Array(values) => { + for value in values { + collect_string_values(value, output); + } + } + Value::Object(values) => { + for value in values.values() { + collect_string_values(value, output); + } + } + _ => {} + } +} + +pub(super) fn next_secret_token( + secrets: &SecretEditValues, + occupied: &HashSet, + next_token: &mut usize, +) -> String { + loop { + let token = format!("{EDIT_REDACTED_PREFIX}{}>", *next_token); + *next_token += 1; + if !secrets.contains_key(&token) && !occupied.contains(&token) { + return token; + } + } +} + +pub(super) fn restore_secret_tokens( + value: &Value, + secrets: &SecretEditValues, +) -> Result { + pub(super) fn restore( + value: &Value, + secrets: &SecretEditValues, + path: &mut Vec, + used_tokens: &mut HashSet, + ) -> Result { + match value { + Value::String(value) => match secrets.get(value) { + None => Ok(Value::String(value.clone())), + Some(secret) if !secret.pattern.matches_instance_path(path) => Err(format!( + "token '{value}' may only appear at its original schema-declared secret location" + )), + Some(_) if !used_tokens.insert(value.clone()) => { + Err(format!("token '{value}' may only appear once")) + } + Some(secret) => Ok(secret.value.clone()), + }, + Value::Array(values) => { + let mut restored = Vec::with_capacity(values.len()); + for (index, value) in values.iter().enumerate() { + path.push(SecretInstanceSegment::Index(index)); + restored.push(restore(value, secrets, path, used_tokens)?); + path.pop(); + } + Ok(Value::Array(restored)) + } + Value::Object(values) => { + let mut restored = Map::with_capacity(values.len()); + for (key, value) in values { + path.push(SecretInstanceSegment::Property(key.clone())); + restored.insert(key.clone(), restore(value, secrets, path, used_tokens)?); + path.pop(); + } + Ok(Value::Object(restored)) + } + value => Ok(value.clone()), + } + } + + restore(value, secrets, &mut Vec::new(), &mut HashSet::new()) +} + +pub(super) fn discover_secret_patterns( + root: &Value, + schema: &Value, + instance_path: &[SecretSegment], + reference_stack: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let mut references = reference_stack.clone(); + let mut reference_chain = Vec::new(); + resolve_schema_chain(root, schema, &mut references, &mut reference_chain) + .map_err(|error| format!("secret schema reference could not be resolved: {error}"))?; + if classify_write_only_chain(&reference_chain)? { + output.push(SecretPattern(instance_path.to_vec())); + return Ok(()); + } + + // Draft 2020-12 treats `$ref` as an applicator, so sibling keywords remain active. Walk + // every node recorded during resolution instead of only the final target; otherwise a + // sibling `properties` subtree can contain writeOnly fields that never get redacted. + for effective_schema in reference_chain { + if let Some(object) = effective_schema.as_object() { + discover_secret_patterns_in_object(root, object, instance_path, &references, output)?; + } + } + Ok(()) +} + +pub(super) fn discover_secret_patterns_in_object( + root: &Value, + object: &Map, + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let properties = object.get("properties").and_then(Value::as_object); + discover_named_secret_patterns(root, properties, instance_path, references, output)?; + let pattern_schemas = collect_secret_pattern_schemas(object)?; + discover_additional_secret_patterns( + root, + object, + properties, + &pattern_schemas, + instance_path, + references, + output, + )?; + discover_pattern_property_secret_patterns( + root, + pattern_schemas, + instance_path, + references, + output, + )?; + discover_item_secret_patterns(root, object, instance_path, references, output)?; + discover_prefix_item_secret_patterns(root, object, instance_path, references, output)?; + discover_all_of_secret_patterns(root, object, instance_path, references, output)?; + reject_array_applicator_secret_patterns(root, object, instance_path, references)?; + reject_value_applicator_secret_patterns( + root, + object, + &["if", "then", "else", "not"], + instance_path, + references, + )?; + discover_contains_secret_patterns(root, object, instance_path, references, output)?; + reject_value_applicator_secret_patterns( + root, + object, + &["unevaluatedProperties", "unevaluatedItems"], + instance_path, + references, + )?; + reject_object_applicator_secret_patterns( + root, + object, + &["dependentSchemas", "dependencies"], + instance_path, + references, + )?; + Ok(()) +} + +pub(super) fn discover_named_secret_patterns( + root: &Value, + properties: Option<&Map>, + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let Some(properties) = properties else { + return Ok(()); + }; + for (property, child_schema) in properties { + let mut child_path = instance_path.to_vec(); + child_path.push(SecretSegment::Property(property.clone())); + discover_secret_patterns(root, child_schema, &child_path, references, output)?; + } + Ok(()) +} + +pub(super) fn collect_secret_pattern_schemas( + object: &Map, +) -> Result, String> { + let Some(patterns) = object.get("patternProperties").and_then(Value::as_object) else { + return Ok(Vec::new()); + }; + let mut pattern_schemas = Vec::new(); + for (pattern, child_schema) in patterns { + let matcher = regex::Regex::new(pattern).map_err(|error| { + format!("unsupported patternProperties expression {pattern:?}: {error}") + })?; + pattern_schemas.push(( + SecretPropertyPattern { + source: pattern.clone(), + matcher, + }, + child_schema, + )); + } + pattern_schemas.sort_by(|(left, _), (right, _)| left.cmp(right)); + Ok(pattern_schemas) +} + +pub(super) fn discover_additional_secret_patterns( + root: &Value, + object: &Map, + properties: Option<&Map>, + pattern_schemas: &[(SecretPropertyPattern, &Value)], + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let Some(additional) = object.get("additionalProperties") else { + return Ok(()); + }; + if !additional.is_object() { + return Ok(()); + } + let mut excluded_properties = properties + .into_iter() + .flat_map(|properties| properties.keys().cloned()) + .collect::>(); + excluded_properties.sort(); + let mut child_path = instance_path.to_vec(); + child_path.push(SecretSegment::UnmatchedProperties( + SecretUnmatchedProperties { + properties: excluded_properties, + patterns: pattern_schemas + .iter() + .map(|(pattern, _)| pattern.clone()) + .collect(), + }, + )); + discover_secret_patterns(root, additional, &child_path, references, output) +} + +pub(super) fn discover_pattern_property_secret_patterns( + root: &Value, + pattern_schemas: Vec<(SecretPropertyPattern, &Value)>, + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + for (pattern, child_schema) in pattern_schemas { + let mut child_path = instance_path.to_vec(); + child_path.push(SecretSegment::Pattern(pattern)); + discover_secret_patterns(root, child_schema, &child_path, references, output)?; + } + Ok(()) +} + +pub(super) fn discover_item_secret_patterns( + root: &Value, + object: &Map, + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let Some(items) = object.get("items") else { + return Ok(()); + }; + if items.is_object() { + let mut child_path = instance_path.to_vec(); + let segment = object + .get("prefixItems") + .and_then(Value::as_array) + .map_or(SecretSegment::Any, |prefix_items| { + SecretSegment::Tail(prefix_items.len()) + }); + child_path.push(segment); + return discover_secret_patterns(root, items, &child_path, references, output); + } + let Some(tuple_items) = items.as_array() else { + return Ok(()); + }; + for (index, child_schema) in tuple_items.iter().enumerate() { + let mut child_path = instance_path.to_vec(); + child_path.push(SecretSegment::Index(index)); + discover_secret_patterns(root, child_schema, &child_path, references, output)?; + } + if let Some(additional_items) = object.get("additionalItems") + && additional_items.is_object() + { + let mut child_path = instance_path.to_vec(); + child_path.push(SecretSegment::Tail(tuple_items.len())); + discover_secret_patterns(root, additional_items, &child_path, references, output)?; + } + Ok(()) +} + +pub(super) fn discover_prefix_item_secret_patterns( + root: &Value, + object: &Map, + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let Some(prefix_items) = object.get("prefixItems").and_then(Value::as_array) else { + return Ok(()); + }; + for (index, child_schema) in prefix_items.iter().enumerate() { + let mut child_path = instance_path.to_vec(); + child_path.push(SecretSegment::Index(index)); + discover_secret_patterns(root, child_schema, &child_path, references, output)?; + } + Ok(()) +} + +pub(super) fn discover_all_of_secret_patterns( + root: &Value, + object: &Map, + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let Some(branches) = object.get("allOf").and_then(Value::as_array) else { + return Ok(()); + }; + for branch in branches { + discover_secret_patterns(root, branch, instance_path, references, output)?; + } + Ok(()) +} + +pub(super) fn reject_array_applicator_secret_patterns( + root: &Value, + object: &Map, + instance_path: &[SecretSegment], + references: &HashSet, +) -> Result<(), String> { + for keyword in ["anyOf", "oneOf"] { + let Some(branches) = object.get(keyword).and_then(Value::as_array) else { + continue; + }; + for branch in branches { + reject_write_only_under_applicator(root, keyword, branch, instance_path, references)?; + } + } + Ok(()) +} + +pub(super) fn reject_value_applicator_secret_patterns( + root: &Value, + object: &Map, + keywords: &[&str], + instance_path: &[SecretSegment], + references: &HashSet, +) -> Result<(), String> { + for keyword in keywords { + if let Some(branch) = object.get(*keyword) + && branch.is_object() + { + reject_write_only_under_applicator(root, keyword, branch, instance_path, references)?; + } + } + Ok(()) +} + +pub(super) fn discover_contains_secret_patterns( + root: &Value, + object: &Map, + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let Some(contains) = object.get("contains") else { + return Ok(()); + }; + if !contains.is_object() { + return Ok(()); + } + let mut child_path = instance_path.to_vec(); + child_path.push(SecretSegment::Any); + discover_secret_patterns(root, contains, &child_path, references, output) +} + +pub(super) fn reject_object_applicator_secret_patterns( + root: &Value, + object: &Map, + keywords: &[&str], + instance_path: &[SecretSegment], + references: &HashSet, +) -> Result<(), String> { + for keyword in keywords { + let Some(branches) = object.get(*keyword).and_then(Value::as_object) else { + continue; + }; + for branch in branches.values().filter(|branch| branch.is_object()) { + reject_write_only_under_applicator(root, keyword, branch, instance_path, references)?; + } + } + Ok(()) +} + +pub(super) fn reject_write_only_under_applicator( + root: &Value, + keyword: &str, + schema: &Value, + instance_path: &[SecretSegment], + references: &HashSet, +) -> Result<(), String> { + let mut nested_patterns = Vec::new(); + discover_secret_patterns( + root, + schema, + instance_path, + references, + &mut nested_patterns, + )?; + if nested_patterns.is_empty() { + Ok(()) + } else { + Err(format!( + "writeOnly fields under '{keyword}' are not supported for secret redaction" + )) + } +} + +pub(super) fn push_pointer(pointer: &str, segment: &str) -> String { + format!("{pointer}/{}", escape_pointer(segment)) +} + +pub(super) fn escape_pointer(segment: &str) -> String { + segment.replace('~', "~0").replace('/', "~1") +} From 42bec22fb932cc73bfc3b570193b960010da0c27 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 21:48:16 -0400 Subject: [PATCH 034/102] test(cli): enforce command syntax and test boundaries Signed-off-by: Will Killian --- crates/cli/tests/architecture_tests.rs | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/cli/tests/architecture_tests.rs b/crates/cli/tests/architecture_tests.rs index a9279dc1d..06534a890 100644 --- a/crates/cli/tests/architecture_tests.rs +++ b/crates/cli/tests/architecture_tests.rs @@ -59,6 +59,44 @@ fn shared_services_do_not_depend_on_commands() { } } +#[test] +fn clap_syntax_is_owned_exclusively_by_commands() { + let src = source_root(); + for path in rust_files(&src) { + if path.starts_with(src.join("commands")) { + continue; + } + let source = fs::read_to_string(&path).unwrap(); + for marker in [ + "use clap::", + "clap::Parser", + "#[arg(", + "#[command(", + "#[value(", + ] { + assert!( + !source.contains(marker), + "{} contains command syntax marker {marker}", + path.display() + ); + } + } +} + +#[test] +fn tests_are_not_embedded_in_the_source_tree() { + let src = source_root(); + for path in rust_files(&src) { + let source = fs::read_to_string(&path).unwrap(); + assert!( + !source.contains("#[cfg(test)]\nmod tests {") + && !source.contains("#[cfg(test)]\r\nmod tests {"), + "inline test module found under src: {}", + path.display() + ); + } +} + #[test] fn agent_directories_do_not_import_one_another_or_commands() { let agents = source_root().join("agents"); From 487abaa34f6bebf48422fd210b4dbe995b3fab73 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 21:54:05 -0400 Subject: [PATCH 035/102] test(cli): serialize configuration cwd mutation Signed-off-by: Will Killian --- crates/cli/tests/coverage/shared/config_tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index a7f95803c..9758222ef 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -22,6 +22,7 @@ use crate::plugins::policy::{ }; struct PluginConfigDiscoveryScope { + _cwd_guard: crate::test_support::CwdTestScope, _guard: MutexGuard<'static, ()>, previous_cwd: PathBuf, previous_xdg_config_home: Option, @@ -33,6 +34,7 @@ struct PluginConfigDiscoveryScope { impl PluginConfigDiscoveryScope { fn enter(cwd: &std::path::Path, xdg_config_home: &std::path::Path) -> Self { + let cwd_guard = crate::test_support::CwdTestScope::locked(); let guard = crate::test_support::ENV_TEST_LOCK .lock() .unwrap_or_else(|error| error.into_inner()); @@ -51,6 +53,7 @@ impl PluginConfigDiscoveryScope { } std::env::set_current_dir(cwd).unwrap(); Self { + _cwd_guard: cwd_guard, _guard: guard, previous_cwd, previous_xdg_config_home, From dec98bd34085fe202a4e39c06c9dfde4923a7020 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 22:12:40 -0400 Subject: [PATCH 036/102] refactor(cli): assign runtime inputs to subsystems Signed-off-by: Will Killian --- crates/cli/src/commands/arguments.rs | 66 ++-- crates/cli/src/configuration/mod.rs | 158 +--------- crates/cli/src/configuration/wizard_model.rs | 12 +- crates/cli/src/hooks/mod.rs | 6 +- crates/cli/src/hooks/types.rs | 37 +++ crates/cli/src/mcp/gateway.rs | 2 +- crates/cli/src/mcp/mod.rs | 2 +- crates/cli/src/plugins/config_io.rs | 4 +- crates/cli/src/plugins/lifecycle.rs | 24 +- crates/cli/src/plugins/mod.rs | 6 +- crates/cli/src/plugins/pricing.rs | 14 +- crates/cli/src/plugins/types.rs | 72 +++++ crates/cli/src/process/mod.rs | 3 + crates/cli/src/process/types.rs | 19 ++ crates/cli/src/server/mod.rs | 4 + crates/cli/src/server/types.rs | 30 ++ .../coverage/commands/model_pricing_tests.rs | 14 +- .../cli/tests/coverage/shared/config_tests.rs | 1 + crates/cli/tests/coverage/shared/mcp_tests.rs | 2 +- .../shared/plugins_lifecycle_tests.rs | 292 +++++++++--------- .../tests/coverage/shared/plugins_tests.rs | 16 +- 21 files changed, 405 insertions(+), 379 deletions(-) create mode 100644 crates/cli/src/hooks/types.rs create mode 100644 crates/cli/src/plugins/types.rs create mode 100644 crates/cli/src/process/types.rs create mode 100644 crates/cli/src/server/types.rs diff --git a/crates/cli/src/commands/arguments.rs b/crates/cli/src/commands/arguments.rs index df1f6d4ba..97795cf5c 100644 --- a/crates/cli/src/commands/arguments.rs +++ b/crates/cli/src/commands/arguments.rs @@ -28,8 +28,8 @@ impl From for CodingAgent { } impl ServerArgs { - pub(crate) fn to_runtime(&self) -> crate::configuration::GatewayOverrides { - crate::configuration::GatewayOverrides { + pub(crate) fn to_runtime(&self) -> crate::server::GatewayOverrides { + crate::server::GatewayOverrides { config: self.config.clone(), bind: self.bind, openai_base_url: self.openai_base_url.clone(), @@ -43,8 +43,8 @@ impl ServerArgs { } impl RunCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::RunOverrides { - crate::configuration::RunOverrides { + pub(crate) fn into_runtime(self) -> crate::process::RunOverrides { + crate::process::RunOverrides { agent: self.agent.map(Into::into), config: self.config, openai_base_url: self.openai_base_url, @@ -59,8 +59,8 @@ impl RunCommand { } impl HookForwardCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::HookForwardRequest { - crate::configuration::HookForwardRequest { + pub(crate) fn into_runtime(self) -> crate::hooks::HookForwardRequest { + crate::hooks::HookForwardRequest { agent: self.agent.into(), gateway_url: self.gateway_url, generation_file: self.generation_file, @@ -619,7 +619,7 @@ impl UninstallCommand { } } -impl From for crate::configuration::PluginsScopeArgs { +impl From for crate::plugins::ConfigurationScope { fn from(value: PluginsScopeArgs) -> Self { Self { user: value.user, @@ -630,61 +630,61 @@ impl From for crate::configuration::PluginsScopeArgs { } impl PluginsEditCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::PluginsEditCommand { - crate::configuration::PluginsEditCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsEditRequest { + crate::plugins::PluginsEditRequest { scope: self.scope.into(), } } } impl PluginsAddCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::PluginsAddCommand { - crate::configuration::PluginsAddCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsAddRequest { + crate::plugins::PluginsAddRequest { scope: self.scope.into(), path: self.path, } } } impl PluginsValidateCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::PluginsValidateCommand { - crate::configuration::PluginsValidateCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsValidateRequest { + crate::plugins::PluginsValidateRequest { target: self.target, json: self.json, } } } impl PluginsListCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::PluginsListCommand { - crate::configuration::PluginsListCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsListRequest { + crate::plugins::PluginsListRequest { all: self.all, json: self.json, } } } impl PluginsInspectCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::PluginsInspectCommand { - crate::configuration::PluginsInspectCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsInspectRequest { + crate::plugins::PluginsInspectRequest { id: self.id, json: self.json, } } } impl PluginsEnableCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::PluginsEnableCommand { - crate::configuration::PluginsEnableCommand { id: self.id } + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsEnableRequest { + crate::plugins::PluginsEnableRequest { id: self.id } } } impl PluginsDisableCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::PluginsDisableCommand { - crate::configuration::PluginsDisableCommand { id: self.id } + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsDisableRequest { + crate::plugins::PluginsDisableRequest { id: self.id } } } impl PluginsRemoveCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::PluginsRemoveCommand { - crate::configuration::PluginsRemoveCommand { id: self.id } + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsRemoveRequest { + crate::plugins::PluginsRemoveRequest { id: self.id } } } -impl From for crate::configuration::PricingScopeArgs { +impl From for crate::plugins::ConfigurationScope { fn from(value: PricingScopeArgs) -> Self { Self { user: value.user, @@ -694,20 +694,20 @@ impl From for crate::configuration::PricingScopeArgs { } } impl PricingValidateCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::PricingValidateCommand { - crate::configuration::PricingValidateCommand { path: self.path } + pub(crate) fn into_runtime(self) -> crate::plugins::PricingValidateRequest { + crate::plugins::PricingValidateRequest { path: self.path } } } impl PricingInitCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::PricingInitCommand { - crate::configuration::PricingInitCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PricingInitRequest { + crate::plugins::PricingInitRequest { scope: self.scope.into(), } } } impl PricingAddSourceCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::PricingAddSourceCommand { - crate::configuration::PricingAddSourceCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PricingAddSourceRequest { + crate::plugins::PricingAddSourceRequest { scope: self.scope.into(), path: self.path, append: self.append, @@ -715,8 +715,8 @@ impl PricingAddSourceCommand { } } impl PricingResolveCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::PricingResolveCommand { - crate::configuration::PricingResolveCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PricingResolveRequest { + crate::plugins::PricingResolveRequest { model: self.model, provider: self.provider, prompt_tokens: self.prompt_tokens, @@ -735,7 +735,7 @@ pub(crate) enum GatewayModeArg { Required, } -impl From for crate::configuration::GatewayMode { +impl From for crate::hooks::GatewayMode { fn from(value: GatewayModeArg) -> Self { match value { GatewayModeArg::HookOnly => Self::HookOnly, diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs index bcfe6de5f..d8d606e83 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -33,6 +33,14 @@ use crate::plugins::lifecycle::{ dynamic_plugin_runtime_closure_digest, enforce_required_dynamic_plugin_startup, }; use crate::plugins::policy::DynamicPluginHostPolicy; +pub(crate) use crate::plugins::{ + ConfigurationScope, PluginsAddRequest, PluginsDisableRequest, PluginsEditRequest, + PluginsEnableRequest, PluginsInspectRequest, PluginsListRequest, PluginsRemoveRequest, + PluginsValidateRequest, PricingAddSourceRequest, PricingInitRequest, PricingResolveRequest, + PricingValidateRequest, +}; +pub(crate) use crate::process::RunOverrides; +pub(crate) use crate::server::GatewayOverrides; pub(crate) const BOOTSTRAP_FINGERPRINT_ENV: &str = "NEMO_RELAY_BOOTSTRAP_FINGERPRINT"; pub(crate) const PLUGIN_IDLE_TIMEOUT_ENV: &str = "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS"; @@ -46,68 +54,6 @@ pub(crate) const DEFAULT_MAX_PASSTHROUGH_BODY_BYTES: usize = 100 * 1024 * 1024; pub(crate) const GATEWAY_URL_ENV: &str = "NEMO_RELAY_GATEWAY_URL"; pub(crate) const TRANSPARENT_RUN_ENV: &str = "NEMO_RELAY_TRANSPARENT_RUN"; -/// Agent-neutral gateway configuration overrides supplied by any process entrypoint. -#[derive(Debug, Clone, Default)] -pub(crate) struct GatewayOverrides { - pub(crate) config: Option, - pub(crate) bind: Option, - pub(crate) openai_base_url: Option, - pub(crate) anthropic_base_url: Option, - pub(crate) plugin_config_path: Option, - pub(crate) ready_file: Option, - pub(crate) max_hook_payload_bytes: Option, - pub(crate) max_passthrough_body_bytes: Option, -} - -impl GatewayOverrides { - pub(crate) fn requested_daemon_mode(&self) -> bool { - self.bind.is_some() - || self.openai_base_url.is_some() - || self.anthropic_base_url.is_some() - || self.plugin_config_path.is_some() - || self.ready_file.is_some() - || self.max_hook_payload_bytes.is_some() - || self.max_passthrough_body_bytes.is_some() - || self.config.is_some() - } -} - -/// Runtime input for transparent agent execution, independent of Clap. -#[derive(Debug, Clone)] -pub(crate) struct RunOverrides { - pub(crate) agent: Option, - pub(crate) config: Option, - pub(crate) openai_base_url: Option, - pub(crate) anthropic_base_url: Option, - pub(crate) session_metadata: Option, - pub(crate) plugin_config_path: Option, - pub(crate) dry_run: bool, - pub(crate) print: bool, - pub(crate) command: Vec, -} - -/// Runtime hook-forward request, independent of command-line parsing. -#[derive(Debug, Clone)] -pub(crate) struct HookForwardRequest { - pub(crate) agent: CodingAgent, - pub(crate) gateway_url: Option, - pub(crate) generation_file: Option, - pub(crate) generation_token: Option, - pub(crate) forward_only: bool, - pub(crate) transparent_run: bool, - pub(crate) profile: Option, - pub(crate) session_metadata: Option, - pub(crate) gateway_mode: Option, - pub(crate) fail_closed: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum GatewayMode { - HookOnly, - Passthrough, - Required, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum IntegrationHost { Codex, @@ -164,82 +110,6 @@ pub(crate) struct UninstallRequest { pub(crate) dry_run: bool, } -#[derive(Debug, Clone, Default)] -pub(crate) struct PluginsScopeArgs { - pub(crate) user: bool, - pub(crate) project: bool, - pub(crate) global: bool, -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct PluginsEditCommand { - pub(crate) scope: PluginsScopeArgs, -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct PluginsAddCommand { - pub(crate) scope: PluginsScopeArgs, - pub(crate) path: PathBuf, -} - -#[derive(Debug, Clone)] -pub(crate) struct PluginsValidateCommand { - pub(crate) target: String, - pub(crate) json: bool, -} -#[derive(Debug, Clone, Default)] -pub(crate) struct PluginsListCommand { - pub(crate) all: bool, - pub(crate) json: bool, -} -#[derive(Debug, Clone)] -pub(crate) struct PluginsInspectCommand { - pub(crate) id: String, - pub(crate) json: bool, -} -#[derive(Debug, Clone)] -pub(crate) struct PluginsEnableCommand { - pub(crate) id: String, -} -#[derive(Debug, Clone)] -pub(crate) struct PluginsDisableCommand { - pub(crate) id: String, -} -#[derive(Debug, Clone)] -pub(crate) struct PluginsRemoveCommand { - pub(crate) id: String, -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct PricingScopeArgs { - pub(crate) user: bool, - pub(crate) project: bool, - pub(crate) global: bool, -} -#[derive(Debug, Clone)] -pub(crate) struct PricingValidateCommand { - pub(crate) path: PathBuf, -} -#[derive(Debug, Clone)] -pub(crate) struct PricingInitCommand { - pub(crate) scope: PricingScopeArgs, -} -#[derive(Debug, Clone)] -pub(crate) struct PricingAddSourceCommand { - pub(crate) scope: PricingScopeArgs, - pub(crate) path: PathBuf, - pub(crate) append: bool, -} -#[derive(Debug, Clone)] -pub(crate) struct PricingResolveCommand { - pub(crate) model: String, - pub(crate) provider: Option, - pub(crate) prompt_tokens: Option, - pub(crate) completion_tokens: Option, - pub(crate) cache_read_tokens: Option, - pub(crate) cache_write_tokens: Option, -} - #[derive(Debug, Clone)] pub(crate) struct GatewayConfig { pub(crate) bind: SocketAddr, @@ -1901,18 +1771,6 @@ fn header_json(headers: &HeaderMap, name: &str) -> Option { header_string(headers, name).and_then(|raw| serde_json::from_str(&raw).ok()) } -impl GatewayMode { - // Returns the installed hook-forward spelling for gateway mode headers. Keeping this separate - // from debug output prevents enum formatting changes from affecting persisted hook commands. - pub(crate) const fn as_arg(self) -> &'static str { - match self { - Self::HookOnly => "hook-only", - Self::Passthrough => "passthrough", - Self::Required => "required", - } - } -} - #[cfg(test)] #[path = "../../tests/coverage/shared/config_tests.rs"] mod tests; diff --git a/crates/cli/src/configuration/wizard_model.rs b/crates/cli/src/configuration/wizard_model.rs index 005a3a90f..ff740f368 100644 --- a/crates/cli/src/configuration/wizard_model.rs +++ b/crates/cli/src/configuration/wizard_model.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use toml_edit::{DocumentMut, Item, Table, value}; use crate::configuration::CodingAgent; -use crate::configuration::{PluginsEditCommand, PluginsScopeArgs}; +use crate::configuration::{ConfigurationScope, PluginsEditRequest}; use crate::error::CliError; /// Where the setup saves its output. @@ -35,22 +35,22 @@ impl ConfigScope { /// Maps the base setup scope to the plugin editor target for the guided continuation. /// /// `Project` and `Both` configure the project `plugins.toml`; `Global` configures the user -/// `plugins.toml`. Returns the existing `PluginsEditCommand` so the in-process editor behaves +/// `plugins.toml`. Returns the existing `PluginsEditRequest` so the in-process editor behaves /// exactly like the equivalent `nemo-relay plugins edit` invocation. -pub(super) fn plugins_edit_command_for_scope(scope: ConfigScope) -> PluginsEditCommand { +pub(super) fn plugins_edit_command_for_scope(scope: ConfigScope) -> PluginsEditRequest { let scope = match scope { - ConfigScope::Project | ConfigScope::Both => PluginsScopeArgs { + ConfigScope::Project | ConfigScope::Both => ConfigurationScope { user: false, project: true, global: false, }, - ConfigScope::Global => PluginsScopeArgs { + ConfigScope::Global => ConfigurationScope { user: true, project: false, global: false, }, }; - PluginsEditCommand { scope } + PluginsEditRequest { scope } } /// Returns the exact command a user runs to resume plugin setup after skipping the continuation. diff --git a/crates/cli/src/hooks/mod.rs b/crates/cli/src/hooks/mod.rs index 0eb46c678..23499d9d2 100644 --- a/crates/cli/src/hooks/mod.rs +++ b/crates/cli/src/hooks/mod.rs @@ -6,6 +6,7 @@ mod encoding; mod merging; mod response; +mod types; #[cfg(test)] pub(crate) use encoding::{ @@ -17,6 +18,7 @@ pub(crate) use encoding::{ }; pub(crate) use merging::merge_hooks; use response::*; +pub(crate) use types::{GatewayMode, HookForwardRequest}; use std::io::Read; use std::path::Path; @@ -28,9 +30,7 @@ use futures_util::StreamExt; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; use serde_json::{Value, json}; -use crate::configuration::{ - CodingAgent, GATEWAY_URL_ENV, GatewayMode, HookForwardRequest, TRANSPARENT_RUN_ENV, -}; +use crate::configuration::{CodingAgent, GATEWAY_URL_ENV, TRANSPARENT_RUN_ENV}; use crate::error::CliError; use crate::installation::generation::InstallGeneration; diff --git a/crates/cli/src/hooks/types.rs b/crates/cli/src/hooks/types.rs new file mode 100644 index 000000000..9959b309d --- /dev/null +++ b/crates/cli/src/hooks/types.rs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +use crate::agents::CodingAgent; + +#[derive(Debug, Clone)] +pub(crate) struct HookForwardRequest { + pub(crate) agent: CodingAgent, + pub(crate) gateway_url: Option, + pub(crate) generation_file: Option, + pub(crate) generation_token: Option, + pub(crate) forward_only: bool, + pub(crate) transparent_run: bool, + pub(crate) profile: Option, + pub(crate) session_metadata: Option, + pub(crate) gateway_mode: Option, + pub(crate) fail_closed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GatewayMode { + HookOnly, + Passthrough, + Required, +} + +impl GatewayMode { + pub(crate) const fn as_arg(self) -> &'static str { + match self { + Self::HookOnly => "hook-only", + Self::Passthrough => "passthrough", + Self::Required => "required", + } + } +} diff --git a/crates/cli/src/mcp/gateway.rs b/crates/cli/src/mcp/gateway.rs index 6f5b6b937..16050f77f 100644 --- a/crates/cli/src/mcp/gateway.rs +++ b/crates/cli/src/mcp/gateway.rs @@ -7,9 +7,9 @@ use std::net::SocketAddr; use std::time::Duration; use crate::bootstrap::{GatewayEndpoint, GatewaySpec}; -use crate::configuration::GatewayOverrides; use crate::error::CliError; use crate::installation::generation::{ActiveGenerationGuard, InstallGeneration}; +use crate::server::GatewayOverrides; const UNHEALTHY_CONFIRMATIONS: u8 = 3; const UNHEALTHY_CONFIRMATION_INTERVAL: Duration = Duration::from_millis(50); diff --git a/crates/cli/src/mcp/mod.rs b/crates/cli/src/mcp/mod.rs index 071ebf654..6eecd7060 100644 --- a/crates/cli/src/mcp/mod.rs +++ b/crates/cli/src/mcp/mod.rs @@ -14,9 +14,9 @@ use std::process::ExitCode; use serde_json::{Value, json}; -use crate::configuration::GatewayOverrides; use crate::error::CliError; use crate::installation::generation::{GENERATION_FILE_ENV, GENERATION_TOKEN_ENV}; +use crate::server::GatewayOverrides; pub(crate) const SERVER_NAME: &str = "nemo-relay"; const LAUNCH_ARGS: &[&str] = &["mcp"]; diff --git a/crates/cli/src/plugins/config_io.rs b/crates/cli/src/plugins/config_io.rs index ce777ddfa..b7af23843 100644 --- a/crates/cli/src/plugins/config_io.rs +++ b/crates/cli/src/plugins/config_io.rs @@ -13,7 +13,7 @@ use serde::Serialize; use serde_json::{Map, Value}; use crate::configuration::{ - PluginsScopeArgs, global_plugin_config_path, project_plugin_config_path, + ConfigurationScope, global_plugin_config_path, project_plugin_config_path, user_plugin_config_path, }; use crate::error::CliError; @@ -269,7 +269,7 @@ fn json_to_toml(value: Value) -> Result { }) } -pub(crate) fn target_scope(command: &PluginsScopeArgs) -> Result { +pub(crate) fn target_scope(command: &ConfigurationScope) -> Result { let selected = [command.user, command.project, command.global] .into_iter() .filter(|selected| *selected) diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index 2ce70ab59..ce0ce17b9 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -18,9 +18,9 @@ use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use crate::configuration::{ - GatewayOverrides, MAX_BOOTSTRAP_IDENTITY_FILE_BYTES, PluginsAddCommand, PluginsDisableCommand, - PluginsEnableCommand, PluginsInspectCommand, PluginsListCommand, PluginsRemoveCommand, - PluginsValidateCommand, ResolvedConfig, ResolvedDynamicPluginConfig, + GatewayOverrides, MAX_BOOTSTRAP_IDENTITY_FILE_BYTES, PluginsAddRequest, PluginsDisableRequest, + PluginsEnableRequest, PluginsInspectRequest, PluginsListRequest, PluginsRemoveRequest, + PluginsValidateRequest, ResolvedConfig, ResolvedDynamicPluginConfig, load_bounded_dynamic_plugin_manifest_bytes, read_bounded_regular_file, resolve_plugins_config, }; use crate::error::{CliError, PluginLifecycleFailureKind}; @@ -79,12 +79,12 @@ pub(crate) fn test_python_environment_digest_calls() -> usize { self::environment::environment_tree_digest_calls() } -pub(crate) fn add(command: PluginsAddCommand, server: &GatewayOverrides) -> Result<(), CliError> { +pub(crate) fn add(command: PluginsAddRequest, server: &GatewayOverrides) -> Result<(), CliError> { add_with_environment_runner(command, server, &ProcessPythonEnvironmentCommandRunner) } fn add_with_environment_runner( - command: PluginsAddCommand, + command: PluginsAddRequest, server: &GatewayOverrides, environment_runner: &impl PythonEnvironmentCommandRunner, ) -> Result<(), CliError> { @@ -249,7 +249,7 @@ pub(crate) fn enforce_required_dynamic_plugin_startup( } pub(crate) fn validate( - command: PluginsValidateCommand, + command: PluginsValidateRequest, server: &GatewayOverrides, ) -> Result<(), CliError> { match PluginTarget::parse(&command.target) { @@ -353,7 +353,7 @@ pub(crate) fn validate( } } -pub(crate) fn list(command: PluginsListCommand, server: &GatewayOverrides) -> Result<(), CliError> { +pub(crate) fn list(command: PluginsListRequest, server: &GatewayOverrides) -> Result<(), CliError> { let resolved = resolve_plugins_config(server.config.as_ref())?; let host_config_by_id = host_config_by_id(&resolved); let scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; @@ -391,7 +391,7 @@ pub(crate) fn list(command: PluginsListCommand, server: &GatewayOverrides) -> Re } pub(crate) fn inspect( - command: PluginsInspectCommand, + command: PluginsInspectRequest, server: &GatewayOverrides, ) -> Result<(), CliError> { let resolved = resolve_plugins_config(server.config.as_ref())?; @@ -424,21 +424,21 @@ pub(crate) fn inspect( } pub(crate) fn enable( - command: PluginsEnableCommand, + command: PluginsEnableRequest, server: &GatewayOverrides, ) -> Result<(), CliError> { mutate_enabled_state(command.id, server, true) } pub(crate) fn disable( - command: PluginsDisableCommand, + command: PluginsDisableRequest, server: &GatewayOverrides, ) -> Result<(), CliError> { mutate_enabled_state(command.id, server, false) } pub(crate) fn remove( - command: PluginsRemoveCommand, + command: PluginsRemoveRequest, server: &GatewayOverrides, ) -> Result<(), CliError> { let mut scopes = load_scoped_registries(server.config.as_ref())?; @@ -2134,7 +2134,7 @@ fn ensure_scope( scopes.len() - 1 } -fn scope_flags_selected(scope: &crate::configuration::PluginsScopeArgs) -> bool { +fn scope_flags_selected(scope: &crate::plugins::ConfigurationScope) -> bool { scope.user || scope.project || scope.global } diff --git a/crates/cli/src/plugins/mod.rs b/crates/cli/src/plugins/mod.rs index f4d114fbf..86d1467fc 100644 --- a/crates/cli/src/plugins/mod.rs +++ b/crates/cli/src/plugins/mod.rs @@ -16,7 +16,6 @@ use dialoguer::{Input, Select}; use nemo_relay::config_editor::{EditorFieldKind, EditorFieldSpec}; use serde_json::{Value, json}; -use crate::configuration::PluginsEditCommand; use crate::error::CliError; pub(crate) mod config_io; @@ -26,6 +25,9 @@ pub(crate) mod lifecycle; pub(crate) mod policy; pub(crate) mod pricing; pub(crate) mod schema; +mod types; + +pub(crate) use types::*; use self::config_io::*; use self::dynamic_editor::*; @@ -100,7 +102,7 @@ fn print_save_success(path: &Path) { ); } -pub(crate) fn edit(command: PluginsEditCommand) -> Result<(), CliError> { +pub(crate) fn edit(command: PluginsEditRequest) -> Result<(), CliError> { ensure_tty()?; let scope = target_scope(&command.scope)?; let path = target_path(scope)?; diff --git a/crates/cli/src/plugins/pricing.rs b/crates/cli/src/plugins/pricing.rs index 3f363d2ea..2e3707642 100644 --- a/crates/cli/src/plugins/pricing.rs +++ b/crates/cli/src/plugins/pricing.rs @@ -13,15 +13,15 @@ use nemo_relay::plugin::{PluginComponentSpec, PluginConfig}; use serde_json::Value; use crate::configuration::{ - GatewayOverrides, PricingAddSourceCommand, PricingInitCommand, PricingResolveCommand, - PricingScopeArgs, PricingValidateCommand, resolve_server_config, + ConfigurationScope, GatewayOverrides, PricingAddSourceRequest, PricingInitRequest, + PricingResolveRequest, PricingValidateRequest, resolve_server_config, }; use crate::error::CliError; use crate::plugins::config_io::{PluginConfigDocument, TargetScope, target_path, validate_config}; const PRICING_PLUGIN_KIND: &str = "pricing"; -pub(crate) fn validate(command: PricingValidateCommand) -> Result<(), CliError> { +pub(crate) fn validate(command: PricingValidateRequest) -> Result<(), CliError> { let catalog = read_pricing_catalog(&command.path)?; let entries = catalog.entries.len(); println!( @@ -32,7 +32,7 @@ pub(crate) fn validate(command: PricingValidateCommand) -> Result<(), CliError> Ok(()) } -pub(crate) fn init(command: PricingInitCommand) -> Result<(), CliError> { +pub(crate) fn init(command: PricingInitRequest) -> Result<(), CliError> { let scope = target_pricing_scope(&command.scope)?; let path = target_path(scope)?; update_plugin_config_document(&path, |plugin_config| { @@ -46,7 +46,7 @@ pub(crate) fn init(command: PricingInitCommand) -> Result<(), CliError> { Ok(()) } -pub(crate) fn add_source(command: PricingAddSourceCommand) -> Result<(), CliError> { +pub(crate) fn add_source(command: PricingAddSourceRequest) -> Result<(), CliError> { let source_path = std::fs::canonicalize(&command.path).map_err(|source| { CliError::Config(format!( "could not canonicalize model pricing catalog '{}': {source}", @@ -90,7 +90,7 @@ fn update_plugin_config_document( document.write() } -pub(crate) fn resolve(command: PricingResolveCommand) -> Result<(), CliError> { +pub(crate) fn resolve(command: PricingResolveRequest) -> Result<(), CliError> { let sources = pricing_catalog_sources_from_current_config()?; if sources.is_empty() { return Err(CliError::Config( @@ -217,7 +217,7 @@ fn resolve_pricing( }) } -fn target_pricing_scope(scope: &PricingScopeArgs) -> Result { +fn target_pricing_scope(scope: &ConfigurationScope) -> Result { let selected = [scope.user, scope.project, scope.global] .into_iter() .filter(|selected| *selected) diff --git a/crates/cli/src/plugins/types.rs b/crates/cli/src/plugins/types.rs new file mode 100644 index 000000000..ddbdb6e5a --- /dev/null +++ b/crates/cli/src/plugins/types.rs @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct ConfigurationScope { + pub(crate) user: bool, + pub(crate) project: bool, + pub(crate) global: bool, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct PluginsEditRequest { + pub(crate) scope: ConfigurationScope, +} +#[derive(Debug, Clone, Default)] +pub(crate) struct PluginsAddRequest { + pub(crate) scope: ConfigurationScope, + pub(crate) path: PathBuf, +} +#[derive(Debug, Clone)] +pub(crate) struct PluginsValidateRequest { + pub(crate) target: String, + pub(crate) json: bool, +} +#[derive(Debug, Clone, Default)] +pub(crate) struct PluginsListRequest { + pub(crate) all: bool, + pub(crate) json: bool, +} +#[derive(Debug, Clone)] +pub(crate) struct PluginsInspectRequest { + pub(crate) id: String, + pub(crate) json: bool, +} +#[derive(Debug, Clone)] +pub(crate) struct PluginsEnableRequest { + pub(crate) id: String, +} +#[derive(Debug, Clone)] +pub(crate) struct PluginsDisableRequest { + pub(crate) id: String, +} +#[derive(Debug, Clone)] +pub(crate) struct PluginsRemoveRequest { + pub(crate) id: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct PricingValidateRequest { + pub(crate) path: PathBuf, +} +#[derive(Debug, Clone)] +pub(crate) struct PricingInitRequest { + pub(crate) scope: ConfigurationScope, +} +#[derive(Debug, Clone)] +pub(crate) struct PricingAddSourceRequest { + pub(crate) scope: ConfigurationScope, + pub(crate) path: PathBuf, + pub(crate) append: bool, +} +#[derive(Debug, Clone)] +pub(crate) struct PricingResolveRequest { + pub(crate) model: String, + pub(crate) provider: Option, + pub(crate) prompt_tokens: Option, + pub(crate) completion_tokens: Option, + pub(crate) cache_read_tokens: Option, + pub(crate) cache_write_tokens: Option, +} diff --git a/crates/cli/src/process/mod.rs b/crates/cli/src/process/mod.rs index 1337220cd..1a1119300 100644 --- a/crates/cli/src/process/mod.rs +++ b/crates/cli/src/process/mod.rs @@ -5,6 +5,9 @@ pub(crate) mod detached; pub(crate) mod launcher; +mod types; + +pub(crate) use types::RunOverrides; use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; diff --git a/crates/cli/src/process/types.rs b/crates/cli/src/process/types.rs new file mode 100644 index 000000000..d86aea678 --- /dev/null +++ b/crates/cli/src/process/types.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +use crate::agents::CodingAgent; + +#[derive(Debug, Clone)] +pub(crate) struct RunOverrides { + pub(crate) agent: Option, + pub(crate) config: Option, + pub(crate) openai_base_url: Option, + pub(crate) anthropic_base_url: Option, + pub(crate) session_metadata: Option, + pub(crate) plugin_config_path: Option, + pub(crate) dry_run: bool, + pub(crate) print: bool, + pub(crate) command: Vec, +} diff --git a/crates/cli/src/server/mod.rs b/crates/cli/src/server/mod.rs index 8d999b7f1..e6c616516 100644 --- a/crates/cli/src/server/mod.rs +++ b/crates/cli/src/server/mod.rs @@ -1,6 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +mod types; + +pub(crate) use types::GatewayOverrides; + use std::future::Future; use std::net::SocketAddr; use std::path::Path; diff --git a/crates/cli/src/server/types.rs b/crates/cli/src/server/types.rs new file mode 100644 index 000000000..25f63d3c3 --- /dev/null +++ b/crates/cli/src/server/types.rs @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::net::SocketAddr; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default)] +pub(crate) struct GatewayOverrides { + pub(crate) config: Option, + pub(crate) bind: Option, + pub(crate) openai_base_url: Option, + pub(crate) anthropic_base_url: Option, + pub(crate) plugin_config_path: Option, + pub(crate) ready_file: Option, + pub(crate) max_hook_payload_bytes: Option, + pub(crate) max_passthrough_body_bytes: Option, +} + +impl GatewayOverrides { + pub(crate) fn requested_daemon_mode(&self) -> bool { + self.bind.is_some() + || self.openai_base_url.is_some() + || self.anthropic_base_url.is_some() + || self.plugin_config_path.is_some() + || self.ready_file.is_some() + || self.max_hook_payload_bytes.is_some() + || self.max_passthrough_body_bytes.is_some() + || self.config.is_some() + } +} diff --git a/crates/cli/tests/coverage/commands/model_pricing_tests.rs b/crates/cli/tests/coverage/commands/model_pricing_tests.rs index c4c5ff965..8f005e561 100644 --- a/crates/cli/tests/coverage/commands/model_pricing_tests.rs +++ b/crates/cli/tests/coverage/commands/model_pricing_tests.rs @@ -34,30 +34,30 @@ fn catalog() -> PricingCatalog { #[test] fn pricing_helpers_cover_scopes_components_sources_and_usage() { assert_eq!( - target_pricing_scope(&PricingScopeArgs::default()).unwrap(), + target_pricing_scope(&ConfigurationScope::default()).unwrap(), TargetScope::User ); assert_eq!( - target_pricing_scope(&PricingScopeArgs { + target_pricing_scope(&ConfigurationScope { project: true, - ..PricingScopeArgs::default() + ..ConfigurationScope::default() }) .unwrap(), TargetScope::Project ); assert_eq!( - target_pricing_scope(&PricingScopeArgs { + target_pricing_scope(&ConfigurationScope { global: true, - ..PricingScopeArgs::default() + ..ConfigurationScope::default() }) .unwrap(), TargetScope::Global ); assert!( - target_pricing_scope(&PricingScopeArgs { + target_pricing_scope(&ConfigurationScope { user: true, project: true, - ..PricingScopeArgs::default() + ..ConfigurationScope::default() }) .unwrap_err() .to_string() diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index 9758222ef..1336633ec 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; +use crate::hooks::GatewayMode; use axum::http::HeaderValue; use base64::Engine; use nemo_relay::plugin::dynamic::{ diff --git a/crates/cli/tests/coverage/shared/mcp_tests.rs b/crates/cli/tests/coverage/shared/mcp_tests.rs index 04a00c5cf..fbe63333b 100644 --- a/crates/cli/tests/coverage/shared/mcp_tests.rs +++ b/crates/cli/tests/coverage/shared/mcp_tests.rs @@ -89,7 +89,7 @@ impl Drop for TransparentRunEnvironment { async fn transparent_mcp_requires_the_wrapper_gateway_url() { let _environment = TransparentRunEnvironment::without_gateway(); - let error = run(&crate::configuration::GatewayOverrides::default()) + let error = run(&crate::server::GatewayOverrides::default()) .await .unwrap_err() .to_string(); diff --git a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs index 642ffcb44..6076d6229 100644 --- a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs @@ -9,9 +9,9 @@ use std::{ use super::*; use crate::configuration::{ - GatewayOverrides, PluginsAddCommand, PluginsDisableCommand, PluginsEnableCommand, - PluginsInspectCommand, PluginsListCommand, PluginsRemoveCommand, PluginsScopeArgs, - PluginsValidateCommand, + ConfigurationScope, GatewayOverrides, PluginsAddRequest, PluginsDisableRequest, + PluginsEnableRequest, PluginsInspectRequest, PluginsListRequest, PluginsRemoveRequest, + PluginsValidateRequest, }; use crate::error::PluginLifecycleFailureKind; use base64::Engine; @@ -616,10 +616,10 @@ fn tracked_native_plugin_example_satisfies_default_trust_policy() { materialize_native_example_manifest(&plugin_dir); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -646,10 +646,10 @@ fn tracked_native_plugin_example_rejects_tampered_artifact() { std::fs::write(artifact_path, b"tampered native plugin example fixture").unwrap(); let error = add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -1753,14 +1753,14 @@ fn add_registers_dynamic_plugin_in_project_plugins_toml() { write_dynamic_manifest(&plugin_dir, "acme.guardrail"); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir.clone(), }, - &crate::configuration::GatewayOverrides::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap(); @@ -1792,10 +1792,10 @@ fn add_rejects_unreadable_declared_config_schema() { std::fs::remove_file(plugin_dir.join("config.schema.json")).unwrap(); let error = add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -1826,7 +1826,7 @@ fn validate_path_rejects_invalid_declared_config_schema() { ); let error = validate( - PluginsValidateCommand { + PluginsValidateRequest { target: plugin_dir.to_string_lossy().into_owned(), json: false, }, @@ -1857,10 +1857,10 @@ fn validate_id_checks_resolved_host_config_against_declared_schema() { ); let server = GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -1879,7 +1879,7 @@ port = "not-an-integer" std::fs::write(&plugins_toml, rendered).unwrap(); let error = validate( - PluginsValidateCommand { + PluginsValidateRequest { target: "acme.schema-config".into(), json: false, }, @@ -1904,10 +1904,10 @@ fn add_provisions_persists_and_removes_managed_python_environment() { let server = GatewayOverrides::default(); add_with_environment_runner( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir.clone(), }, @@ -1999,7 +1999,7 @@ fn add_provisions_persists_and_removes_managed_python_environment() { .any(|arg| arg == "-e" || arg == "--editable") ); enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.python".into(), }, &server, @@ -2013,7 +2013,7 @@ fn add_provisions_persists_and_removes_managed_python_environment() { let stale_marker = environment_path.join("stale-marker"); std::fs::write(&stale_marker, b"stale").unwrap(); remove( - PluginsRemoveCommand { + PluginsRemoveRequest { id: "acme.python".into(), }, &server, @@ -2028,10 +2028,10 @@ fn add_provisions_persists_and_removes_managed_python_environment() { assert_eq!(removed.record.source.environment_ref, None); add_with_environment_runner( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -2054,10 +2054,10 @@ fn add_rolls_back_python_environment_when_installation_fails() { let runner = FakePythonEnvironmentRunner::failing_install(); let error = add_with_environment_runner( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -2097,10 +2097,10 @@ fn enable_rejects_missing_managed_python_environment() { let runner = FakePythonEnvironmentRunner::default(); let server = GatewayOverrides::default(); add_with_environment_runner( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -2120,7 +2120,7 @@ fn enable_rejects_missing_managed_python_environment() { std::fs::remove_dir_all(&environment_ref).unwrap(); let error = enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.python-missing".into(), }, &server, @@ -2156,10 +2156,10 @@ fn enable_rejects_python_environment_outside_managed_location() { let runner = FakePythonEnvironmentRunner::default(); let server = GatewayOverrides::default(); add_with_environment_runner( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -2188,7 +2188,7 @@ fn enable_rejects_python_environment_outside_managed_location() { scope.save().unwrap(); let error = enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.python-outside".into(), }, &server, @@ -2229,10 +2229,10 @@ fn add_requires_manifest_root_for_python_workers() { let runner = FakePythonEnvironmentRunner::default(); let error = add_with_environment_runner( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -2271,10 +2271,10 @@ fn add_rejects_python_entrypoint_module_that_is_not_integrity_checked_artifact() let runner = FakePythonEnvironmentRunner::default(); let error = add_with_environment_runner( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -2377,10 +2377,10 @@ fn remove_can_retry_after_guarded_environment_cleanup_failure() { let runner = FakePythonEnvironmentRunner::default(); let server = GatewayOverrides::default(); add_with_environment_runner( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -2415,7 +2415,7 @@ fn remove_can_retry_after_guarded_environment_cleanup_failure() { scope.save().unwrap(); let error = remove( - PluginsRemoveCommand { + PluginsRemoveRequest { id: "acme.python-retry".into(), }, &server, @@ -2446,7 +2446,7 @@ fn remove_can_retry_after_guarded_environment_cleanup_failure() { scope.save().unwrap(); remove( - PluginsRemoveCommand { + PluginsRemoveRequest { id: "acme.python-retry".into(), }, &server, @@ -2471,13 +2471,13 @@ fn active_dynamic_plugin_components_project_enabled_native_records_only() { let plugin_dir = temp.path().join("plugins").join("native"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_native_dynamic_manifest(&plugin_dir, "acme.native"); - let server = crate::configuration::GatewayOverrides::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -2490,7 +2490,7 @@ fn active_dynamic_plugin_components_project_enabled_native_records_only() { assert!(inactive.is_empty()); enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.native".into(), }, &server, @@ -2518,13 +2518,13 @@ fn active_dynamic_plugin_components_accept_enabled_worker_records() { let plugin_dir = temp.path().join("plugins").join("worker"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.worker"); - let server = crate::configuration::GatewayOverrides::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -2532,7 +2532,7 @@ fn active_dynamic_plugin_components_accept_enabled_worker_records() { ) .unwrap(); enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.worker".into(), }, &server, @@ -2561,13 +2561,13 @@ fn active_dynamic_plugin_components_accept_worker_records_without_manifest_ref() let plugin_dir = temp.path().join("plugins").join("worker"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.worker"); - let server = crate::configuration::GatewayOverrides::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -2575,7 +2575,7 @@ fn active_dynamic_plugin_components_accept_worker_records_without_manifest_ref() ) .unwrap(); enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.worker".into(), }, &server, @@ -2616,26 +2616,26 @@ fn add_rejects_duplicate_dynamic_plugin_ids() { write_dynamic_manifest(&plugin_dir, "acme.guardrail"); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir.clone(), }, - &crate::configuration::GatewayOverrides::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap(); let error = add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, - &crate::configuration::GatewayOverrides::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap_err() .to_string(); @@ -2660,10 +2660,10 @@ fn add_rejects_scope_flags_when_explicit_config_is_set() { }; let error = add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -2694,14 +2694,14 @@ allowed = false .unwrap(); let error = add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, - &crate::configuration::GatewayOverrides::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap_err(); @@ -2768,14 +2768,14 @@ fn list_and_inspect_render_discovered_dynamic_plugins() { write_dynamic_manifest(&plugin_dir, "acme.guardrail"); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, - &crate::configuration::GatewayOverrides::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap(); @@ -2840,14 +2840,14 @@ fn validate_renders_summary_for_path_and_id_targets() { let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.guardrail"); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, - &crate::configuration::GatewayOverrides::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap(); @@ -2892,22 +2892,22 @@ fn validate_renders_summary_for_path_and_id_targets() { assert!(id_summary.contains("desired.enabled: false")); let missing_validate = validate( - PluginsValidateCommand { + PluginsValidateRequest { target: "missing.plugin".into(), json: false, }, - &crate::configuration::GatewayOverrides::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap_err() .to_string(); assert!(missing_validate.contains("not registered")); let missing_inspect = inspect( - PluginsInspectCommand { + PluginsInspectRequest { id: "missing.plugin".into(), json: false, }, - &crate::configuration::GatewayOverrides::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap_err() .to_string(); @@ -2915,8 +2915,8 @@ fn validate_renders_summary_for_path_and_id_targets() { assert_eq!( list( - PluginsListCommand::default(), - &crate::configuration::GatewayOverrides::default() + PluginsListRequest::default(), + &crate::server::GatewayOverrides::default() ) .unwrap(), () @@ -2931,13 +2931,13 @@ fn enable_disable_and_remove_persist_lifecycle_state() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.guardrail"); - let server = crate::configuration::GatewayOverrides::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -2946,7 +2946,7 @@ fn enable_disable_and_remove_persist_lifecycle_state() { .unwrap(); enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.guardrail".into(), }, &server, @@ -2961,7 +2961,7 @@ fn enable_disable_and_remove_persist_lifecycle_state() { assert!(enabled.record.spec.enabled); disable( - PluginsDisableCommand { + PluginsDisableRequest { id: "acme.guardrail".into(), }, &server, @@ -2975,7 +2975,7 @@ fn enable_disable_and_remove_persist_lifecycle_state() { assert!(!disabled.record.spec.enabled); remove( - PluginsRemoveCommand { + PluginsRemoveRequest { id: "acme.guardrail".into(), }, &server, @@ -3000,7 +3000,7 @@ fn enable_disable_and_remove_persist_lifecycle_state() { assert!(all_list.contains("tombstoned")); let error = enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.guardrail".into(), }, &server, @@ -3034,8 +3034,8 @@ fn add_with_explicit_config_uses_sibling_plugins_and_state_files() { }; add( - PluginsAddCommand { - scope: PluginsScopeArgs::default(), + PluginsAddRequest { + scope: ConfigurationScope::default(), path: plugin_dir, }, &server, @@ -3142,7 +3142,7 @@ fn manually_configured_python_worker_cannot_enable_without_lifecycle_add() { assert!(summary.contains("runtime environment is unavailable")); let error = enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.python-direct".into(), }, &GatewayOverrides::default(), @@ -3245,10 +3245,10 @@ fn hydrate_persists_updated_policy_and_error_state() { write_dynamic_manifest(&plugin_dir, "acme.persist-blocked"); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir.clone(), }, @@ -3517,13 +3517,13 @@ fn enable_refuses_dynamic_plugins_blocked_by_host_policy_and_persists_status() { std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.enable-blocked"); - let server = crate::configuration::GatewayOverrides::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -3546,7 +3546,7 @@ fn enable_refuses_dynamic_plugins_blocked_by_host_policy_and_persists_status() { .unwrap(); let error = enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.enable-blocked".into(), }, &server, @@ -3598,13 +3598,13 @@ fn disable_succeeds_when_registered_plugin_manifest_is_unreadable() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.guardrail"); - let server = crate::configuration::GatewayOverrides::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir.clone(), }, @@ -3613,7 +3613,7 @@ fn disable_succeeds_when_registered_plugin_manifest_is_unreadable() { .unwrap(); enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.guardrail".into(), }, &server, @@ -3623,7 +3623,7 @@ fn disable_succeeds_when_registered_plugin_manifest_is_unreadable() { std::fs::remove_file(plugin_dir.join("relay-plugin.toml")).unwrap(); disable( - PluginsDisableCommand { + PluginsDisableRequest { id: "acme.guardrail".into(), }, &server, @@ -3647,13 +3647,13 @@ fn validate_marks_registered_plugins_invalid_when_host_policy_blocks_them() { std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.validate-blocked"); - let server = crate::configuration::GatewayOverrides::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -3678,7 +3678,7 @@ fn validate_marks_registered_plugins_invalid_when_host_policy_blocks_them() { .unwrap(); validate( - PluginsValidateCommand { + PluginsValidateRequest { target: "acme.validate-blocked".into(), json: false, }, @@ -3790,13 +3790,13 @@ fn add_can_revive_tombstoned_records() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.revive"); - let server = crate::configuration::GatewayOverrides::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir.clone(), }, @@ -3805,7 +3805,7 @@ fn add_can_revive_tombstoned_records() { .unwrap(); remove( - PluginsRemoveCommand { + PluginsRemoveRequest { id: "acme.revive".into(), }, &server, @@ -3813,10 +3813,10 @@ fn add_can_revive_tombstoned_records() { .unwrap(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -3844,10 +3844,10 @@ fn json_helpers_emit_stable_success_and_failure_shapes() { let server = GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -3978,10 +3978,10 @@ fn remove_tolerates_unreadable_non_target_manifest_entries() { let server = GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -4001,7 +4001,7 @@ fn remove_tolerates_unreadable_non_target_manifest_entries() { .unwrap(); remove( - PluginsRemoveCommand { + PluginsRemoveRequest { id: "acme.guardrail".into(), }, &server, @@ -4105,10 +4105,10 @@ fn inspect_redacts_host_config_values() { let server = GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, @@ -4185,10 +4185,10 @@ fn inspect_distinguishes_empty_host_config_from_missing_host_config() { let server = GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { + PluginsAddRequest { + scope: ConfigurationScope { project: true, - ..PluginsScopeArgs::default() + ..ConfigurationScope::default() }, path: plugin_dir, }, diff --git a/crates/cli/tests/coverage/shared/plugins_tests.rs b/crates/cli/tests/coverage/shared/plugins_tests.rs index ef7167397..c8cfe3c5c 100644 --- a/crates/cli/tests/coverage/shared/plugins_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_tests.rs @@ -3,7 +3,7 @@ use super::*; use crate::configuration::{ - PluginsScopeArgs, global_plugin_config_path, project_plugin_config_path, + ConfigurationScope, global_plugin_config_path, project_plugin_config_path, user_plugin_config_path, }; use nemo_relay::config_editor::{EditorConfig, EditorSchema}; @@ -150,30 +150,30 @@ fn local_llm_guardrails_component_config(config_yaml: &str) -> serde_json::Map Date: Sun, 12 Jul 2026 22:15:50 -0400 Subject: [PATCH 037/102] refactor(cli): make configuration scope explicit Signed-off-by: Will Killian --- crates/cli/src/bootstrap/mod.rs | 3 +- crates/cli/src/commands/arguments.rs | 20 +- crates/cli/src/configuration/mod.rs | 10 +- crates/cli/src/configuration/wizard_model.rs | 14 +- crates/cli/src/diagnostics/mod.rs | 6 +- crates/cli/src/plugins/config_io.rs | 24 +-- crates/cli/src/plugins/lifecycle.rs | 11 +- crates/cli/src/plugins/pricing.rs | 29 ++- crates/cli/src/plugins/types.rs | 13 +- crates/cli/src/process/launcher.rs | 8 +- .../coverage/commands/model_pricing_tests.rs | 24 +-- .../shared/plugins_lifecycle_tests.rs | 175 ++++-------------- .../tests/coverage/shared/plugins_tests.rs | 26 +-- 13 files changed, 114 insertions(+), 249 deletions(-) diff --git a/crates/cli/src/bootstrap/mod.rs b/crates/cli/src/bootstrap/mod.rs index e8e874107..b7b5a541e 100644 --- a/crates/cli/src/bootstrap/mod.rs +++ b/crates/cli/src/bootstrap/mod.rs @@ -17,13 +17,14 @@ use std::time::{Duration, Instant}; use serde::Deserialize; -use crate::configuration::{GatewayOverrides, resolve_persistent_server_config}; +use crate::configuration::resolve_persistent_server_config; use crate::error::CliError; use crate::gateway::client::{ self as gateway_client, RelayHealth, VerifiedHttpError, VerifiedHttpResponse, loopback_bind, probe_with_instance as probe_relay_health_with_instance, }; use crate::process::detached; +use crate::server::GatewayOverrides; #[cfg(test)] pub(crate) use detached::{ WINDOWS_CREATE_BREAKAWAY_FROM_JOB, WINDOWS_CREATE_NEW_PROCESS_GROUP, WINDOWS_CREATE_NO_WINDOW, diff --git a/crates/cli/src/commands/arguments.rs b/crates/cli/src/commands/arguments.rs index 97795cf5c..61c159b3b 100644 --- a/crates/cli/src/commands/arguments.rs +++ b/crates/cli/src/commands/arguments.rs @@ -621,10 +621,12 @@ impl UninstallCommand { impl From for crate::plugins::ConfigurationScope { fn from(value: PluginsScopeArgs) -> Self { - Self { - user: value.user, - project: value.project, - global: value.global, + match (value.user, value.project, value.global) { + (false, false, false) => Self::Default, + (true, false, false) => Self::User, + (false, true, false) => Self::Project, + (false, false, true) => Self::Global, + _ => Self::Invalid, } } } @@ -686,10 +688,12 @@ impl PluginsRemoveCommand { impl From for crate::plugins::ConfigurationScope { fn from(value: PricingScopeArgs) -> Self { - Self { - user: value.user, - project: value.project, - global: value.global, + match (value.user, value.project, value.global) { + (false, false, false) => Self::Default, + (true, false, false) => Self::User, + (false, true, false) => Self::Project, + (false, false, true) => Self::Global, + _ => Self::Invalid, } } } diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs index d8d606e83..cbb840404 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -33,14 +33,8 @@ use crate::plugins::lifecycle::{ dynamic_plugin_runtime_closure_digest, enforce_required_dynamic_plugin_startup, }; use crate::plugins::policy::DynamicPluginHostPolicy; -pub(crate) use crate::plugins::{ - ConfigurationScope, PluginsAddRequest, PluginsDisableRequest, PluginsEditRequest, - PluginsEnableRequest, PluginsInspectRequest, PluginsListRequest, PluginsRemoveRequest, - PluginsValidateRequest, PricingAddSourceRequest, PricingInitRequest, PricingResolveRequest, - PricingValidateRequest, -}; -pub(crate) use crate::process::RunOverrides; -pub(crate) use crate::server::GatewayOverrides; +use crate::process::RunOverrides; +use crate::server::GatewayOverrides; pub(crate) const BOOTSTRAP_FINGERPRINT_ENV: &str = "NEMO_RELAY_BOOTSTRAP_FINGERPRINT"; pub(crate) const PLUGIN_IDLE_TIMEOUT_ENV: &str = "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS"; diff --git a/crates/cli/src/configuration/wizard_model.rs b/crates/cli/src/configuration/wizard_model.rs index ff740f368..814f2c50b 100644 --- a/crates/cli/src/configuration/wizard_model.rs +++ b/crates/cli/src/configuration/wizard_model.rs @@ -8,8 +8,8 @@ use std::path::{Path, PathBuf}; use toml_edit::{DocumentMut, Item, Table, value}; use crate::configuration::CodingAgent; -use crate::configuration::{ConfigurationScope, PluginsEditRequest}; use crate::error::CliError; +use crate::plugins::{ConfigurationScope, PluginsEditRequest}; /// Where the setup saves its output. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -39,16 +39,8 @@ impl ConfigScope { /// exactly like the equivalent `nemo-relay plugins edit` invocation. pub(super) fn plugins_edit_command_for_scope(scope: ConfigScope) -> PluginsEditRequest { let scope = match scope { - ConfigScope::Project | ConfigScope::Both => ConfigurationScope { - user: false, - project: true, - global: false, - }, - ConfigScope::Global => ConfigurationScope { - user: true, - project: false, - global: false, - }, + ConfigScope::Project | ConfigScope::Both => ConfigurationScope::Project, + ConfigScope::Global => ConfigurationScope::User, }; PluginsEditRequest { scope } } diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index af42b976f..433864f56 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -30,11 +30,11 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest; use uuid::Uuid; use crate::configuration::{ - AgentConfigs, CodingAgent, DynamicPluginHostConfigStatus, GatewayConfig, GatewayOverrides, - ResolvedConfig, default_plugin_config_paths, effective_plugin_toml_sources, - resolve_server_config, + AgentConfigs, CodingAgent, DynamicPluginHostConfigStatus, GatewayConfig, ResolvedConfig, + default_plugin_config_paths, effective_plugin_toml_sources, resolve_server_config, }; use crate::error::CliError; +use crate::server::GatewayOverrides; const NETWORK_TIMEOUT: Duration = Duration::from_secs(2); const PRICING_PLUGIN_KIND: &str = "pricing"; diff --git a/crates/cli/src/plugins/config_io.rs b/crates/cli/src/plugins/config_io.rs index b7af23843..2179f3418 100644 --- a/crates/cli/src/plugins/config_io.rs +++ b/crates/cli/src/plugins/config_io.rs @@ -13,10 +13,10 @@ use serde::Serialize; use serde_json::{Map, Value}; use crate::configuration::{ - ConfigurationScope, global_plugin_config_path, project_plugin_config_path, - user_plugin_config_path, + global_plugin_config_path, project_plugin_config_path, user_plugin_config_path, }; use crate::error::CliError; +use crate::plugins::ConfigurationScope; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TargetScope { @@ -270,21 +270,13 @@ fn json_to_toml(value: Value) -> Result { } pub(crate) fn target_scope(command: &ConfigurationScope) -> Result { - let selected = [command.user, command.project, command.global] - .into_iter() - .filter(|selected| *selected) - .count(); - if selected > 1 { - return Err(CliError::Config( + match command { + ConfigurationScope::Default | ConfigurationScope::User => Ok(TargetScope::User), + ConfigurationScope::Project => Ok(TargetScope::Project), + ConfigurationScope::Global => Ok(TargetScope::Global), + ConfigurationScope::Invalid => Err(CliError::Config( "choose only one of --user, --project, or --global".into(), - )); - } - if command.project { - Ok(TargetScope::Project) - } else if command.global { - Ok(TargetScope::Global) - } else { - Ok(TargetScope::User) + )), } } diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index ce0ce17b9..154787652 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -18,20 +18,23 @@ use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use crate::configuration::{ - GatewayOverrides, MAX_BOOTSTRAP_IDENTITY_FILE_BYTES, PluginsAddRequest, PluginsDisableRequest, - PluginsEnableRequest, PluginsInspectRequest, PluginsListRequest, PluginsRemoveRequest, - PluginsValidateRequest, ResolvedConfig, ResolvedDynamicPluginConfig, + MAX_BOOTSTRAP_IDENTITY_FILE_BYTES, ResolvedConfig, ResolvedDynamicPluginConfig, load_bounded_dynamic_plugin_manifest_bytes, read_bounded_regular_file, resolve_plugins_config, }; use crate::error::{CliError, PluginLifecycleFailureKind}; use crate::plugins::policy::{ EvaluatedDynamicPluginHostPolicy, evaluate_dynamic_plugin_host_policy, }; +use crate::server::GatewayOverrides; use super::config_io::{ append_dynamic_plugin_reference, remove_dynamic_plugin_reference, target_scope, }; use super::schema::PluginConfigSchema; +use super::{ + PluginsAddRequest, PluginsDisableRequest, PluginsEnableRequest, PluginsInspectRequest, + PluginsListRequest, PluginsRemoveRequest, PluginsValidateRequest, +}; mod environment; mod render; @@ -2135,7 +2138,7 @@ fn ensure_scope( } fn scope_flags_selected(scope: &crate::plugins::ConfigurationScope) -> bool { - scope.user || scope.project || scope.global + !matches!(scope, crate::plugins::ConfigurationScope::Default) } fn restore_plugins_toml(path: &std::path::Path, original: Option<&[u8]>) -> Result<(), CliError> { diff --git a/crates/cli/src/plugins/pricing.rs b/crates/cli/src/plugins/pricing.rs index 2e3707642..b6ec7112b 100644 --- a/crates/cli/src/plugins/pricing.rs +++ b/crates/cli/src/plugins/pricing.rs @@ -12,9 +12,12 @@ use nemo_relay::codec::response::Usage; use nemo_relay::plugin::{PluginComponentSpec, PluginConfig}; use serde_json::Value; -use crate::configuration::{ - ConfigurationScope, GatewayOverrides, PricingAddSourceRequest, PricingInitRequest, - PricingResolveRequest, PricingValidateRequest, resolve_server_config, +use crate::configuration::resolve_server_config; +use crate::server::GatewayOverrides; + +use super::{ + ConfigurationScope, PricingAddSourceRequest, PricingInitRequest, PricingResolveRequest, + PricingValidateRequest, }; use crate::error::CliError; use crate::plugins::config_io::{PluginConfigDocument, TargetScope, target_path, validate_config}; @@ -218,21 +221,13 @@ fn resolve_pricing( } fn target_pricing_scope(scope: &ConfigurationScope) -> Result { - let selected = [scope.user, scope.project, scope.global] - .into_iter() - .filter(|selected| *selected) - .count(); - if selected > 1 { - return Err(CliError::Config( + match scope { + ConfigurationScope::Default | ConfigurationScope::User => Ok(TargetScope::User), + ConfigurationScope::Project => Ok(TargetScope::Project), + ConfigurationScope::Global => Ok(TargetScope::Global), + ConfigurationScope::Invalid => Err(CliError::Config( "choose only one of --user, --project, or --global".into(), - )); - } - if scope.project { - Ok(TargetScope::Project) - } else if scope.global { - Ok(TargetScope::Global) - } else { - Ok(TargetScope::User) + )), } } diff --git a/crates/cli/src/plugins/types.rs b/crates/cli/src/plugins/types.rs index ddbdb6e5a..05f842436 100644 --- a/crates/cli/src/plugins/types.rs +++ b/crates/cli/src/plugins/types.rs @@ -4,10 +4,15 @@ use std::path::PathBuf; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub(crate) struct ConfigurationScope { - pub(crate) user: bool, - pub(crate) project: bool, - pub(crate) global: bool, +pub(crate) enum ConfigurationScope { + /// No explicit scope flag was supplied. Runtime behavior defaults to the user scope. + #[default] + Default, + User, + Project, + Global, + /// More than one mutually exclusive command scope was supplied. + Invalid, } #[derive(Debug, Clone, Default)] diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index ff697ae8b..0891baae3 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -15,14 +15,16 @@ use tokio::sync::oneshot; use tokio::task::JoinHandle; use crate::configuration::{ - AgentConfigs, CodingAgent, GatewayConfig, GatewayOverrides, RELAY_PLUGIN_ID, - RELAY_SOURCE_PLUGIN_ID, ResolvedConfig, RunOverrides, any_config_file_exists, - resolve_run_config, + AgentConfigs, CodingAgent, GatewayConfig, RELAY_PLUGIN_ID, RELAY_SOURCE_PLUGIN_ID, + ResolvedConfig, any_config_file_exists, resolve_run_config, }; use crate::error::CliError; use crate::hooks::{generated_hooks, transparent_hook_forward_command}; use crate::plugins::lifecycle::ActiveDynamicPluginComponent; use crate::server; +use crate::server::GatewayOverrides; + +use super::RunOverrides; /// Runs a child coding-agent command behind an ephemeral local gateway. /// diff --git a/crates/cli/tests/coverage/commands/model_pricing_tests.rs b/crates/cli/tests/coverage/commands/model_pricing_tests.rs index 8f005e561..2dba37f31 100644 --- a/crates/cli/tests/coverage/commands/model_pricing_tests.rs +++ b/crates/cli/tests/coverage/commands/model_pricing_tests.rs @@ -38,30 +38,18 @@ fn pricing_helpers_cover_scopes_components_sources_and_usage() { TargetScope::User ); assert_eq!( - target_pricing_scope(&ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }) - .unwrap(), + target_pricing_scope(&ConfigurationScope::Project).unwrap(), TargetScope::Project ); assert_eq!( - target_pricing_scope(&ConfigurationScope { - global: true, - ..ConfigurationScope::default() - }) - .unwrap(), + target_pricing_scope(&ConfigurationScope::Global).unwrap(), TargetScope::Global ); assert!( - target_pricing_scope(&ConfigurationScope { - user: true, - project: true, - ..ConfigurationScope::default() - }) - .unwrap_err() - .to_string() - .contains("choose only one") + target_pricing_scope(&ConfigurationScope::Invalid) + .unwrap_err() + .to_string() + .contains("choose only one") ); let mut plugin_config = PluginConfig::default(); diff --git a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs index 6076d6229..a61217b9d 100644 --- a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs @@ -8,12 +8,12 @@ use std::{ }; use super::*; -use crate::configuration::{ - ConfigurationScope, GatewayOverrides, PluginsAddRequest, PluginsDisableRequest, - PluginsEnableRequest, PluginsInspectRequest, PluginsListRequest, PluginsRemoveRequest, - PluginsValidateRequest, -}; use crate::error::PluginLifecycleFailureKind; +use crate::plugins::{ + ConfigurationScope, PluginsAddRequest, PluginsDisableRequest, PluginsEnableRequest, + PluginsInspectRequest, PluginsListRequest, PluginsRemoveRequest, PluginsValidateRequest, +}; +use crate::server::GatewayOverrides; use base64::Engine; use nemo_relay::plugin::dynamic::{ DynamicPluginFailurePhase, WorkerPluginLoadSpec, load_worker_plugins, @@ -617,10 +617,7 @@ fn tracked_native_plugin_example_satisfies_default_trust_policy() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &GatewayOverrides::default(), @@ -647,10 +644,7 @@ fn tracked_native_plugin_example_rejects_tampered_artifact() { let error = add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &GatewayOverrides::default(), @@ -1754,10 +1748,7 @@ fn add_registers_dynamic_plugin_in_project_plugins_toml() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir.clone(), }, &crate::server::GatewayOverrides::default(), @@ -1793,10 +1784,7 @@ fn add_rejects_unreadable_declared_config_schema() { let error = add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &GatewayOverrides::default(), @@ -1858,10 +1846,7 @@ fn validate_id_checks_resolved_host_config_against_declared_schema() { let server = GatewayOverrides::default(); add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -1905,10 +1890,7 @@ fn add_provisions_persists_and_removes_managed_python_environment() { add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir.clone(), }, &server, @@ -2029,10 +2011,7 @@ fn add_provisions_persists_and_removes_managed_python_environment() { add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -2055,10 +2034,7 @@ fn add_rolls_back_python_environment_when_installation_fails() { let error = add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &GatewayOverrides::default(), @@ -2098,10 +2074,7 @@ fn enable_rejects_missing_managed_python_environment() { let server = GatewayOverrides::default(); add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -2157,10 +2130,7 @@ fn enable_rejects_python_environment_outside_managed_location() { let server = GatewayOverrides::default(); add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -2230,10 +2200,7 @@ fn add_requires_manifest_root_for_python_workers() { let error = add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &GatewayOverrides::default(), @@ -2272,10 +2239,7 @@ fn add_rejects_python_entrypoint_module_that_is_not_integrity_checked_artifact() let error = add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &GatewayOverrides::default(), @@ -2378,10 +2342,7 @@ fn remove_can_retry_after_guarded_environment_cleanup_failure() { let server = GatewayOverrides::default(); add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -2475,10 +2436,7 @@ fn active_dynamic_plugin_components_project_enabled_native_records_only() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -2522,10 +2480,7 @@ fn active_dynamic_plugin_components_accept_enabled_worker_records() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -2565,10 +2520,7 @@ fn active_dynamic_plugin_components_accept_worker_records_without_manifest_ref() add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -2617,10 +2569,7 @@ fn add_rejects_duplicate_dynamic_plugin_ids() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir.clone(), }, &crate::server::GatewayOverrides::default(), @@ -2629,10 +2578,7 @@ fn add_rejects_duplicate_dynamic_plugin_ids() { let error = add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &crate::server::GatewayOverrides::default(), @@ -2661,10 +2607,7 @@ fn add_rejects_scope_flags_when_explicit_config_is_set() { let error = add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -2695,10 +2638,7 @@ allowed = false let error = add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &crate::server::GatewayOverrides::default(), @@ -2769,10 +2709,7 @@ fn list_and_inspect_render_discovered_dynamic_plugins() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &crate::server::GatewayOverrides::default(), @@ -2841,10 +2778,7 @@ fn validate_renders_summary_for_path_and_id_targets() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &crate::server::GatewayOverrides::default(), @@ -2935,10 +2869,7 @@ fn enable_disable_and_remove_persist_lifecycle_state() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -3246,10 +3177,7 @@ fn hydrate_persists_updated_policy_and_error_state() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir.clone(), }, &GatewayOverrides::default(), @@ -3521,10 +3449,7 @@ fn enable_refuses_dynamic_plugins_blocked_by_host_policy_and_persists_status() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -3602,10 +3527,7 @@ fn disable_succeeds_when_registered_plugin_manifest_is_unreadable() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir.clone(), }, &server, @@ -3651,10 +3573,7 @@ fn validate_marks_registered_plugins_invalid_when_host_policy_blocks_them() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -3794,10 +3713,7 @@ fn add_can_revive_tombstoned_records() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir.clone(), }, &server, @@ -3814,10 +3730,7 @@ fn add_can_revive_tombstoned_records() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -3845,10 +3758,7 @@ fn json_helpers_emit_stable_success_and_failure_shapes() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -3979,10 +3889,7 @@ fn remove_tolerates_unreadable_non_target_manifest_entries() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -4106,10 +4013,7 @@ fn inspect_redacts_host_config_values() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -4186,10 +4090,7 @@ fn inspect_distinguishes_empty_host_config_from_missing_host_config() { add( PluginsAddRequest { - scope: ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }, + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, diff --git a/crates/cli/tests/coverage/shared/plugins_tests.rs b/crates/cli/tests/coverage/shared/plugins_tests.rs index c8cfe3c5c..4b71f2b59 100644 --- a/crates/cli/tests/coverage/shared/plugins_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_tests.rs @@ -3,9 +3,9 @@ use super::*; use crate::configuration::{ - ConfigurationScope, global_plugin_config_path, project_plugin_config_path, - user_plugin_config_path, + global_plugin_config_path, project_plugin_config_path, user_plugin_config_path, }; +use crate::plugins::ConfigurationScope; use nemo_relay::config_editor::{EditorConfig, EditorSchema}; use nemo_relay::observability::plugin_component::{OBSERVABILITY_PLUGIN_KIND, ObservabilityConfig}; use nemo_relay::plugin::{ConfigPolicy, PluginComponentSpec, PluginConfig}; @@ -154,29 +154,17 @@ fn target_scope_defaults_to_user_and_rejects_conflicts() { TargetScope::User ); assert_eq!( - target_scope(&ConfigurationScope { - project: true, - ..ConfigurationScope::default() - }) - .unwrap(), + target_scope(&ConfigurationScope::Project).unwrap(), TargetScope::Project ); assert_eq!( - target_scope(&ConfigurationScope { - global: true, - ..ConfigurationScope::default() - }) - .unwrap(), + target_scope(&ConfigurationScope::Global).unwrap(), TargetScope::Global ); - let error = target_scope(&ConfigurationScope { - user: true, - project: true, - ..ConfigurationScope::default() - }) - .unwrap_err() - .to_string(); + let error = target_scope(&ConfigurationScope::Invalid) + .unwrap_err() + .to_string(); assert!(error.contains("choose only one"), "error was: {error}"); } From 5ebb030a34d0b6601733d161148f462655d321b6 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 22:19:55 -0400 Subject: [PATCH 038/102] refactor(cli): colocate core command arguments Signed-off-by: Will Killian --- crates/cli/src/commands/arguments.rs | 233 +----------------- crates/cli/src/commands/completions/mod.rs | 12 +- crates/cli/src/commands/configuration.rs | 12 +- crates/cli/src/commands/diagnostics.rs | 23 +- crates/cli/src/commands/hook_forward.rs | 69 +++++- crates/cli/src/commands/mcp.rs | 2 +- crates/cli/src/commands/mod.rs | 8 +- crates/cli/src/commands/plugins.rs | 3 +- crates/cli/src/commands/run.rs | 51 +++- crates/cli/src/commands/serve.rs | 50 ++++ .../cli/tests/coverage/commands/main_tests.rs | 7 +- 11 files changed, 231 insertions(+), 239 deletions(-) create mode 100644 crates/cli/src/commands/serve.rs diff --git a/crates/cli/src/commands/arguments.rs b/crates/cli/src/commands/arguments.rs index 61c159b3b..b18900008 100644 --- a/crates/cli/src/commands/arguments.rs +++ b/crates/cli/src/commands/arguments.rs @@ -1,11 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::net::SocketAddr; use std::path::PathBuf; use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; +use super::completions::CompletionsCommand; +use super::configuration::ConfigCommand; +use super::diagnostics::{AgentsCommand, DoctorCommand}; +use super::hook_forward::HookForwardCommand; +use super::run::{EasyPathCommand, RunCommand}; +use super::serve::ServerArgs; use crate::agents::CodingAgent; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] @@ -27,54 +32,6 @@ impl From for CodingAgent { } } -impl ServerArgs { - pub(crate) fn to_runtime(&self) -> crate::server::GatewayOverrides { - crate::server::GatewayOverrides { - config: self.config.clone(), - bind: self.bind, - openai_base_url: self.openai_base_url.clone(), - anthropic_base_url: self.anthropic_base_url.clone(), - plugin_config_path: self.plugin_config_path.clone(), - ready_file: self.ready_file.clone(), - max_hook_payload_bytes: self.max_hook_payload_bytes, - max_passthrough_body_bytes: self.max_passthrough_body_bytes, - } - } -} - -impl RunCommand { - pub(crate) fn into_runtime(self) -> crate::process::RunOverrides { - crate::process::RunOverrides { - agent: self.agent.map(Into::into), - config: self.config, - openai_base_url: self.openai_base_url, - anthropic_base_url: self.anthropic_base_url, - session_metadata: self.session_metadata, - plugin_config_path: self.plugin_config_path, - dry_run: self.dry_run, - print: self.print, - command: self.command, - } - } -} - -impl HookForwardCommand { - pub(crate) fn into_runtime(self) -> crate::hooks::HookForwardRequest { - crate::hooks::HookForwardRequest { - agent: self.agent.into(), - gateway_url: self.gateway_url, - generation_file: self.generation_file, - generation_token: self.generation_token, - forward_only: self.forward_only, - transparent_run: self.transparent_run, - profile: self.profile, - session_metadata: self.session_metadata, - gateway_mode: self.gateway_mode.map(Into::into), - fail_closed: self.fail_closed, - } - } -} - #[derive(Debug, Clone, Parser)] #[command(name = "nemo-relay")] #[command(about = "Coding-agent gateway for NeMo Relay observability")] @@ -164,25 +121,6 @@ pub(crate) enum Command { HookForward(HookForwardCommand), } -/// Args for `nemo-relay doctor`. `--json` is on this command (rather than as a global flag) -/// so it doesn't pollute the help output of subcommands where it has no meaning. -#[derive(Debug, Clone, Args)] -pub(crate) struct DoctorCommand { - /// Limit readiness checks to one supported agent. - #[arg(value_enum)] - pub(crate) agent: Option, - /// Diagnose an installed coding-agent integration instead of the normal Relay config. - #[arg(long, value_enum)] - pub(crate) plugin: Option, - /// Plugin install state directory. Defaults to the platform data directory. - #[arg(long)] - pub(crate) install_dir: Option, - /// Emit machine-readable JSON instead of the formatted human report. Versioned via - /// `schema_version`; stable shape for CI / evaluation harness consumption. - #[arg(long)] - pub(crate) json: bool, -} - #[derive(Debug, Clone, Args)] pub(crate) struct InstallCommand { #[arg(value_enum)] @@ -207,49 +145,6 @@ pub(crate) struct UninstallCommand { pub(crate) dry_run: bool, } -/// Args for `nemo-relay agents`. Shares the `--json` shape with `nemo-relay doctor`'s -/// `agents` field so the two outputs can be unified by downstream consumers. -#[derive(Debug, Clone, Args)] -pub(crate) struct AgentsCommand { - /// Emit the supported + detected agent list as JSON instead of formatted text. - #[arg(long)] - pub(crate) json: bool, -} - -/// Args for `nemo-relay completions ` (print to stdout) or `nemo-relay completions --install` -/// (auto-detect $SHELL and write to the standard fpath / completions directory). -/// -/// The Homebrew / curl-install flows drop completion scripts automatically; this subcommand is -/// the escape hatch for CI, custom shells, regeneration, and `cargo install` users where no -/// post-install hook runs. -#[derive(Debug, Clone, Args)] -pub(crate) struct CompletionsCommand { - /// Shell to generate the completion script for. Optional when used with `--install` (the - /// installer auto-detects `$SHELL`). - #[arg(value_enum)] - pub(crate) shell: Option, - /// Write the completion script into the shell's standard completions directory instead of - /// printing to stdout. Auto-detects `$SHELL` when no shell argument is given. - #[arg(long)] - pub(crate) install: bool, -} - -/// Args for `nemo-relay config`. The setup wizard runs by default; `--reset` short-circuits to -/// a destructive clear. An optional positional agent name scopes both the wizard and `--reset` -/// to a single agent's settings, leaving other agents' blocks untouched. -#[derive(Debug, Clone, Args)] -pub(crate) struct ConfigCommand { - /// Scope this run to one agent. Wizard skips the agent multi-select; `--reset` removes - /// only that agent's block from the existing config file. Omit to operate on all agents. - #[arg(value_enum)] - pub(crate) agent: Option, - /// Delete the project config file or the scoped transparent-wrapper agent block. Persistent - /// Hermes MCP, hooks, and trust are removed with `nemo-relay uninstall hermes`. The wizard - /// does not run after reset; invoke `nemo-relay config` again to recreate configuration. - #[arg(long)] - pub(crate) reset: bool, -} - /// Args for `nemo-relay plugins`. #[derive(Debug, Clone, Args)] pub(crate) struct PluginsCommand { @@ -478,104 +373,6 @@ pub(crate) struct PluginsRemoveCommand { pub(crate) id: String, } -#[derive(Debug, Clone, Default, Args)] -pub(crate) struct ServerArgs { - /// Path to an explicit config file (disables auto-discovery of workspace/global/system) - #[arg(long)] - pub(crate) config: Option, - /// Address for the gateway to listen on in daemon mode (default 127.0.0.1:4040) - #[arg(long, env = "NEMO_RELAY_GATEWAY_BIND")] - pub(crate) bind: Option, - /// Upstream OpenAI-compatible base URL (e.g. https://api.openai.com/v1, NVIDIA inference) - #[arg(long, env = "NEMO_RELAY_OPENAI_BASE_URL")] - pub(crate) openai_base_url: Option, - /// Upstream Anthropic base URL (e.g. https://api.anthropic.com) - #[arg(long, env = "NEMO_RELAY_ANTHROPIC_BASE_URL")] - pub(crate) anthropic_base_url: Option, - /// Internal override for the plugin configuration file. - #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] - pub(crate) plugin_config_path: Option, - /// Internal readiness file used by plugin sidecar bootstrap. - #[arg(long, hide = true)] - pub(crate) ready_file: Option, - /// Maximum accepted coding-agent hook payload size, in bytes. - #[arg(long, env = "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES")] - pub(crate) max_hook_payload_bytes: Option, - /// Maximum accepted provider passthrough request body size, in bytes. - #[arg(long, env = "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES")] - pub(crate) max_passthrough_body_bytes: Option, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct HookForwardCommand { - #[arg(value_enum)] - pub(crate) agent: AgentArg, - #[arg(long)] - pub(crate) gateway_url: Option, - /// Private install-generation fence used by generated persistent hooks. - #[arg(long, hide = true)] - pub(crate) generation_file: Option, - /// Immutable generation identity paired with `generation_file` by installed hooks. - #[arg(long, hide = true)] - pub(crate) generation_token: Option, - /// Forward to an existing gateway without starting or recovering Relay. - #[arg( - long, - conflicts_with_all = ["generation_file", "generation_token"] - )] - pub(crate) forward_only: bool, - /// Marks the process-private hook source injected by `nemo-relay run`. - #[arg( - long, - hide = true, - conflicts_with_all = ["generation_file", "generation_token", "forward_only"] - )] - pub(crate) transparent_run: bool, - #[arg(long)] - pub(crate) profile: Option, - #[arg(long)] - pub(crate) session_metadata: Option, - #[arg(long, value_enum)] - pub(crate) gateway_mode: Option, - #[arg(long)] - pub(crate) fail_closed: bool, -} - -/// Args for the easy-path agent shortcut (`nemo-relay claude`, `nemo-relay codex`, etc.). -/// Holds only pass-through agent args; the agent itself is selected by which subcommand variant -/// is invoked, and upstream settings come from the resolved config file. If no config file is -/// present, the dispatcher fires setup. -#[derive(Debug, Clone, Args)] -pub(crate) struct EasyPathCommand { - /// Pass-through args forwarded to the underlying agent process. Use `--` to separate them - /// from `nemo-relay`'s own flags. See the `Examples` section below for agent-specific shapes. - #[arg(last = true)] - pub(crate) command: Vec, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct RunCommand { - #[arg(long, value_enum)] - pub(crate) agent: Option, - #[arg(long)] - pub(crate) config: Option, - #[arg(long)] - pub(crate) openai_base_url: Option, - #[arg(long)] - pub(crate) anthropic_base_url: Option, - #[arg(long)] - pub(crate) session_metadata: Option, - /// Internal override for the plugin configuration file. - #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] - pub(crate) plugin_config_path: Option, - #[arg(long)] - pub(crate) dry_run: bool, - #[arg(long)] - pub(crate) print: bool, - #[arg(last = true)] - pub(crate) command: Vec, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] #[value(rename_all = "kebab-case")] pub(crate) enum IntegrationHost { @@ -730,21 +527,3 @@ impl PricingResolveCommand { } } } - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -#[value(rename_all = "kebab-case")] -pub(crate) enum GatewayModeArg { - HookOnly, - Passthrough, - Required, -} - -impl From for crate::hooks::GatewayMode { - fn from(value: GatewayModeArg) -> Self { - match value { - GatewayModeArg::HookOnly => Self::HookOnly, - GatewayModeArg::Passthrough => Self::Passthrough, - GatewayModeArg::Required => Self::Required, - } - } -} diff --git a/crates/cli/src/commands/completions/mod.rs b/crates/cli/src/commands/completions/mod.rs index 89ef1c718..c4ff6d08d 100644 --- a/crates/cli/src/commands/completions/mod.rs +++ b/crates/cli/src/commands/completions/mod.rs @@ -5,11 +5,21 @@ mod install; use std::process::ExitCode; +use clap::Args; + use clap::CommandFactory; -use crate::commands::arguments::{Cli, CompletionsCommand}; +use crate::commands::arguments::Cli; use crate::error::CliError; +#[derive(Debug, Clone, Args)] +pub(crate) struct CompletionsCommand { + #[arg(value_enum)] + pub(crate) shell: Option, + #[arg(long)] + pub(crate) install: bool, +} + pub(super) fn execute(command: CompletionsCommand) -> Result { if command.install { let path = install::install(command.shell)?; diff --git a/crates/cli/src/commands/configuration.rs b/crates/cli/src/commands/configuration.rs index c74914a68..03ac02d57 100644 --- a/crates/cli/src/commands/configuration.rs +++ b/crates/cli/src/commands/configuration.rs @@ -3,9 +3,19 @@ use std::process::ExitCode; -use super::arguments::ConfigCommand; +use clap::Args; + +use super::arguments::AgentArg; use crate::error::CliError; +#[derive(Debug, Clone, Args)] +pub(crate) struct ConfigCommand { + #[arg(value_enum)] + pub(crate) agent: Option, + #[arg(long)] + pub(crate) reset: bool, +} + pub(super) async fn execute(command: ConfigCommand) -> Result { let agent = command.agent.map(Into::into); if command.reset { diff --git a/crates/cli/src/commands/diagnostics.rs b/crates/cli/src/commands/diagnostics.rs index b83244a7d..09438b9bf 100644 --- a/crates/cli/src/commands/diagnostics.rs +++ b/crates/cli/src/commands/diagnostics.rs @@ -1,11 +1,32 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::path::PathBuf; use std::process::ExitCode; -use super::arguments::DoctorCommand; +use clap::Args; + +use super::arguments::{AgentArg, IntegrationHost}; use crate::error::CliError; +#[derive(Debug, Clone, Args)] +pub(crate) struct DoctorCommand { + #[arg(value_enum)] + pub(crate) agent: Option, + #[arg(long, value_enum)] + pub(crate) plugin: Option, + #[arg(long)] + pub(crate) install_dir: Option, + #[arg(long)] + pub(crate) json: bool, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct AgentsCommand { + #[arg(long)] + pub(crate) json: bool, +} + pub(super) async fn execute(command: DoctorCommand) -> Result { if let Some(plugin) = command.plugin { crate::agents::install::doctor(plugin.into(), command.install_dir, command.json) diff --git a/crates/cli/src/commands/hook_forward.rs b/crates/cli/src/commands/hook_forward.rs index a579170f3..d6246a1ff 100644 --- a/crates/cli/src/commands/hook_forward.rs +++ b/crates/cli/src/commands/hook_forward.rs @@ -1,9 +1,76 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use super::arguments::HookForwardCommand; +use std::path::PathBuf; + +use clap::{Args, ValueEnum}; + +use super::arguments::AgentArg; use crate::error::CliError; +#[derive(Debug, Clone, Args)] +pub(crate) struct HookForwardCommand { + #[arg(value_enum)] + pub(crate) agent: AgentArg, + #[arg(long)] + pub(crate) gateway_url: Option, + #[arg(long, hide = true)] + pub(crate) generation_file: Option, + #[arg(long, hide = true)] + pub(crate) generation_token: Option, + #[arg(long, conflicts_with_all = ["generation_file", "generation_token"])] + pub(crate) forward_only: bool, + #[arg( + long, + hide = true, + conflicts_with_all = ["generation_file", "generation_token", "forward_only"] + )] + pub(crate) transparent_run: bool, + #[arg(long)] + pub(crate) profile: Option, + #[arg(long)] + pub(crate) session_metadata: Option, + #[arg(long, value_enum)] + pub(crate) gateway_mode: Option, + #[arg(long)] + pub(crate) fail_closed: bool, +} + +impl HookForwardCommand { + fn into_runtime(self) -> crate::hooks::HookForwardRequest { + crate::hooks::HookForwardRequest { + agent: self.agent.into(), + gateway_url: self.gateway_url, + generation_file: self.generation_file, + generation_token: self.generation_token, + forward_only: self.forward_only, + transparent_run: self.transparent_run, + profile: self.profile, + session_metadata: self.session_metadata, + gateway_mode: self.gateway_mode.map(Into::into), + fail_closed: self.fail_closed, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub(crate) enum GatewayModeArg { + HookOnly, + Passthrough, + Required, +} + +impl From for crate::hooks::GatewayMode { + fn from(value: GatewayModeArg) -> Self { + match value { + GatewayModeArg::HookOnly => Self::HookOnly, + GatewayModeArg::Passthrough => Self::Passthrough, + GatewayModeArg::Required => Self::Required, + } + } +} + pub(super) async fn execute(command: HookForwardCommand) -> Result<(), CliError> { crate::hooks::hook_forward(command.into_runtime()).await } diff --git a/crates/cli/src/commands/mcp.rs b/crates/cli/src/commands/mcp.rs index 5a2e0fef5..9d132fc6c 100644 --- a/crates/cli/src/commands/mcp.rs +++ b/crates/cli/src/commands/mcp.rs @@ -3,7 +3,7 @@ use std::process::ExitCode; -use super::arguments::ServerArgs; +use super::serve::ServerArgs; use crate::error::CliError; pub(super) async fn execute(server: &ServerArgs) -> Result { diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index f4e3db0fa..46d90187e 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -13,14 +13,18 @@ mod mcp; mod model_pricing; mod plugins; mod run; +mod serve; use std::process::ExitCode; use clap::Parser; -use self::arguments::{Cli, Command, ServerArgs}; +use self::arguments::{Cli, Command}; #[cfg(test)] -use self::arguments::{CompletionsCommand, PluginsCommand, PricingCommand}; +use self::arguments::{PluginsCommand, PricingCommand}; +#[cfg(test)] +use self::completions::CompletionsCommand; +use self::serve::ServerArgs; use crate::agents::CodingAgent; use crate::{ configuration as runtime_configuration, diagnostics as runtime_diagnostics, error, server, diff --git a/crates/cli/src/commands/plugins.rs b/crates/cli/src/commands/plugins.rs index 07fa2bc6c..cd462390e 100644 --- a/crates/cli/src/commands/plugins.rs +++ b/crates/cli/src/commands/plugins.rs @@ -3,7 +3,8 @@ use std::process::ExitCode; -use super::arguments::{PluginsCommand, PluginsSubcommand, ServerArgs}; +use super::arguments::{PluginsCommand, PluginsSubcommand}; +use super::serve::ServerArgs; use crate::error::CliError; pub(super) fn execute(command: PluginsCommand, server: &ServerArgs) -> Result { diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index 7ece72da2..100c8170e 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -1,12 +1,61 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::path::PathBuf; use std::process::ExitCode; -use super::arguments::{EasyPathCommand, RunCommand, ServerArgs}; +use clap::Args; + +use super::arguments::AgentArg; +use super::serve::ServerArgs; use crate::agents::CodingAgent; use crate::error::CliError; +/// Args for an easy-path agent shortcut. +#[derive(Debug, Clone, Args)] +pub(crate) struct EasyPathCommand { + #[arg(last = true)] + pub(super) command: Vec, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct RunCommand { + #[arg(long, value_enum)] + pub(super) agent: Option, + #[arg(long)] + pub(super) config: Option, + #[arg(long)] + pub(super) openai_base_url: Option, + #[arg(long)] + pub(super) anthropic_base_url: Option, + #[arg(long)] + pub(super) session_metadata: Option, + #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] + pub(super) plugin_config_path: Option, + #[arg(long)] + pub(super) dry_run: bool, + #[arg(long)] + pub(super) print: bool, + #[arg(last = true)] + pub(super) command: Vec, +} + +impl RunCommand { + fn into_runtime(self) -> crate::process::RunOverrides { + crate::process::RunOverrides { + agent: self.agent.map(Into::into), + config: self.config, + openai_base_url: self.openai_base_url, + anthropic_base_url: self.anthropic_base_url, + session_metadata: self.session_metadata, + plugin_config_path: self.plugin_config_path, + dry_run: self.dry_run, + print: self.print, + command: self.command, + } + } +} + pub(super) async fn execute( command: RunCommand, server: &ServerArgs, diff --git a/crates/cli/src/commands/serve.rs b/crates/cli/src/commands/serve.rs new file mode 100644 index 000000000..3b83fff9e --- /dev/null +++ b/crates/cli/src/commands/serve.rs @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::net::SocketAddr; +use std::path::PathBuf; + +use clap::Args; + +#[derive(Debug, Clone, Default, Args)] +pub(crate) struct ServerArgs { + /// Path to an explicit config file (disables auto-discovery of workspace/global/system) + #[arg(long)] + pub(super) config: Option, + /// Address for the gateway to listen on in daemon mode (default 127.0.0.1:4040) + #[arg(long, env = "NEMO_RELAY_GATEWAY_BIND")] + pub(super) bind: Option, + /// Upstream OpenAI-compatible base URL (e.g. https://api.openai.com/v1, NVIDIA inference) + #[arg(long, env = "NEMO_RELAY_OPENAI_BASE_URL")] + pub(super) openai_base_url: Option, + /// Upstream Anthropic base URL (e.g. https://api.anthropic.com) + #[arg(long, env = "NEMO_RELAY_ANTHROPIC_BASE_URL")] + pub(super) anthropic_base_url: Option, + /// Internal override for the plugin configuration file. + #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] + pub(super) plugin_config_path: Option, + /// Internal readiness file used by plugin sidecar bootstrap. + #[arg(long, hide = true)] + pub(super) ready_file: Option, + /// Maximum accepted coding-agent hook payload size, in bytes. + #[arg(long, env = "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES")] + pub(super) max_hook_payload_bytes: Option, + /// Maximum accepted provider passthrough request body size, in bytes. + #[arg(long, env = "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES")] + pub(super) max_passthrough_body_bytes: Option, +} + +impl ServerArgs { + pub(super) fn to_runtime(&self) -> crate::server::GatewayOverrides { + crate::server::GatewayOverrides { + config: self.config.clone(), + bind: self.bind, + openai_base_url: self.openai_base_url.clone(), + anthropic_base_url: self.anthropic_base_url.clone(), + plugin_config_path: self.plugin_config_path.clone(), + ready_file: self.ready_file.clone(), + max_hook_payload_bytes: self.max_hook_payload_bytes, + max_passthrough_body_bytes: self.max_passthrough_body_bytes, + } + } +} diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index 201b54d3e..3cb3c0aa3 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -4,11 +4,12 @@ use clap::Parser; use std::ffi::OsString; +use super::completions::CompletionsCommand; +use super::serve::ServerArgs; use super::*; use crate::commands::arguments::{ - CompletionsCommand, PluginsCommand, PluginsEditCommand, PluginsInspectCommand, - PluginsListCommand, PluginsSubcommand, PluginsValidateCommand, PricingSubcommand, - PricingValidateCommand, ServerArgs, + PluginsCommand, PluginsEditCommand, PluginsInspectCommand, PluginsListCommand, + PluginsSubcommand, PluginsValidateCommand, PricingSubcommand, PricingValidateCommand, }; struct EnvScope { From e03493becb722baf3604b67d688d251400fad80b Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 22:22:52 -0400 Subject: [PATCH 039/102] refactor(cli): finish command syntax colocation Signed-off-by: Will Killian --- crates/cli/src/commands/arguments.rs | 529 ------------------ .../cli/src/commands/completions/install.rs | 2 +- crates/cli/src/commands/completions/mod.rs | 2 +- crates/cli/src/commands/configuration.rs | 2 +- crates/cli/src/commands/diagnostics.rs | 3 +- crates/cli/src/commands/hook_forward.rs | 2 +- crates/cli/src/commands/install.rs | 71 ++- crates/cli/src/commands/mod.rs | 10 +- crates/cli/src/commands/model_pricing.rs | 135 ++++- crates/cli/src/commands/plugins.rs | 212 ++++++- crates/cli/src/commands/root.rs | 123 ++++ crates/cli/src/commands/run.rs | 2 +- .../cli/tests/coverage/commands/main_tests.rs | 5 +- 13 files changed, 554 insertions(+), 544 deletions(-) delete mode 100644 crates/cli/src/commands/arguments.rs create mode 100644 crates/cli/src/commands/root.rs diff --git a/crates/cli/src/commands/arguments.rs b/crates/cli/src/commands/arguments.rs deleted file mode 100644 index b18900008..000000000 --- a/crates/cli/src/commands/arguments.rs +++ /dev/null @@ -1,529 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use std::path::PathBuf; - -use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; - -use super::completions::CompletionsCommand; -use super::configuration::ConfigCommand; -use super::diagnostics::{AgentsCommand, DoctorCommand}; -use super::hook_forward::HookForwardCommand; -use super::run::{EasyPathCommand, RunCommand}; -use super::serve::ServerArgs; -use crate::agents::CodingAgent; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -#[value(rename_all = "kebab-case")] -pub(crate) enum AgentArg { - #[value(name = "claude", alias = "claude-code")] - Claude, - Codex, - Hermes, -} - -impl From for CodingAgent { - fn from(value: AgentArg) -> Self { - match value { - AgentArg::Claude => Self::ClaudeCode, - AgentArg::Codex => Self::Codex, - AgentArg::Hermes => Self::Hermes, - } - } -} - -#[derive(Debug, Clone, Parser)] -#[command(name = "nemo-relay")] -#[command(about = "Coding-agent gateway for NeMo Relay observability")] -#[command(version)] -pub(crate) struct Cli { - #[command(flatten)] - pub(crate) server: ServerArgs, - #[command(subcommand)] - pub(crate) command: Option, -} - -#[derive(Debug, Clone, Subcommand)] -pub(crate) enum Command { - /// Run Claude Code with observability (setup on first use) - #[command( - long_about = "Run Anthropic's `claude` CLI under an ephemeral NeMo Relay gateway. \ - Observability (ATIF + OpenInference) is wired in transparently via \ - ANTHROPIC_BASE_URL. First-time use launches the setup wizard so the \ - `[agents.claude]` block lands in `.nemo-relay/config.toml` and observation \ - starts on the next invocation without prompts.", - after_help = "Examples:\n \ - nemo-relay claude\n \ - nemo-relay claude -- chat \"refactor the launcher\"\n \ - nemo-relay claude -- --resume " - )] - Claude(EasyPathCommand), - /// Run Codex with observability (setup on first use) - #[command( - long_about = "Run OpenAI's `codex` CLI under an ephemeral NeMo Relay gateway. NeMo Relay \ - injects a `nemo-relay-openai` provider override so codex points at the \ - gateway; the gateway then forwards to `--openai-base-url` (defaults to \ - api.openai.com) with `OPENAI_API_KEY` injected on the codex route (see \ - NMF-86 — codex's own auth.json JWT is stripped). The supported host version \ - is validated before launch.", - after_help = "Examples:\n \ - nemo-relay codex\n \ - nemo-relay codex -- exec \"fix the bug in foo.rs\"\n \ - nemo-relay --openai-base-url https://inference-api.nvidia.com codex" - )] - Codex(EasyPathCommand), - /// Run Hermes with observability (setup on first use) - #[command( - long_about = "Run Hermes Agent under an ephemeral NeMo Relay gateway. The wrapper uses a \ - process-private HERMES_HOME overlay for dynamic hooks, without rewriting \ - the user's Hermes configuration. Use `nemo-relay install hermes` when bare \ - Hermes processes should load the shared native Relay gateway on \ - 127.0.0.1:47632 through MCP.", - after_help = "Examples:\n \ - nemo-relay hermes\n \ - nemo-relay hermes -- chat --provider custom" - )] - Hermes(EasyPathCommand), - /// Keep a shared Relay gateway ready for an MCP client. - #[command( - long_about = "Start or reuse a shared native NeMo Relay gateway for an MCP stdio \ - connection. The command acquires the gateway immediately, before reading \ - MCP protocol frames. The gateway binds 127.0.0.1:47632 by default and MCP \ - initialization completes only after Relay identity and readiness are \ - verified. Multiple MCP clients share the gateway; it remains available \ - until its idle timeout after the final client closes. This command \ - advertises no MCP tools.", - after_help = "Examples:\n \ - nemo-relay mcp\n \ - nemo-relay --bind 127.0.0.1:4041 mcp # explicit standalone/test bind" - )] - Mcp, - /// Run the interactive setup (writes `.nemo-relay/config.toml`) - Config(ConfigCommand), - /// Create or edit plugin configuration (writes `plugins.toml`) - Plugins(PluginsCommand), - /// Install coding-agent plugins from the local nemo-relay CLI. - Install(InstallCommand), - /// Uninstall coding-agent plugins installed by `nemo-relay install`. - Uninstall(UninstallCommand), - /// Validate and configure model pricing catalogs. - ModelPricing(PricingCommand), - /// Diagnose env, agents, config, observability (optionally scoped to one agent) - Doctor(DoctorCommand), - /// List supported and locally-detected agents (use `--json` for machine output) - Agents(AgentsCommand), - /// Print shell completion script (e.g. `nemo-relay completions zsh > ~/.zfunc/_nemo-relay`) - Completions(CompletionsCommand), - /// Run an agent deterministically (no wizard; errors if config is missing) - Run(RunCommand), - /// Internal: subprocess used by installed hooks to forward events. Not typed by humans. - #[command(hide = true)] - HookForward(HookForwardCommand), -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct InstallCommand { - #[arg(value_enum)] - pub(crate) host: IntegrationHost, - #[arg(long)] - pub(crate) install_dir: Option, - #[arg(long)] - pub(crate) force: bool, - #[arg(long)] - pub(crate) dry_run: bool, - #[arg(long)] - pub(crate) skip_doctor: bool, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct UninstallCommand { - #[arg(value_enum)] - pub(crate) host: IntegrationHost, - #[arg(long)] - pub(crate) install_dir: Option, - #[arg(long)] - pub(crate) dry_run: bool, -} - -/// Args for `nemo-relay plugins`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsCommand { - #[command(subcommand)] - pub(crate) command: PluginsSubcommand, -} - -#[derive(Debug, Clone, Copy)] -pub(crate) struct PluginJsonContext<'a> { - pub(crate) command: &'static str, - pub(crate) target: Option<&'a str>, -} - -/// Plugin configuration subcommands. -#[derive(Debug, Clone, Subcommand)] -pub(crate) enum PluginsSubcommand { - /// Interactively create or edit built-in and dynamic plugin configuration. - Edit(PluginsEditCommand), - /// Register a manifest-backed dynamic plugin in `plugins.toml`. - Add(PluginsAddCommand), - /// Validate a manifest-backed dynamic plugin by path or installed ID. - Validate(PluginsValidateCommand), - /// List discovered dynamic plugins from the resolved host config. - List(PluginsListCommand), - /// Inspect one discovered dynamic plugin by canonical ID. - Inspect(PluginsInspectCommand), - /// Mark a registered dynamic plugin enabled in desired state. - Enable(PluginsEnableCommand), - /// Mark a registered dynamic plugin disabled in desired state. - Disable(PluginsDisableCommand), - /// Tombstone a registered dynamic plugin and remove its host discovery reference. - Remove(PluginsRemoveCommand), -} - -impl PluginsSubcommand { - pub(crate) fn json_context(&self) -> Option> { - match self { - Self::Validate(command) if command.json => Some(PluginJsonContext { - command: "plugins validate", - target: Some(command.target.as_str()), - }), - Self::List(command) if command.json => Some(PluginJsonContext { - command: "plugins list", - target: None, - }), - Self::Inspect(command) if command.json => Some(PluginJsonContext { - command: "plugins inspect", - target: Some(command.id.as_str()), - }), - _ => None, - } - } -} - -/// Args for `nemo-relay model-pricing`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PricingCommand { - #[command(subcommand)] - pub(crate) command: PricingSubcommand, -} - -/// Model pricing catalog and resolver subcommands. -#[derive(Debug, Clone, Subcommand)] -pub(crate) enum PricingSubcommand { - /// Validate a model pricing catalog JSON file. - Validate(PricingValidateCommand), - /// Initialize model pricing in `plugins.toml`. - Init(PricingInitCommand), - /// Add a model pricing catalog file source to `plugins.toml`. - AddSource(PricingAddSourceCommand), - /// Resolve which model pricing entry matches a model and optional usage. - Resolve(PricingResolveCommand), -} - -/// Common target-scope flags for model pricing config mutations. -#[derive(Debug, Clone, Default, Args)] -#[command(group( - ArgGroup::new("pricing_scope") - .args(["user", "project", "global"]) - .multiple(false) -))] -pub(crate) struct PricingScopeArgs { - /// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) user: bool, - /// Edit the nearest project config at `.nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) project: bool, - /// Edit the system config at `/etc/nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) global: bool, -} - -/// Args for `nemo-relay model-pricing validate`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PricingValidateCommand { - /// Path to a Relay model pricing catalog JSON file. - pub(crate) path: PathBuf, -} - -/// Args for `nemo-relay model-pricing init`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PricingInitCommand { - #[command(flatten)] - pub(crate) scope: PricingScopeArgs, -} - -/// Args for `nemo-relay model-pricing add-source`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PricingAddSourceCommand { - #[command(flatten)] - pub(crate) scope: PricingScopeArgs, - /// Path to a Relay model pricing catalog JSON file. - pub(crate) path: PathBuf, - /// Append as a lower-priority source instead of prepending as the highest-priority override. - #[arg(long)] - pub(crate) append: bool, -} - -/// Args for `nemo-relay model-pricing resolve`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PricingResolveCommand { - /// Model ID or routed model name to look up. - pub(crate) model: String, - /// Optional provider or route, such as `openai`, `anthropic`, or `azure/openai`. - #[arg(long)] - pub(crate) provider: Option, - /// Prompt/input token count to use for an estimate. - #[arg(long)] - pub(crate) prompt_tokens: Option, - /// Completion/output token count to use for an estimate. - #[arg(long)] - pub(crate) completion_tokens: Option, - /// Prompt-cache read token count to use for an estimate. - #[arg(long)] - pub(crate) cache_read_tokens: Option, - /// Prompt-cache write token count to use for an estimate. - #[arg(long)] - pub(crate) cache_write_tokens: Option, -} - -/// Args for `nemo-relay plugins edit`. -#[derive(Debug, Clone, Default, Args)] -#[command(group( - ArgGroup::new("scope") - .args(["user", "project", "global"]) - .multiple(false) -))] -pub(crate) struct PluginsScopeArgs { - /// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) user: bool, - /// Edit the nearest project config at `.nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) project: bool, - /// Edit the system config at `/etc/nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) global: bool, -} - -/// Args for `nemo-relay plugins edit`. -#[derive(Debug, Clone, Default, Args)] -pub(crate) struct PluginsEditCommand { - #[command(flatten)] - pub(crate) scope: PluginsScopeArgs, -} - -/// Args for `nemo-relay plugins add`. -#[derive(Debug, Clone, Default, Args)] -pub(crate) struct PluginsAddCommand { - #[command(flatten)] - pub(crate) scope: PluginsScopeArgs, - /// Path to a plugin directory or explicit `relay-plugin.toml`. - pub(crate) path: PathBuf, -} - -/// Args for `nemo-relay plugins validate`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsValidateCommand { - /// Canonical plugin ID or a local plugin directory / `relay-plugin.toml` path. - pub(crate) target: String, - /// Emit machine-readable JSON output. - #[arg(long)] - pub(crate) json: bool, -} - -/// Args for `nemo-relay plugins list`. -#[derive(Debug, Clone, Default, Args)] -pub(crate) struct PluginsListCommand { - /// Include tombstoned dynamic plugin records in the output. - #[arg(long)] - pub(crate) all: bool, - /// Emit machine-readable JSON output. - #[arg(long)] - pub(crate) json: bool, -} - -/// Args for `nemo-relay plugins inspect`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsInspectCommand { - /// Canonical plugin ID. - pub(crate) id: String, - /// Emit machine-readable JSON output. - #[arg(long)] - pub(crate) json: bool, -} - -/// Args for `nemo-relay plugins enable`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsEnableCommand { - /// Canonical plugin ID. - pub(crate) id: String, -} - -/// Args for `nemo-relay plugins disable`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsDisableCommand { - /// Canonical plugin ID. - pub(crate) id: String, -} - -/// Args for `nemo-relay plugins remove`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsRemoveCommand { - /// Canonical plugin ID. - pub(crate) id: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] -#[value(rename_all = "kebab-case")] -pub(crate) enum IntegrationHost { - Codex, - #[value(name = "claude-code", alias = "claude")] - ClaudeCode, - Hermes, - All, -} - -impl From for crate::configuration::IntegrationHost { - fn from(value: IntegrationHost) -> Self { - match value { - IntegrationHost::Codex => Self::Codex, - IntegrationHost::ClaudeCode => Self::ClaudeCode, - IntegrationHost::Hermes => Self::Hermes, - IntegrationHost::All => Self::All, - } - } -} - -impl InstallCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::InstallRequest { - crate::configuration::InstallRequest { - host: self.host.into(), - install_dir: self.install_dir, - force: self.force, - dry_run: self.dry_run, - skip_doctor: self.skip_doctor, - } - } -} - -impl UninstallCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::UninstallRequest { - crate::configuration::UninstallRequest { - host: self.host.into(), - install_dir: self.install_dir, - dry_run: self.dry_run, - } - } -} - -impl From for crate::plugins::ConfigurationScope { - fn from(value: PluginsScopeArgs) -> Self { - match (value.user, value.project, value.global) { - (false, false, false) => Self::Default, - (true, false, false) => Self::User, - (false, true, false) => Self::Project, - (false, false, true) => Self::Global, - _ => Self::Invalid, - } - } -} - -impl PluginsEditCommand { - pub(crate) fn into_runtime(self) -> crate::plugins::PluginsEditRequest { - crate::plugins::PluginsEditRequest { - scope: self.scope.into(), - } - } -} -impl PluginsAddCommand { - pub(crate) fn into_runtime(self) -> crate::plugins::PluginsAddRequest { - crate::plugins::PluginsAddRequest { - scope: self.scope.into(), - path: self.path, - } - } -} -impl PluginsValidateCommand { - pub(crate) fn into_runtime(self) -> crate::plugins::PluginsValidateRequest { - crate::plugins::PluginsValidateRequest { - target: self.target, - json: self.json, - } - } -} -impl PluginsListCommand { - pub(crate) fn into_runtime(self) -> crate::plugins::PluginsListRequest { - crate::plugins::PluginsListRequest { - all: self.all, - json: self.json, - } - } -} -impl PluginsInspectCommand { - pub(crate) fn into_runtime(self) -> crate::plugins::PluginsInspectRequest { - crate::plugins::PluginsInspectRequest { - id: self.id, - json: self.json, - } - } -} -impl PluginsEnableCommand { - pub(crate) fn into_runtime(self) -> crate::plugins::PluginsEnableRequest { - crate::plugins::PluginsEnableRequest { id: self.id } - } -} -impl PluginsDisableCommand { - pub(crate) fn into_runtime(self) -> crate::plugins::PluginsDisableRequest { - crate::plugins::PluginsDisableRequest { id: self.id } - } -} -impl PluginsRemoveCommand { - pub(crate) fn into_runtime(self) -> crate::plugins::PluginsRemoveRequest { - crate::plugins::PluginsRemoveRequest { id: self.id } - } -} - -impl From for crate::plugins::ConfigurationScope { - fn from(value: PricingScopeArgs) -> Self { - match (value.user, value.project, value.global) { - (false, false, false) => Self::Default, - (true, false, false) => Self::User, - (false, true, false) => Self::Project, - (false, false, true) => Self::Global, - _ => Self::Invalid, - } - } -} -impl PricingValidateCommand { - pub(crate) fn into_runtime(self) -> crate::plugins::PricingValidateRequest { - crate::plugins::PricingValidateRequest { path: self.path } - } -} -impl PricingInitCommand { - pub(crate) fn into_runtime(self) -> crate::plugins::PricingInitRequest { - crate::plugins::PricingInitRequest { - scope: self.scope.into(), - } - } -} -impl PricingAddSourceCommand { - pub(crate) fn into_runtime(self) -> crate::plugins::PricingAddSourceRequest { - crate::plugins::PricingAddSourceRequest { - scope: self.scope.into(), - path: self.path, - append: self.append, - } - } -} -impl PricingResolveCommand { - pub(crate) fn into_runtime(self) -> crate::plugins::PricingResolveRequest { - crate::plugins::PricingResolveRequest { - model: self.model, - provider: self.provider, - prompt_tokens: self.prompt_tokens, - completion_tokens: self.completion_tokens, - cache_read_tokens: self.cache_read_tokens, - cache_write_tokens: self.cache_write_tokens, - } - } -} diff --git a/crates/cli/src/commands/completions/install.rs b/crates/cli/src/commands/completions/install.rs index 4a53edf19..e83fa5e88 100644 --- a/crates/cli/src/commands/completions/install.rs +++ b/crates/cli/src/commands/completions/install.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use clap_complete::Shell; -use crate::commands::arguments::Cli; +use crate::commands::root::Cli; use crate::error::CliError; /// Generates the completion script for `$SHELL` and writes it to the matching shell-specific diff --git a/crates/cli/src/commands/completions/mod.rs b/crates/cli/src/commands/completions/mod.rs index c4ff6d08d..77df5e826 100644 --- a/crates/cli/src/commands/completions/mod.rs +++ b/crates/cli/src/commands/completions/mod.rs @@ -9,7 +9,7 @@ use clap::Args; use clap::CommandFactory; -use crate::commands::arguments::Cli; +use crate::commands::root::Cli; use crate::error::CliError; #[derive(Debug, Clone, Args)] diff --git a/crates/cli/src/commands/configuration.rs b/crates/cli/src/commands/configuration.rs index 03ac02d57..d42731674 100644 --- a/crates/cli/src/commands/configuration.rs +++ b/crates/cli/src/commands/configuration.rs @@ -5,7 +5,7 @@ use std::process::ExitCode; use clap::Args; -use super::arguments::AgentArg; +use super::root::AgentArg; use crate::error::CliError; #[derive(Debug, Clone, Args)] diff --git a/crates/cli/src/commands/diagnostics.rs b/crates/cli/src/commands/diagnostics.rs index 09438b9bf..0f5d5ef28 100644 --- a/crates/cli/src/commands/diagnostics.rs +++ b/crates/cli/src/commands/diagnostics.rs @@ -6,7 +6,8 @@ use std::process::ExitCode; use clap::Args; -use super::arguments::{AgentArg, IntegrationHost}; +use super::install::IntegrationHost; +use super::root::AgentArg; use crate::error::CliError; #[derive(Debug, Clone, Args)] diff --git a/crates/cli/src/commands/hook_forward.rs b/crates/cli/src/commands/hook_forward.rs index d6246a1ff..122590caf 100644 --- a/crates/cli/src/commands/hook_forward.rs +++ b/crates/cli/src/commands/hook_forward.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use clap::{Args, ValueEnum}; -use super::arguments::AgentArg; +use super::root::AgentArg; use crate::error::CliError; #[derive(Debug, Clone, Args)] diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index 4e9d9bfbb..f97e3589d 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -1,11 +1,80 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::path::PathBuf; use std::process::ExitCode; -use super::arguments::{InstallCommand, UninstallCommand}; +use clap::{Args, ValueEnum}; + use crate::error::CliError; +#[derive(Debug, Clone, Args)] +pub(crate) struct InstallCommand { + #[arg(value_enum)] + pub(crate) host: IntegrationHost, + #[arg(long)] + pub(crate) install_dir: Option, + #[arg(long)] + pub(crate) force: bool, + #[arg(long)] + pub(crate) dry_run: bool, + #[arg(long)] + pub(crate) skip_doctor: bool, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct UninstallCommand { + #[arg(value_enum)] + pub(crate) host: IntegrationHost, + #[arg(long)] + pub(crate) install_dir: Option, + #[arg(long)] + pub(crate) dry_run: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub(crate) enum IntegrationHost { + Codex, + #[value(name = "claude-code", alias = "claude")] + ClaudeCode, + Hermes, + All, +} + +impl From for crate::configuration::IntegrationHost { + fn from(value: IntegrationHost) -> Self { + match value { + IntegrationHost::Codex => Self::Codex, + IntegrationHost::ClaudeCode => Self::ClaudeCode, + IntegrationHost::Hermes => Self::Hermes, + IntegrationHost::All => Self::All, + } + } +} + +impl InstallCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::InstallRequest { + crate::configuration::InstallRequest { + host: self.host.into(), + install_dir: self.install_dir, + force: self.force, + dry_run: self.dry_run, + skip_doctor: self.skip_doctor, + } + } +} + +impl UninstallCommand { + pub(crate) fn into_runtime(self) -> crate::configuration::UninstallRequest { + crate::configuration::UninstallRequest { + host: self.host.into(), + install_dir: self.install_dir, + dry_run: self.dry_run, + } + } +} + pub(super) fn install(command: InstallCommand) -> Result { crate::agents::install::install(command.into_runtime()) } diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 46d90187e..12d1c365a 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -3,7 +3,6 @@ //! Command parsing, dispatch, rendering, and exit-code ownership. -pub(crate) mod arguments; mod completions; mod configuration; mod diagnostics; @@ -12,6 +11,7 @@ mod install; mod mcp; mod model_pricing; mod plugins; +pub(crate) mod root; mod run; mod serve; @@ -19,11 +19,13 @@ use std::process::ExitCode; use clap::Parser; -use self::arguments::{Cli, Command}; -#[cfg(test)] -use self::arguments::{PluginsCommand, PricingCommand}; #[cfg(test)] use self::completions::CompletionsCommand; +#[cfg(test)] +use self::model_pricing::PricingCommand; +#[cfg(test)] +use self::plugins::PluginsCommand; +use self::root::{Cli, Command}; use self::serve::ServerArgs; use crate::agents::CodingAgent; use crate::{ diff --git a/crates/cli/src/commands/model_pricing.rs b/crates/cli/src/commands/model_pricing.rs index 8d99db5a8..6ee8b5af0 100644 --- a/crates/cli/src/commands/model_pricing.rs +++ b/crates/cli/src/commands/model_pricing.rs @@ -1,11 +1,144 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::path::PathBuf; use std::process::ExitCode; -use super::arguments::{PricingCommand, PricingSubcommand}; +use clap::{ArgGroup, Args, Subcommand}; + use crate::error::CliError; +/// Args for `nemo-relay model-pricing`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PricingCommand { + #[command(subcommand)] + pub(crate) command: PricingSubcommand, +} + +/// Model pricing catalog and resolver subcommands. +#[derive(Debug, Clone, Subcommand)] +pub(crate) enum PricingSubcommand { + /// Validate a model pricing catalog JSON file. + Validate(PricingValidateCommand), + /// Initialize model pricing in `plugins.toml`. + Init(PricingInitCommand), + /// Add a model pricing catalog file source to `plugins.toml`. + AddSource(PricingAddSourceCommand), + /// Resolve which model pricing entry matches a model and optional usage. + Resolve(PricingResolveCommand), +} + +/// Common target-scope flags for model pricing config mutations. +#[derive(Debug, Clone, Default, Args)] +#[command(group( + ArgGroup::new("pricing_scope") + .args(["user", "project", "global"]) + .multiple(false) +))] +pub(crate) struct PricingScopeArgs { + /// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) user: bool, + /// Edit the nearest project config at `.nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) project: bool, + /// Edit the system config at `/etc/nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) global: bool, +} + +/// Args for `nemo-relay model-pricing validate`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PricingValidateCommand { + /// Path to a Relay model pricing catalog JSON file. + pub(crate) path: PathBuf, +} + +/// Args for `nemo-relay model-pricing init`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PricingInitCommand { + #[command(flatten)] + pub(crate) scope: PricingScopeArgs, +} + +/// Args for `nemo-relay model-pricing add-source`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PricingAddSourceCommand { + #[command(flatten)] + pub(crate) scope: PricingScopeArgs, + /// Path to a Relay model pricing catalog JSON file. + pub(crate) path: PathBuf, + /// Append as a lower-priority source instead of prepending as the highest-priority override. + #[arg(long)] + pub(crate) append: bool, +} + +/// Args for `nemo-relay model-pricing resolve`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PricingResolveCommand { + /// Model ID or routed model name to look up. + pub(crate) model: String, + /// Optional provider or route, such as `openai`, `anthropic`, or `azure/openai`. + #[arg(long)] + pub(crate) provider: Option, + /// Prompt/input token count to use for an estimate. + #[arg(long)] + pub(crate) prompt_tokens: Option, + /// Completion/output token count to use for an estimate. + #[arg(long)] + pub(crate) completion_tokens: Option, + /// Prompt-cache read token count to use for an estimate. + #[arg(long)] + pub(crate) cache_read_tokens: Option, + /// Prompt-cache write token count to use for an estimate. + #[arg(long)] + pub(crate) cache_write_tokens: Option, +} +impl From for crate::plugins::ConfigurationScope { + fn from(value: PricingScopeArgs) -> Self { + match (value.user, value.project, value.global) { + (false, false, false) => Self::Default, + (true, false, false) => Self::User, + (false, true, false) => Self::Project, + (false, false, true) => Self::Global, + _ => Self::Invalid, + } + } +} +impl PricingValidateCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PricingValidateRequest { + crate::plugins::PricingValidateRequest { path: self.path } + } +} +impl PricingInitCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PricingInitRequest { + crate::plugins::PricingInitRequest { + scope: self.scope.into(), + } + } +} +impl PricingAddSourceCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PricingAddSourceRequest { + crate::plugins::PricingAddSourceRequest { + scope: self.scope.into(), + path: self.path, + append: self.append, + } + } +} +impl PricingResolveCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PricingResolveRequest { + crate::plugins::PricingResolveRequest { + model: self.model, + provider: self.provider, + prompt_tokens: self.prompt_tokens, + completion_tokens: self.completion_tokens, + cache_read_tokens: self.cache_read_tokens, + cache_write_tokens: self.cache_write_tokens, + } + } +} + pub(super) fn execute(command: PricingCommand) -> Result { match command.command { PricingSubcommand::Validate(command) => { diff --git a/crates/cli/src/commands/plugins.rs b/crates/cli/src/commands/plugins.rs index cd462390e..9aeb4701e 100644 --- a/crates/cli/src/commands/plugins.rs +++ b/crates/cli/src/commands/plugins.rs @@ -1,12 +1,222 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::path::PathBuf; use std::process::ExitCode; -use super::arguments::{PluginsCommand, PluginsSubcommand}; +use clap::{ArgGroup, Args, Subcommand}; + use super::serve::ServerArgs; use crate::error::CliError; +/// Args for `nemo-relay plugins`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsCommand { + #[command(subcommand)] + pub(crate) command: PluginsSubcommand, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct PluginJsonContext<'a> { + pub(crate) command: &'static str, + pub(crate) target: Option<&'a str>, +} + +/// Plugin configuration subcommands. +#[derive(Debug, Clone, Subcommand)] +pub(crate) enum PluginsSubcommand { + /// Interactively create or edit built-in and dynamic plugin configuration. + Edit(PluginsEditCommand), + /// Register a manifest-backed dynamic plugin in `plugins.toml`. + Add(PluginsAddCommand), + /// Validate a manifest-backed dynamic plugin by path or installed ID. + Validate(PluginsValidateCommand), + /// List discovered dynamic plugins from the resolved host config. + List(PluginsListCommand), + /// Inspect one discovered dynamic plugin by canonical ID. + Inspect(PluginsInspectCommand), + /// Mark a registered dynamic plugin enabled in desired state. + Enable(PluginsEnableCommand), + /// Mark a registered dynamic plugin disabled in desired state. + Disable(PluginsDisableCommand), + /// Tombstone a registered dynamic plugin and remove its host discovery reference. + Remove(PluginsRemoveCommand), +} + +impl PluginsSubcommand { + pub(crate) fn json_context(&self) -> Option> { + match self { + Self::Validate(command) if command.json => Some(PluginJsonContext { + command: "plugins validate", + target: Some(command.target.as_str()), + }), + Self::List(command) if command.json => Some(PluginJsonContext { + command: "plugins list", + target: None, + }), + Self::Inspect(command) if command.json => Some(PluginJsonContext { + command: "plugins inspect", + target: Some(command.id.as_str()), + }), + _ => None, + } + } +} + +/// Args for `nemo-relay plugins edit`. +#[derive(Debug, Clone, Default, Args)] +#[command(group( + ArgGroup::new("scope") + .args(["user", "project", "global"]) + .multiple(false) +))] +pub(crate) struct PluginsScopeArgs { + /// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) user: bool, + /// Edit the nearest project config at `.nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) project: bool, + /// Edit the system config at `/etc/nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) global: bool, +} + +/// Args for `nemo-relay plugins edit`. +#[derive(Debug, Clone, Default, Args)] +pub(crate) struct PluginsEditCommand { + #[command(flatten)] + pub(crate) scope: PluginsScopeArgs, +} + +/// Args for `nemo-relay plugins add`. +#[derive(Debug, Clone, Default, Args)] +pub(crate) struct PluginsAddCommand { + #[command(flatten)] + pub(crate) scope: PluginsScopeArgs, + /// Path to a plugin directory or explicit `relay-plugin.toml`. + pub(crate) path: PathBuf, +} + +/// Args for `nemo-relay plugins validate`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsValidateCommand { + /// Canonical plugin ID or a local plugin directory / `relay-plugin.toml` path. + pub(crate) target: String, + /// Emit machine-readable JSON output. + #[arg(long)] + pub(crate) json: bool, +} + +/// Args for `nemo-relay plugins list`. +#[derive(Debug, Clone, Default, Args)] +pub(crate) struct PluginsListCommand { + /// Include tombstoned dynamic plugin records in the output. + #[arg(long)] + pub(crate) all: bool, + /// Emit machine-readable JSON output. + #[arg(long)] + pub(crate) json: bool, +} + +/// Args for `nemo-relay plugins inspect`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsInspectCommand { + /// Canonical plugin ID. + pub(crate) id: String, + /// Emit machine-readable JSON output. + #[arg(long)] + pub(crate) json: bool, +} + +/// Args for `nemo-relay plugins enable`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsEnableCommand { + /// Canonical plugin ID. + pub(crate) id: String, +} + +/// Args for `nemo-relay plugins disable`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsDisableCommand { + /// Canonical plugin ID. + pub(crate) id: String, +} + +/// Args for `nemo-relay plugins remove`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsRemoveCommand { + /// Canonical plugin ID. + pub(crate) id: String, +} + +impl From for crate::plugins::ConfigurationScope { + fn from(value: PluginsScopeArgs) -> Self { + match (value.user, value.project, value.global) { + (false, false, false) => Self::Default, + (true, false, false) => Self::User, + (false, true, false) => Self::Project, + (false, false, true) => Self::Global, + _ => Self::Invalid, + } + } +} + +impl PluginsEditCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsEditRequest { + crate::plugins::PluginsEditRequest { + scope: self.scope.into(), + } + } +} +impl PluginsAddCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsAddRequest { + crate::plugins::PluginsAddRequest { + scope: self.scope.into(), + path: self.path, + } + } +} +impl PluginsValidateCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsValidateRequest { + crate::plugins::PluginsValidateRequest { + target: self.target, + json: self.json, + } + } +} +impl PluginsListCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsListRequest { + crate::plugins::PluginsListRequest { + all: self.all, + json: self.json, + } + } +} +impl PluginsInspectCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsInspectRequest { + crate::plugins::PluginsInspectRequest { + id: self.id, + json: self.json, + } + } +} +impl PluginsEnableCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsEnableRequest { + crate::plugins::PluginsEnableRequest { id: self.id } + } +} +impl PluginsDisableCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsDisableRequest { + crate::plugins::PluginsDisableRequest { id: self.id } + } +} +impl PluginsRemoveCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsRemoveRequest { + crate::plugins::PluginsRemoveRequest { id: self.id } + } +} + pub(super) fn execute(command: PluginsCommand, server: &ServerArgs) -> Result { let server = server.to_runtime(); let json_context = command diff --git a/crates/cli/src/commands/root.rs b/crates/cli/src/commands/root.rs new file mode 100644 index 000000000..ddafae7fa --- /dev/null +++ b/crates/cli/src/commands/root.rs @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use clap::{Parser, Subcommand, ValueEnum}; + +use super::completions::CompletionsCommand; +use super::configuration::ConfigCommand; +use super::diagnostics::{AgentsCommand, DoctorCommand}; +use super::hook_forward::HookForwardCommand; +use super::install::{InstallCommand, UninstallCommand}; +use super::model_pricing::PricingCommand; +use super::plugins::PluginsCommand; +use super::run::{EasyPathCommand, RunCommand}; +use super::serve::ServerArgs; +use crate::agents::CodingAgent; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub(crate) enum AgentArg { + #[value(name = "claude", alias = "claude-code")] + Claude, + Codex, + Hermes, +} + +impl From for CodingAgent { + fn from(value: AgentArg) -> Self { + match value { + AgentArg::Claude => Self::ClaudeCode, + AgentArg::Codex => Self::Codex, + AgentArg::Hermes => Self::Hermes, + } + } +} + +#[derive(Debug, Clone, Parser)] +#[command(name = "nemo-relay")] +#[command(about = "Coding-agent gateway for NeMo Relay observability")] +#[command(version)] +pub(crate) struct Cli { + #[command(flatten)] + pub(crate) server: ServerArgs, + #[command(subcommand)] + pub(crate) command: Option, +} + +#[derive(Debug, Clone, Subcommand)] +pub(crate) enum Command { + /// Run Claude Code with observability (setup on first use) + #[command( + long_about = "Run Anthropic's `claude` CLI under an ephemeral NeMo Relay gateway. \ + Observability (ATIF + OpenInference) is wired in transparently via \ + ANTHROPIC_BASE_URL. First-time use launches the setup wizard so the \ + `[agents.claude]` block lands in `.nemo-relay/config.toml` and observation \ + starts on the next invocation without prompts.", + after_help = "Examples:\n \ + nemo-relay claude\n \ + nemo-relay claude -- chat \"refactor the launcher\"\n \ + nemo-relay claude -- --resume " + )] + Claude(EasyPathCommand), + /// Run Codex with observability (setup on first use) + #[command( + long_about = "Run OpenAI's `codex` CLI under an ephemeral NeMo Relay gateway. NeMo Relay \ + injects a `nemo-relay-openai` provider override so codex points at the \ + gateway; the gateway then forwards to `--openai-base-url` (defaults to \ + api.openai.com) with `OPENAI_API_KEY` injected on the codex route (see \ + NMF-86 — codex's own auth.json JWT is stripped). The supported host version \ + is validated before launch.", + after_help = "Examples:\n \ + nemo-relay codex\n \ + nemo-relay codex -- exec \"fix the bug in foo.rs\"\n \ + nemo-relay --openai-base-url https://inference-api.nvidia.com codex" + )] + Codex(EasyPathCommand), + /// Run Hermes with observability (setup on first use) + #[command( + long_about = "Run Hermes Agent under an ephemeral NeMo Relay gateway. The wrapper uses a \ + process-private HERMES_HOME overlay for dynamic hooks, without rewriting \ + the user's Hermes configuration. Use `nemo-relay install hermes` when bare \ + Hermes processes should load the shared native Relay gateway on \ + 127.0.0.1:47632 through MCP.", + after_help = "Examples:\n \ + nemo-relay hermes\n \ + nemo-relay hermes -- chat --provider custom" + )] + Hermes(EasyPathCommand), + /// Keep a shared Relay gateway ready for an MCP client. + #[command( + long_about = "Start or reuse a shared native NeMo Relay gateway for an MCP stdio \ + connection. The command acquires the gateway immediately, before reading \ + MCP protocol frames. The gateway binds 127.0.0.1:47632 by default and MCP \ + initialization completes only after Relay identity and readiness are \ + verified. Multiple MCP clients share the gateway; it remains available \ + until its idle timeout after the final client closes. This command \ + advertises no MCP tools.", + after_help = "Examples:\n \ + nemo-relay mcp\n \ + nemo-relay --bind 127.0.0.1:4041 mcp # explicit standalone/test bind" + )] + Mcp, + /// Run the interactive setup (writes `.nemo-relay/config.toml`) + Config(ConfigCommand), + /// Create or edit plugin configuration (writes `plugins.toml`) + Plugins(PluginsCommand), + /// Install coding-agent plugins from the local nemo-relay CLI. + Install(InstallCommand), + /// Uninstall coding-agent plugins installed by `nemo-relay install`. + Uninstall(UninstallCommand), + /// Validate and configure model pricing catalogs. + ModelPricing(PricingCommand), + /// Diagnose env, agents, config, observability (optionally scoped to one agent) + Doctor(DoctorCommand), + /// List supported and locally-detected agents (use `--json` for machine output) + Agents(AgentsCommand), + /// Print shell completion script (e.g. `nemo-relay completions zsh > ~/.zfunc/_nemo-relay`) + Completions(CompletionsCommand), + /// Run an agent deterministically (no wizard; errors if config is missing) + Run(RunCommand), + /// Internal: subprocess used by installed hooks to forward events. Not typed by humans. + #[command(hide = true)] + HookForward(HookForwardCommand), +} diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index 100c8170e..59e2134e7 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -6,7 +6,7 @@ use std::process::ExitCode; use clap::Args; -use super::arguments::AgentArg; +use super::root::AgentArg; use super::serve::ServerArgs; use crate::agents::CodingAgent; use crate::error::CliError; diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index 3cb3c0aa3..cb0e3246b 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -7,9 +7,10 @@ use std::ffi::OsString; use super::completions::CompletionsCommand; use super::serve::ServerArgs; use super::*; -use crate::commands::arguments::{ +use crate::commands::model_pricing::{PricingSubcommand, PricingValidateCommand}; +use crate::commands::plugins::{ PluginsCommand, PluginsEditCommand, PluginsInspectCommand, PluginsListCommand, - PluginsSubcommand, PluginsValidateCommand, PricingSubcommand, PricingValidateCommand, + PluginsSubcommand, PluginsValidateCommand, }; struct EnvScope { From 705753bc57adea2a4efcb23a9fbb37ca98682211 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 22:24:42 -0400 Subject: [PATCH 040/102] refactor(cli): move installation inputs out of configuration Signed-off-by: Will Killian --- crates/cli/src/agents/install/host.rs | 2 +- crates/cli/src/agents/install/marketplace.rs | 2 +- crates/cli/src/agents/install/mod.rs | 4 +- .../cli/src/agents/install/operation_lock.rs | 2 +- crates/cli/src/agents/install/setup.rs | 3 +- crates/cli/src/agents/install/state.rs | 2 +- crates/cli/src/commands/install.rs | 10 ++-- crates/cli/src/configuration/mod.rs | 56 ----------------- crates/cli/src/installation/mod.rs | 60 +++++++++++++++++++ .../coverage/agents/plugin_install_tests.rs | 6 +- 10 files changed, 77 insertions(+), 70 deletions(-) diff --git a/crates/cli/src/agents/install/host.rs b/crates/cli/src/agents/install/host.rs index e415613d1..cb6e0c987 100644 --- a/crates/cli/src/agents/install/host.rs +++ b/crates/cli/src/agents/install/host.rs @@ -11,7 +11,7 @@ use serde_json::Value; #[cfg(test)] use serde_json::json; -use crate::configuration::IntegrationHost; +use crate::installation::IntegrationHost; use super::state::PluginInstallOptions; use super::{MARKETPLACE_NAME, PLUGIN_NAME, RELAY_COMMAND}; diff --git a/crates/cli/src/agents/install/marketplace.rs b/crates/cli/src/agents/install/marketplace.rs index c16880867..151e54ec1 100644 --- a/crates/cli/src/agents/install/marketplace.rs +++ b/crates/cli/src/agents/install/marketplace.rs @@ -9,8 +9,8 @@ use std::path::Path; use serde_json::{Value, json}; -use crate::configuration::IntegrationHost; use crate::hooks::generated_hooks; +use crate::installation::IntegrationHost; use crate::installation::generation::{ write_new_generation_with_token_at, write_staged_generation_with_token, }; diff --git a/crates/cli/src/agents/install/mod.rs b/crates/cli/src/agents/install/mod.rs index 25adc9f75..79b17b02b 100644 --- a/crates/cli/src/agents/install/mod.rs +++ b/crates/cli/src/agents/install/mod.rs @@ -18,11 +18,13 @@ use std::time::{Duration, Instant}; use serde::Serialize; use serde_json::{Value, json}; -use crate::configuration::{CodingAgent, InstallRequest, IntegrationHost, UninstallRequest}; +use crate::configuration::CodingAgent; use crate::error::CliError; +use crate::installation::IntegrationHost; use crate::installation::generation::{ GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration, }; +use crate::installation::{InstallRequest, UninstallRequest}; use host::{ CommandRunner, RealCommandRunner, host_registration_report, require_host_cli, require_relay, diff --git a/crates/cli/src/agents/install/operation_lock.rs b/crates/cli/src/agents/install/operation_lock.rs index 1d654f05b..791092aed 100644 --- a/crates/cli/src/agents/install/operation_lock.rs +++ b/crates/cli/src/agents/install/operation_lock.rs @@ -8,8 +8,8 @@ use std::path::{Path, PathBuf}; use std::thread; use std::time::{Duration, Instant}; -use crate::configuration::IntegrationHost; use crate::filesystem::{LockAttempt, try_lock_exclusive}; +use crate::installation::IntegrationHost; pub(super) const DEFAULT_OPERATION_LOCK_TIMEOUT: Duration = Duration::from_secs(5); const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25); diff --git a/crates/cli/src/agents/install/setup.rs b/crates/cli/src/agents/install/setup.rs index 93a439ec2..f1ac6a545 100644 --- a/crates/cli/src/agents/install/setup.rs +++ b/crates/cli/src/agents/install/setup.rs @@ -4,7 +4,8 @@ //! Host setup, restore, and doctor delegation. use crate::agents as host; -use crate::configuration::{CodingAgent, IntegrationHost}; +use crate::configuration::CodingAgent; +use crate::installation::IntegrationHost; use serde_json::Value; use std::path::Path; diff --git a/crates/cli/src/agents/install/state.rs b/crates/cli/src/agents/install/state.rs index f57facac3..ca41dcdfc 100644 --- a/crates/cli/src/agents/install/state.rs +++ b/crates/cli/src/agents/install/state.rs @@ -10,7 +10,7 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; -use crate::configuration::IntegrationHost; +use crate::installation::IntegrationHost; use crate::installation::generation::GENERATION_FILE_NAME; use super::PLUGIN_NAME; diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index f97e3589d..5af38c1b0 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -42,7 +42,7 @@ pub(crate) enum IntegrationHost { All, } -impl From for crate::configuration::IntegrationHost { +impl From for crate::installation::IntegrationHost { fn from(value: IntegrationHost) -> Self { match value { IntegrationHost::Codex => Self::Codex, @@ -54,8 +54,8 @@ impl From for crate::configuration::IntegrationHost { } impl InstallCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::InstallRequest { - crate::configuration::InstallRequest { + pub(crate) fn into_runtime(self) -> crate::installation::InstallRequest { + crate::installation::InstallRequest { host: self.host.into(), install_dir: self.install_dir, force: self.force, @@ -66,8 +66,8 @@ impl InstallCommand { } impl UninstallCommand { - pub(crate) fn into_runtime(self) -> crate::configuration::UninstallRequest { - crate::configuration::UninstallRequest { + pub(crate) fn into_runtime(self) -> crate::installation::UninstallRequest { + crate::installation::UninstallRequest { host: self.host.into(), install_dir: self.install_dir, dry_run: self.dry_run, diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs index cbb840404..d87394277 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -48,62 +48,6 @@ pub(crate) const DEFAULT_MAX_PASSTHROUGH_BODY_BYTES: usize = 100 * 1024 * 1024; pub(crate) const GATEWAY_URL_ENV: &str = "NEMO_RELAY_GATEWAY_URL"; pub(crate) const TRANSPARENT_RUN_ENV: &str = "NEMO_RELAY_TRANSPARENT_RUN"; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) enum IntegrationHost { - Codex, - ClaudeCode, - Hermes, - All, -} - -impl IntegrationHost { - pub(crate) const fn agent(self) -> Option { - match self { - Self::Codex => Some(CodingAgent::Codex), - Self::ClaudeCode => Some(CodingAgent::ClaudeCode), - Self::Hermes => Some(CodingAgent::Hermes), - Self::All => None, - } - } - - pub(crate) const fn as_arg(self) -> &'static str { - match self.agent() { - Some(agent) => agent.install_arg(), - None => "all", - } - } - - pub(crate) const fn label(self) -> &'static str { - match self.agent() { - Some(agent) => agent.label(), - None => "all", - } - } - - pub(crate) const fn executable(self) -> Option<&'static str> { - match self.agent() { - Some(agent) => Some(agent.executable()), - None => None, - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct InstallRequest { - pub(crate) host: IntegrationHost, - pub(crate) install_dir: Option, - pub(crate) force: bool, - pub(crate) dry_run: bool, - pub(crate) skip_doctor: bool, -} - -#[derive(Debug, Clone)] -pub(crate) struct UninstallRequest { - pub(crate) host: IntegrationHost, - pub(crate) install_dir: Option, - pub(crate) dry_run: bool, -} - #[derive(Debug, Clone)] pub(crate) struct GatewayConfig { pub(crate) bind: SocketAddr, diff --git a/crates/cli/src/installation/mod.rs b/crates/cli/src/installation/mod.rs index 3077bbbcc..b9b62b437 100644 --- a/crates/cli/src/installation/mod.rs +++ b/crates/cli/src/installation/mod.rs @@ -3,4 +3,64 @@ //! Transactional installation primitives. +use std::path::PathBuf; + +use crate::agents::CodingAgent; + pub(crate) mod generation; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum IntegrationHost { + Codex, + ClaudeCode, + Hermes, + All, +} + +impl IntegrationHost { + pub(crate) const fn agent(self) -> Option { + match self { + Self::Codex => Some(CodingAgent::Codex), + Self::ClaudeCode => Some(CodingAgent::ClaudeCode), + Self::Hermes => Some(CodingAgent::Hermes), + Self::All => None, + } + } + + pub(crate) const fn as_arg(self) -> &'static str { + match self.agent() { + Some(agent) => agent.install_arg(), + None => "all", + } + } + + pub(crate) const fn label(self) -> &'static str { + match self.agent() { + Some(agent) => agent.label(), + None => "all", + } + } + + pub(crate) const fn executable(self) -> Option<&'static str> { + match self.agent() { + Some(agent) => Some(agent.executable()), + None => None, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct InstallRequest { + pub(crate) host: IntegrationHost, + pub(crate) install_dir: Option, + pub(crate) force: bool, + pub(crate) dry_run: bool, + pub(crate) skip_doctor: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct UninstallRequest { + pub(crate) host: IntegrationHost, + pub(crate) install_dir: Option, + pub(crate) dry_run: bool, +} diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index cd0d46556..cd4749641 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -1986,7 +1986,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { std::fs::create_dir_all(&empty_path).unwrap(); let _path = PathScope::set_isolated(&empty_path, &dir.path().join("home")); - let install_error = install(crate::configuration::InstallRequest { + let install_error = install(crate::installation::InstallRequest { host: IntegrationHost::All, install_dir: Some(dir.path().join("install")), force: false, @@ -2000,7 +2000,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { "error was: {install_error}" ); - let uninstall_error = uninstall(crate::configuration::UninstallRequest { + let uninstall_error = uninstall(crate::installation::UninstallRequest { host: IntegrationHost::All, install_dir: Some(dir.path().join("install")), dry_run: false, @@ -2032,7 +2032,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { ); assert_eq!( - install(crate::configuration::InstallRequest { + install(crate::installation::InstallRequest { host: IntegrationHost::Codex, install_dir: Some(dir.path().join("dry-run-install")), force: false, From 1f9cf38075c8f3dfb552a6798cb9c0c0b9e29cd2 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 22:30:35 -0400 Subject: [PATCH 041/102] refactor(cli): make all a command-only install target Signed-off-by: Will Killian --- crates/cli/src/agents/install/host.rs | 58 +- crates/cli/src/agents/install/marketplace.rs | 50 +- crates/cli/src/agents/install/mod.rs | 312 ++++--- .../cli/src/agents/install/operation_lock.rs | 16 +- crates/cli/src/agents/install/setup.rs | 94 +- crates/cli/src/agents/install/state.rs | 38 +- crates/cli/src/commands/diagnostics.rs | 12 +- crates/cli/src/commands/install.rs | 67 +- crates/cli/src/installation/mod.rs | 44 - .../agents/plugin_install_setup_tests.rs | 64 +- .../coverage/agents/plugin_install_tests.rs | 859 ++++++++---------- 11 files changed, 729 insertions(+), 885 deletions(-) diff --git a/crates/cli/src/agents/install/host.rs b/crates/cli/src/agents/install/host.rs index cb6e0c987..8c34182d1 100644 --- a/crates/cli/src/agents/install/host.rs +++ b/crates/cli/src/agents/install/host.rs @@ -11,19 +11,19 @@ use serde_json::Value; #[cfg(test)] use serde_json::json; -use crate::installation::IntegrationHost; +use crate::agents::CodingAgent; use super::state::PluginInstallOptions; use super::{MARKETPLACE_NAME, PLUGIN_NAME, RELAY_COMMAND}; pub(super) fn run_host_marketplace_registration( - host: IntegrationHost, + host: CodingAgent, marketplace_root: &Path, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { run_command( - host.executable().expect("concrete plugin host"), + host.executable(), &[ "plugin".into(), "marketplace".into(), @@ -36,13 +36,13 @@ pub(super) fn run_host_marketplace_registration( } pub(super) fn run_host_plugin_registration( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { match host { - IntegrationHost::Codex => run_command( - host.executable().expect("concrete plugin host"), + CodingAgent::Codex => run_command( + host.executable(), &[ "plugin".into(), "add".into(), @@ -51,8 +51,8 @@ pub(super) fn run_host_plugin_registration( options, runner, ), - IntegrationHost::ClaudeCode => run_command( - host.executable().expect("concrete plugin host"), + CodingAgent::ClaudeCode => run_command( + host.executable(), &[ "plugin".into(), "install".into(), @@ -63,20 +63,20 @@ pub(super) fn run_host_plugin_registration( options, runner, ), - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Hermes => { unreachable!("all is expanded before host registration") } } } pub(super) fn run_host_plugin_removal( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { match host { - IntegrationHost::Codex => run_command( - host.executable().expect("concrete plugin host"), + CodingAgent::Codex => run_command( + host.executable(), &[ "plugin".into(), "remove".into(), @@ -85,13 +85,13 @@ pub(super) fn run_host_plugin_removal( options, runner, )?, - IntegrationHost::ClaudeCode => run_command( - host.executable().expect("concrete plugin host"), + CodingAgent::ClaudeCode => run_command( + host.executable(), &["plugin".into(), "uninstall".into(), PLUGIN_NAME.into()], options, runner, )?, - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Hermes => { unreachable!("all is expanded before host unregistration") } } @@ -99,12 +99,12 @@ pub(super) fn run_host_plugin_removal( } pub(super) fn run_host_marketplace_removal( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { run_command( - host.executable().expect("concrete plugin host"), + host.executable(), &[ "plugin".into(), "marketplace".into(), @@ -139,7 +139,7 @@ impl HostRegistrationReport { #[cfg(test)] pub(super) fn validate_host_registration( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result { @@ -156,14 +156,14 @@ pub(super) fn validate_host_registration( } Err(format!( "{} plugin host registration is incomplete: missing {}", - host.executable().expect("concrete plugin host"), + host.executable(), missing.join(", ") )) } } pub(super) fn host_registration_report( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result { @@ -175,15 +175,15 @@ pub(super) fn host_registration_report( } require_host_cli(host, options, runner)?; Ok(match host { - IntegrationHost::ClaudeCode => HostRegistrationReport { + CodingAgent::ClaudeCode => HostRegistrationReport { host_plugin_registered: claude_plugin_registered(options, runner)?, host_marketplace_registered: claude_marketplace_registered(options, runner)?, }, - IntegrationHost::Codex => HostRegistrationReport { + CodingAgent::Codex => HostRegistrationReport { host_plugin_registered: codex_plugin_registered(options, runner)?, host_marketplace_registered: codex_marketplace_registered(options, runner)?, }, - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Hermes => { unreachable!("all is expanded before host registration checks") } }) @@ -342,16 +342,14 @@ pub(super) fn validate_relay_mcp( } pub(super) fn require_host_cli( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { if options.dry_run { return Ok(()); } - let cli = host - .executable() - .expect("all is expanded before CLI validation"); + let cli = host.executable(); runner .resolve_executable(cli)? .map(|_| ()) @@ -359,16 +357,14 @@ pub(super) fn require_host_cli( } pub(super) fn validate_host_version( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { if options.dry_run { return Ok(()); } - let agent = host - .agent() - .expect("all is expanded before host version validation"); + let agent = host; let output = run_capture_command(agent.executable(), &["--version".into()], options, runner)?; agent.validate_version_output(&output.stdout).map(|_| ()) } diff --git a/crates/cli/src/agents/install/marketplace.rs b/crates/cli/src/agents/install/marketplace.rs index 151e54ec1..964bfba0d 100644 --- a/crates/cli/src/agents/install/marketplace.rs +++ b/crates/cli/src/agents/install/marketplace.rs @@ -9,8 +9,8 @@ use std::path::Path; use serde_json::{Value, json}; +use crate::agents::CodingAgent; use crate::hooks::generated_hooks; -use crate::installation::IntegrationHost; use crate::installation::generation::{ write_new_generation_with_token_at, write_staged_generation_with_token, }; @@ -19,7 +19,7 @@ use super::state::{PluginInstallOptions, PluginLayout, remove_path, write_json}; use super::{MARKETPLACE_NAME, PLUGIN_NAME}; pub(super) fn write_plugin_marketplace( - host: IntegrationHost, + host: CodingAgent, layout: &PluginLayout, relay: &Path, options: &PluginInstallOptions, @@ -36,7 +36,7 @@ pub(super) fn write_plugin_marketplace( } pub(super) fn write_plugin_marketplace_for_generation( - host: IntegrationHost, + host: CodingAgent, layout: &PluginLayout, relay: &Path, active_generation_fence: &Path, @@ -80,9 +80,9 @@ pub(super) fn write_plugin_marketplace_for_generation( Ok(()) } -pub(super) fn marketplace_manifest(host: IntegrationHost) -> Value { +pub(super) fn marketplace_manifest(host: CodingAgent) -> Value { match host { - IntegrationHost::Codex => json!({ + CodingAgent::Codex => json!({ "name": MARKETPLACE_NAME, "interface": { "displayName": "NeMo Relay Local" @@ -100,7 +100,7 @@ pub(super) fn marketplace_manifest(host: IntegrationHost) -> Value { "category": "Coding" }] }), - IntegrationHost::ClaudeCode => json!({ + CodingAgent::ClaudeCode => json!({ "name": MARKETPLACE_NAME, "metadata": { "description": "Local NeMo Relay plugins for Claude Code." @@ -116,30 +116,30 @@ pub(super) fn marketplace_manifest(host: IntegrationHost) -> Value { "category": "development" }] }), - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Hermes => { unreachable!("all is expanded before manifest generation") } } } -pub(super) fn plugin_manifest(host: IntegrationHost) -> Value { +pub(super) fn plugin_manifest(host: CodingAgent) -> Value { let description = match host { - IntegrationHost::Codex => { + CodingAgent::Codex => { "Native Relay gateway lifecycle and Codex hooks for complete local observability." } - IntegrationHost::ClaudeCode => { + CodingAgent::ClaudeCode => { "Native Relay gateway lifecycle and Claude Code hooks for complete local observability." } - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Hermes => { unreachable!("all is expanded before manifest generation") } }; let keywords = match host { - IntegrationHost::Codex => json!(["nemo-relay", "codex", "hooks", "observability"]), - IntegrationHost::ClaudeCode => { + CodingAgent::Codex => json!(["nemo-relay", "codex", "hooks", "observability"]), + CodingAgent::ClaudeCode => { json!(["nemo-relay", "claude-code", "hooks", "observability"]) } - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Hermes => { unreachable!("all is expanded before manifest generation") } }; @@ -157,7 +157,7 @@ pub(super) fn plugin_manifest(host: IntegrationHost) -> Value { "keywords": keywords }); manifest["mcpServers"] = json!("./.mcp.json"); - if matches!(host, IntegrationHost::Codex) { + if matches!(host, CodingAgent::Codex) { manifest["interface"] = json!({ "displayName": "NeMo Relay Plugin", "shortDescription": "Run the native Relay gateway and capture Codex lifecycle events.", @@ -174,7 +174,7 @@ pub(super) fn plugin_manifest(host: IntegrationHost) -> Value { } pub(super) fn plugin_mcp_config( - host: IntegrationHost, + host: CodingAgent, relay: &Path, generation_fence: &Path, generation_token: &str, @@ -185,24 +185,24 @@ pub(super) fn plugin_mcp_config( .as_object_mut() .expect("persistent MCP server is a JSON object"); match host { - IntegrationHost::Codex => { + CodingAgent::Codex => { fields.insert("env_vars".into(), json!(plugin_mcp_env_vars()?)); fields.insert("required".into(), json!(true)); fields.insert("startup_timeout_sec".into(), json!(20)); } - IntegrationHost::ClaudeCode => { + CodingAgent::ClaudeCode => { fields.insert("alwaysLoad".into(), json!(true)); } - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Hermes => { unreachable!("all is expanded before MCP generation") } } Ok(match host { - IntegrationHost::Codex => json!({ (crate::mcp::SERVER_NAME): server }), - IntegrationHost::ClaudeCode => { + CodingAgent::Codex => json!({ (crate::mcp::SERVER_NAME): server }), + CodingAgent::ClaudeCode => { json!({ "mcpServers": { (crate::mcp::SERVER_NAME): server } }) } - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Hermes => { unreachable!("all is expanded before MCP generation") } }) @@ -218,14 +218,12 @@ fn absolute_or_self(path: &Path) -> std::path::PathBuf { } pub(super) fn plugin_hooks( - host: IntegrationHost, + host: CodingAgent, relay: &Path, generation_fence: &Path, generation_token: &str, ) -> Result { - let agent = host - .agent() - .expect("all is expanded before hook generation"); + let agent = host; let generation_fence = absolute_or_self(generation_fence); Ok(generated_hooks( agent, diff --git a/crates/cli/src/agents/install/mod.rs b/crates/cli/src/agents/install/mod.rs index 79b17b02b..c6fd247e2 100644 --- a/crates/cli/src/agents/install/mod.rs +++ b/crates/cli/src/agents/install/mod.rs @@ -18,9 +18,8 @@ use std::time::{Duration, Instant}; use serde::Serialize; use serde_json::{Value, json}; -use crate::configuration::CodingAgent; +use crate::agents::CodingAgent; use crate::error::CliError; -use crate::installation::IntegrationHost; use crate::installation::generation::{ GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration, }; @@ -119,7 +118,7 @@ impl HostPluginReadiness { } struct PendingHostPluginReadiness { - host: IntegrationHost, + host: CodingAgent, state_path: PathBuf, receiver: Receiver, } @@ -131,13 +130,13 @@ struct PendingHostPluginReadiness { /// into a persistent coding-agent integration. pub(crate) fn collect_default_host_plugin_readiness() -> Vec { let install_dir = default_install_dir().canonicalize_or_self(); - let mut hosts = [IntegrationHost::Codex, IntegrationHost::ClaudeCode] + let mut hosts = [CodingAgent::Codex, CodingAgent::ClaudeCode] .into_iter() .filter(|host| state_path(*host, &install_dir).exists()) .collect::>(); if hermes_config_path().is_ok_and(|path| crate::agents::hermes::persistent_state_exists(&path)) { - hosts.push(IntegrationHost::Hermes); + hosts.push(CodingAgent::Hermes); } let pending = hosts .into_iter() @@ -156,12 +155,13 @@ pub(crate) fn collect_default_host_plugin_readiness() -> Vec PendingHostPluginReadiness { let state_path = match host { - IntegrationHost::Hermes => hermes_config_path() - .unwrap_or_else(|_| state_path(IntegrationHost::Hermes, &install_dir)), + CodingAgent::Hermes => { + hermes_config_path().unwrap_or_else(|_| state_path(CodingAgent::Hermes, &install_dir)) + } _ => state_path(host, &install_dir), }; let worker_state_path = state_path.clone(); @@ -177,7 +177,7 @@ fn spawn_default_host_plugin_readiness( let runner = RealCommandRunner; let setup_runner = RealPluginSetupRunner; let readiness = match host { - IntegrationHost::Hermes => { + CodingAgent::Hermes => { collect_hermes_host_readiness(&worker_state_path, &options, &runner) } _ => collect_host_plugin_readiness(host, &options, &runner, &setup_runner), @@ -211,12 +211,12 @@ fn receive_host_plugin_readiness( } fn failed_host_plugin_readiness( - host: IntegrationHost, + host: CodingAgent, state_path: PathBuf, details: impl Into, ) -> HostPluginReadiness { let (marketplace, plugin) = match host { - IntegrationHost::Hermes => (None, None), + CodingAgent::Hermes => (None, None), _ => { let layout = PluginLayout::new(host, state_path.parent().unwrap_or_else(|| Path::new("."))); @@ -224,8 +224,8 @@ fn failed_host_plugin_readiness( } }; let mut readiness = HostPluginReadiness { - host: host.as_arg().to_string(), - remediation: format!("nemo-relay install {} --force", host.as_arg()), + host: host.install_arg().to_string(), + remediation: format!("nemo-relay install {} --force", host.install_arg()), state_path, marketplace, plugin, @@ -239,7 +239,7 @@ fn failed_host_plugin_readiness( readiness } -pub(crate) fn install(command: InstallRequest) -> Result { +pub(crate) fn install(host: CodingAgent, command: InstallRequest) -> Result { let operation_lock_dir = if command.dry_run { PathBuf::new() } else { @@ -255,18 +255,20 @@ pub(crate) fn install(command: InstallRequest) -> Result { dry_run: command.dry_run, skip_doctor: command.skip_doctor, }; - run_for_hosts( - command.host, - HostSelectionMode::Install, + run_for_host( + host, &options, |host, options, runner, setup_runner| match host { - IntegrationHost::Hermes => install_hermes_host(options, runner), + CodingAgent::Hermes => install_hermes_host(options, runner), _ => install_host(host, options, runner, setup_runner), }, ) } -pub(crate) fn uninstall(command: UninstallRequest) -> Result { +pub(crate) fn uninstall( + host: CodingAgent, + command: UninstallRequest, +) -> Result { let operation_lock_dir = if command.dry_run { PathBuf::new() } else { @@ -282,19 +284,18 @@ pub(crate) fn uninstall(command: UninstallRequest) -> Result dry_run: command.dry_run, skip_doctor: true, }; - run_for_hosts( - command.host, - HostSelectionMode::InstalledState, + run_for_host( + host, &options, |host, options, runner, setup_runner| match host { - IntegrationHost::Hermes => uninstall_hermes_host(options), + CodingAgent::Hermes => uninstall_hermes_host(options), _ => uninstall_host(host, options, runner, setup_runner), }, ) } pub(crate) fn doctor( - host: IntegrationHost, + hosts: &[CodingAgent], install_dir: Option, json: bool, ) -> Result { @@ -307,29 +308,35 @@ pub(crate) fn doctor( dry_run: false, skip_doctor: true, }; + if hosts.is_empty() { + return Err(CliError::Install( + "no installed Claude Code, Codex, or Hermes integration state was found".into(), + )); + } if json { - return doctor_json(host, &options); + return doctor_json(hosts, &options); } - run_for_hosts( - host, - HostSelectionMode::InstalledState, - &options, - |host, options, runner, setup_runner| match host { - IntegrationHost::Hermes => doctor_hermes_host(options, runner), - _ => doctor_host(host, options, runner, setup_runner), - }, - ) + for &host in hosts { + run_for_host( + host, + &options, + |host, options, runner, setup_runner| match host { + CodingAgent::Hermes => doctor_hermes_host(options, runner), + _ => doctor_host(host, options, runner, setup_runner), + }, + )?; + } + Ok(ExitCode::SUCCESS) } -fn run_for_hosts( - host: IntegrationHost, - mode: HostSelectionMode, +fn run_for_host( + host: CodingAgent, options: &PluginInstallOptions, mut action: F, ) -> Result where F: FnMut( - IntegrationHost, + CodingAgent, &PluginInstallOptions, &dyn CommandRunner, &dyn PluginSetupRunner, @@ -337,45 +344,21 @@ where { let runner = RealCommandRunner; let setup_runner = RealPluginSetupRunner; - let hosts = select_hosts(host, mode, options, &runner)?; - if hosts.is_empty() { - return Err(CliError::Install(match host { - IntegrationHost::All => match mode { - HostSelectionMode::Install => { - "no supported Claude Code, Codex, or Hermes host CLI was detected".into() - } - HostSelectionMode::InstalledState => { - "no installed Claude Code, Codex, or Hermes integration state was found".into() - } - }, - _ => "no supported integration host selected".into(), - })); - } - for host in hosts { - action(host, options, &runner, &setup_runner).map_err(CliError::Install)?; - } + action(host, options, &runner, &setup_runner).map_err(CliError::Install)?; Ok(ExitCode::SUCCESS) } fn doctor_json( - host: IntegrationHost, + hosts: &[CodingAgent], options: &PluginInstallOptions, ) -> Result { let runner = RealCommandRunner; let setup_runner = RealPluginSetupRunner; - let hosts = select_hosts(host, HostSelectionMode::InstalledState, options, &runner)?; - if hosts.is_empty() { - return Err(CliError::Install(match host { - IntegrationHost::All => { - "no installed Claude Code, Codex, or Hermes integration state was found".into() - } - _ => "no supported integration host selected".into(), - })); - } let reports = hosts - .into_iter() + .iter() + .copied() .map(|host| match host { - IntegrationHost::Hermes => doctor_hermes_json_value(options, &runner), + CodingAgent::Hermes => doctor_hermes_json_value(options, &runner), _ => doctor_host_json_value(host, options, &runner, &setup_runner), }) .collect::, _>>() @@ -383,7 +366,7 @@ fn doctor_json( let ready = reports .iter() .all(|report| report.get("ok").and_then(Value::as_bool) == Some(true)); - if matches!(host, IntegrationHost::All) { + if hosts.len() > 1 { print_json(&json!({ "schema_version": 1, "plugins": reports @@ -401,30 +384,23 @@ fn doctor_json( }) } -fn select_hosts( - host: IntegrationHost, +fn select_available_agents( + candidates: &[CodingAgent], mode: HostSelectionMode, - options: &PluginInstallOptions, + install_dir: &Path, runner: &dyn CommandRunner, -) -> Result, CliError> { - if host != IntegrationHost::All { - return Ok(vec![host]); - } +) -> Result, CliError> { let mut hosts = Vec::new(); - for candidate in [ - IntegrationHost::Codex, - IntegrationHost::ClaudeCode, - IntegrationHost::Hermes, - ] { + for &candidate in candidates { let selected = match mode { HostSelectionMode::Install => runner - .resolve_executable(candidate.executable().expect("concrete plugin host")) + .resolve_executable(candidate.executable()) .map_err(CliError::Install)? .is_some(), HostSelectionMode::InstalledState => match candidate { - IntegrationHost::Hermes => hermes_config_path() + CodingAgent::Hermes => hermes_config_path() .is_ok_and(|path| crate::agents::hermes::persistent_state_exists(&path)), - _ => state_path(candidate, &options.install_dir).exists(), + _ => state_path(candidate, install_dir).exists(), }, }; if selected { @@ -434,6 +410,33 @@ fn select_hosts( Ok(hosts) } +pub(crate) fn detected_install_agents( + candidates: &[CodingAgent], +) -> Result, CliError> { + select_available_agents( + candidates, + HostSelectionMode::Install, + &default_install_dir().canonicalize_or_self(), + &RealCommandRunner, + ) +} + +pub(crate) fn installed_agents( + candidates: &[CodingAgent], + install_dir: Option<&Path>, +) -> Result, CliError> { + let install_dir = install_dir + .map(Path::to_path_buf) + .unwrap_or_else(default_install_dir) + .canonicalize_or_self(); + select_available_agents( + candidates, + HostSelectionMode::InstalledState, + &install_dir, + &RealCommandRunner, + ) +} + fn hermes_config_path() -> Result { std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) @@ -446,8 +449,8 @@ fn install_hermes_host( options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { - require_host_cli(IntegrationHost::Hermes, options, runner)?; - host::validate_host_version(IntegrationHost::Hermes, options, runner)?; + require_host_cli(CodingAgent::Hermes, options, runner)?; + host::validate_host_version(CodingAgent::Hermes, options, runner)?; let relay = require_relay(options, runner)?; validate_relay_hook_forward(&relay, options, runner)?; validate_relay_mcp(&relay, options, runner)?; @@ -528,10 +531,10 @@ fn collect_hermes_host_readiness( runner: &dyn CommandRunner, ) -> HostPluginReadiness { let mut readiness = HostPluginReadiness { - host: IntegrationHost::Hermes.as_arg().into(), + host: CodingAgent::Hermes.install_arg().into(), remediation: format!( "nemo-relay install {} --force", - IntegrationHost::Hermes.as_arg() + CodingAgent::Hermes.install_arg() ), state_path: config.to_path_buf(), marketplace: None, @@ -543,7 +546,7 @@ fn collect_hermes_host_readiness( plugin_setup: None, }; - let host_cli = require_host_cli(IntegrationHost::Hermes, options, runner); + let host_cli = require_host_cli(CodingAgent::Hermes, options, runner); readiness.push( "Host CLI", host_cli @@ -551,12 +554,12 @@ fn collect_hermes_host_readiness( .map(|_| "hermes is available".into()) .map_err(Clone::clone), ); - let version = host::validate_host_version(IntegrationHost::Hermes, options, runner); + let version = host::validate_host_version(CodingAgent::Hermes, options, runner); if version.is_err() { readiness.remediation = format!( "upgrade to {}, then run `nemo-relay install {} --force`", CodingAgent::Hermes.version_requirement(), - IntegrationHost::Hermes.as_arg() + CodingAgent::Hermes.install_arg() ); } readiness.push( @@ -601,7 +604,7 @@ fn collect_hermes_host_readiness( } fn install_host( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -616,7 +619,7 @@ fn install_host( } fn install_host_with_operation_timeout( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -636,7 +639,7 @@ fn install_host_with_operation_timeout( } fn install_host_locked( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -852,7 +855,7 @@ fn install_host_locked( return Err(error); } registration.host_plugin_added = true; - if !matches!(host, IntegrationHost::Codex) { + if !matches!(host, CodingAgent::Codex) { setup_installed = true; } run_plugin_setup_with_generation( @@ -952,7 +955,7 @@ fn install_host_locked( } fn uninstall_host( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -967,7 +970,7 @@ fn uninstall_host( } fn uninstall_host_with_operation_timeout( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -987,7 +990,7 @@ fn uninstall_host_with_operation_timeout( } fn uninstall_host_locked( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -1048,7 +1051,7 @@ fn uninstall_host_locked( } fn retire_installed_generation( - host: IntegrationHost, + host: CodingAgent, plugin_root: &Path, expected_generation_lock: &Path, local_install_exists: bool, @@ -1083,7 +1086,7 @@ fn retire_installed_generation( } fn retire_replacement_before_rollback( - host: IntegrationHost, + host: CodingAgent, layout: &PluginLayout, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, @@ -1135,15 +1138,15 @@ fn retire_replacement_before_rollback( Ok(Some(retirement)) } -fn existing_plugin_install_requires_force_error(host: IntegrationHost) -> String { +fn existing_plugin_install_requires_force_error(host: CodingAgent) -> String { format!( "an existing fenced {} plugin install was found; rerun `nemo-relay install {} --force` to replace it safely", host.label(), - host.as_arg() + host.install_arg() ) } -fn missing_generation_fence_error(host: IntegrationHost, generation_fence: &Path) -> String { +fn missing_generation_fence_error(host: CodingAgent, generation_fence: &Path) -> String { unsafe_generation_fence_error( host, &format!("is missing at {}", generation_fence.display()), @@ -1151,7 +1154,7 @@ fn missing_generation_fence_error(host: IntegrationHost, generation_fence: &Path } fn invalid_generation_fence_error( - host: IntegrationHost, + host: CodingAgent, generation_fence: &Path, cause: &str, ) -> String { @@ -1164,22 +1167,22 @@ fn invalid_generation_fence_error( ) } -fn unsafe_generation_fence_error(host: IntegrationHost, problem: &str) -> String { +fn unsafe_generation_fence_error(host: CodingAgent, problem: &str) -> String { match host { - IntegrationHost::Codex => format!( + CodingAgent::Codex => format!( "cannot safely replace or uninstall an existing Codex plugin because its MCP generation marker {problem}; close all Codex clients and standalone `nemo-relay mcp` processes, run `codex plugin remove nemo-relay-plugin@nemo-relay-local` and `codex plugin marketplace remove nemo-relay-local`, remove the stale marketplace and state from the selected install directory, then run `nemo-relay install codex --force` to create a fenced install (and `nemo-relay uninstall codex` afterward if removal was intended)" ), - IntegrationHost::ClaudeCode => format!( + CodingAgent::ClaudeCode => format!( "cannot safely replace or uninstall an existing Claude Code plugin because its MCP generation marker {problem}; close all Claude Code clients and standalone `nemo-relay mcp` processes, run `claude plugin uninstall nemo-relay-plugin` and `claude plugin marketplace remove nemo-relay-local`, remove the stale marketplace and state from the selected install directory, then run `nemo-relay install claude-code --force` to create a fenced install (and `nemo-relay uninstall claude-code` afterward if removal was intended)" ), - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Hermes => { unreachable!("all is expanded before generation validation") } } } -fn legacy_plugin_without_mcp(host: IntegrationHost, plugin_root: &Path) -> Result { - if !matches!(host, IntegrationHost::ClaudeCode) || plugin_root.join(".mcp.json").exists() { +fn legacy_plugin_without_mcp(host: CodingAgent, plugin_root: &Path) -> Result { + if !matches!(host, CodingAgent::ClaudeCode) || plugin_root.join(".mcp.json").exists() { return Ok(false); } let manifest_path = plugin_manifest_path(host, plugin_root); @@ -1203,7 +1206,7 @@ fn legacy_plugin_without_mcp(host: IntegrationHost, plugin_root: &Path) -> Resul } fn uninstall_host_with_setup_override( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -1241,7 +1244,7 @@ fn uninstall_host_with_setup_override( } fn doctor_host( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -1269,7 +1272,7 @@ fn doctor_host( } fn doctor_host_json_value( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -1296,7 +1299,7 @@ fn doctor_host_json_value( } fn collect_host_plugin_readiness( - host: IntegrationHost, + host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -1317,8 +1320,8 @@ fn collect_host_plugin_readiness( .map(|state| state.plugin_root.clone()) .or_else(|| state_path.exists().then(|| layout.plugin_root.clone())); let mut readiness = HostPluginReadiness { - host: host.as_arg().to_string(), - remediation: format!("nemo-relay install {} --force", host.as_arg()), + host: host.install_arg().to_string(), + remediation: format!("nemo-relay install {} --force", host.install_arg()), state_path: state_path.clone(), marketplace, plugin, @@ -1411,24 +1414,17 @@ fn collect_host_plugin_readiness( "Host CLI", host_cli_check .as_ref() - .map(|_| { - format!( - "{} is available", - host.executable().expect("concrete plugin host") - ) - }) + .map(|_| format!("{} is available", host.executable())) .map_err(Clone::clone), ); if host_cli_check.is_ok() { - let agent = host - .agent() - .expect("all is expanded before readiness checks"); + let agent = host; let version = host::validate_host_version(host, options, runner); if version.is_err() { readiness.remediation = format!( "upgrade to {}, then run `nemo-relay install {} --force`", agent.version_requirement(), - host.as_arg() + host.install_arg() ); } readiness.push( @@ -1524,7 +1520,7 @@ fn generated_manifest_check(path: &Path, expected: &Value, label: &str) -> Resul } fn generated_mcp_config_check( - host: IntegrationHost, + host: CodingAgent, path: &Path, expected: &Value, ) -> Result { @@ -1532,7 +1528,7 @@ fn generated_mcp_config_check( } fn generated_mcp_config_check_for_platform( - host: IntegrationHost, + host: CodingAgent, path: &Path, expected: &Value, windows: bool, @@ -1548,11 +1544,11 @@ fn generated_mcp_config_check_for_platform( if actual == *expected { return Ok(format!("valid at {}", path.display())); } - if !matches!(host, IntegrationHost::Codex) { + if !matches!(host, CodingAgent::Codex) { return Err(format!( "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", path.display(), - host.as_arg() + host.install_arg() )); } let expected_server = &expected["nemo-relay"]; @@ -1561,14 +1557,14 @@ fn generated_mcp_config_check_for_platform( return Err(format!( "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", path.display(), - host.as_arg() + host.install_arg() )); }; let Some(actual_vars) = mcp_env_var_names(actual_server) else { return Err(format!( "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", path.display(), - host.as_arg() + host.install_arg() )); }; let duplicate = actual_vars.iter().enumerate().any(|(index, name)| { @@ -1586,7 +1582,7 @@ fn generated_mcp_config_check_for_platform( return Err(format!( "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", path.display(), - host.as_arg() + host.install_arg() )); } let missing = expected_vars @@ -1605,7 +1601,7 @@ fn generated_mcp_config_check_for_platform( "MCP server at {} is missing forwarded environment variables: {}; run `nemo-relay install {} --force`", path.display(), missing.join(", "), - host.as_arg() + host.install_arg() )); } let mut expected_without_vars = expected.clone(); @@ -1626,7 +1622,7 @@ fn generated_mcp_config_check_for_platform( Err(format!( "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", path.display(), - host.as_arg() + host.install_arg() )) } @@ -1639,24 +1635,24 @@ fn mcp_env_var_names(server: &Value) -> Option> { .collect() } -fn marketplace_manifest_path(host: IntegrationHost, root: &Path) -> PathBuf { +fn marketplace_manifest_path(host: CodingAgent, root: &Path) -> PathBuf { match host { - IntegrationHost::Codex => root + CodingAgent::Codex => root .join(".agents") .join("plugins") .join("marketplace.json"), - IntegrationHost::ClaudeCode => root.join(".claude-plugin").join("marketplace.json"), - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::ClaudeCode => root.join(".claude-plugin").join("marketplace.json"), + CodingAgent::Hermes => { unreachable!("all is expanded before layout resolution") } } } -fn plugin_manifest_path(host: IntegrationHost, root: &Path) -> PathBuf { +fn plugin_manifest_path(host: CodingAgent, root: &Path) -> PathBuf { match host { - IntegrationHost::Codex => root.join(".codex-plugin").join("plugin.json"), - IntegrationHost::ClaudeCode => root.join(".claude-plugin").join("plugin.json"), - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Codex => root.join(".codex-plugin").join("plugin.json"), + CodingAgent::ClaudeCode => root.join(".claude-plugin").join("plugin.json"), + CodingAgent::Hermes => { unreachable!("all is expanded before layout resolution") } } @@ -1679,7 +1675,7 @@ struct ReplacementGenerationLock { } fn acquire_replacement_generation_lock( - host: IntegrationHost, + host: CodingAgent, marker_path: &Path, expected_generation_lock: &Path, remove_lock_if_unreferenced: bool, @@ -1783,7 +1779,7 @@ struct PluginInstallPreflight { } fn prepare_plugin_install( - host: IntegrationHost, + host: CodingAgent, layout: &PluginLayout, options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -1819,13 +1815,13 @@ fn prepare_plugin_install( .unwrap_or_else(|| layout.plugin_root.clone()); let previous_generation_fence = previous_plugin_root.join(GENERATION_FILE_NAME); let local_install_exists = match host { - IntegrationHost::Codex => layout.marketplace_root.exists(), - IntegrationHost::ClaudeCode => { + CodingAgent::Codex => layout.marketplace_root.exists(), + CodingAgent::ClaudeCode => { plugin_manifest_path(host, &previous_plugin_root).exists() || previous_plugin_root.join(".mcp.json").exists() || previous_generation_fence.exists() } - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Hermes => { unreachable!("all is expanded before install preflight") } }; @@ -1953,7 +1949,7 @@ fn generation_lock_is_absent(path: &Path) -> bool { } fn stage_plugin_marketplace( - host: IntegrationHost, + host: CodingAgent, relay: &Path, target: &PluginLayout, initialize_generation_lock: bool, @@ -1961,7 +1957,7 @@ fn stage_plugin_marketplace( ) -> Result { let parent = options.install_dir.join(format!( ".{}-install-stage-{}", - host.as_arg(), + host.install_arg(), uuid::Uuid::now_v7() )); stage_plugin_marketplace_at( @@ -1975,7 +1971,7 @@ fn stage_plugin_marketplace( } fn stage_plugin_marketplace_at( - host: IntegrationHost, + host: CodingAgent, relay: &Path, target: &PluginLayout, initialize_generation_lock: bool, @@ -2008,7 +2004,7 @@ fn stage_plugin_marketplace_at( } fn begin_force_replacement( - host: IntegrationHost, + host: CodingAgent, layout: &PluginLayout, preflight: PluginInstallPreflight, options: &PluginInstallOptions, @@ -2033,7 +2029,7 @@ fn begin_force_replacement( .unwrap_or(&options.install_dir); let backup_marketplace_root = backup_parent.join(format!( ".{}-marketplace-backup-{}", - host.as_arg(), + host.install_arg(), uuid::Uuid::now_v7() )); let backup_plugin_root = @@ -2043,7 +2039,7 @@ fn begin_force_replacement( .unwrap_or(&options.install_dir) .join(format!( ".{}-plugin-backup-{}", - host.as_arg(), + host.install_arg(), uuid::Uuid::now_v7() )) }); @@ -2150,7 +2146,7 @@ fn begin_force_replacement( } fn restore_force_replacement_after_error( - host: IntegrationHost, + host: CodingAgent, layout: &PluginLayout, snapshot: &mut ForceInstallSnapshot, options: &PluginInstallOptions, @@ -2167,7 +2163,7 @@ fn restore_force_replacement_after_error( } fn restore_force_replacement( - host: IntegrationHost, + host: CodingAgent, layout: &PluginLayout, snapshot: &mut ForceInstallSnapshot, options: &PluginInstallOptions, @@ -2294,7 +2290,7 @@ fn restore_force_replacement( } fn force_cleanup_existing_install( - host: IntegrationHost, + host: CodingAgent, layout: &PluginLayout, options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -2318,7 +2314,7 @@ fn force_cleanup_existing_install( } fn rollback_install( - host: IntegrationHost, + host: CodingAgent, layout: &PluginLayout, registration: HostRegistrationProgress, setup_installed: bool, @@ -2347,7 +2343,7 @@ fn rollback_install( } fn run_host_unregistration( - host: IntegrationHost, + host: CodingAgent, state: &mut PluginState, install_dir: &Path, options: &PluginInstallOptions, diff --git a/crates/cli/src/agents/install/operation_lock.rs b/crates/cli/src/agents/install/operation_lock.rs index 791092aed..ef30ee737 100644 --- a/crates/cli/src/agents/install/operation_lock.rs +++ b/crates/cli/src/agents/install/operation_lock.rs @@ -8,8 +8,8 @@ use std::path::{Path, PathBuf}; use std::thread; use std::time::{Duration, Instant}; +use crate::agents::CodingAgent; use crate::filesystem::{LockAttempt, try_lock_exclusive}; -use crate::installation::IntegrationHost; pub(super) const DEFAULT_OPERATION_LOCK_TIMEOUT: Duration = Duration::from_secs(5); const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25); @@ -21,7 +21,7 @@ pub(super) struct PluginOperationLock { impl PluginOperationLock { pub(super) fn acquire( - host: IntegrationHost, + host: CodingAgent, global_lock_dir: &Path, install_dir: &Path, timeout: Duration, @@ -37,7 +37,7 @@ impl PluginOperationLock { } fn acquire_lock_file( - host: IntegrationHost, + host: CodingAgent, directory: &Path, deadline: Instant, scope: &str, @@ -87,15 +87,15 @@ fn acquire_lock_file( } } -pub(super) fn operation_lock_path(host: IntegrationHost, install_dir: &Path) -> PathBuf { +pub(super) fn operation_lock_path(host: CodingAgent, install_dir: &Path) -> PathBuf { install_dir.join(format!(".nemo-relay-{}-operation.lock", host_name(host))) } -fn host_name(host: IntegrationHost) -> &'static str { +fn host_name(host: CodingAgent) -> &'static str { match host { - IntegrationHost::Codex => "codex", - IntegrationHost::ClaudeCode => "claude-code", - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Codex => "codex", + CodingAgent::ClaudeCode => "claude-code", + CodingAgent::Hermes => { unreachable!("all is expanded before operation locking") } } diff --git a/crates/cli/src/agents/install/setup.rs b/crates/cli/src/agents/install/setup.rs index f1ac6a545..eed21ffa2 100644 --- a/crates/cli/src/agents/install/setup.rs +++ b/crates/cli/src/agents/install/setup.rs @@ -4,8 +4,7 @@ //! Host setup, restore, and doctor delegation. use crate::agents as host; -use crate::configuration::CodingAgent; -use crate::installation::IntegrationHost; +use crate::agents::CodingAgent; use serde_json::Value; use std::path::Path; @@ -15,7 +14,7 @@ use super::state::PluginLayout; #[cfg(test)] pub(super) fn run_plugin_setup( - host: IntegrationHost, + host: CodingAgent, layout: &PluginLayout, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, @@ -24,7 +23,7 @@ pub(super) fn run_plugin_setup( } pub(super) fn run_plugin_setup_with_generation( - host: IntegrationHost, + host: CodingAgent, layout: &PluginLayout, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, @@ -43,7 +42,7 @@ pub(super) fn run_plugin_setup_with_generation( } pub(super) fn run_plugin_uninstall( - host: IntegrationHost, + host: CodingAgent, plugin_root: &Path, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, @@ -57,7 +56,7 @@ pub(super) fn run_plugin_uninstall( #[cfg(test)] pub(super) fn run_plugin_doctor( - host: IntegrationHost, + host: CodingAgent, plugin_root: &Path, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, @@ -66,7 +65,7 @@ pub(super) fn run_plugin_doctor( } pub(super) fn run_plugin_doctor_with_generation( - host: IntegrationHost, + host: CodingAgent, plugin_root: &Path, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, @@ -80,34 +79,33 @@ pub(super) fn run_plugin_doctor_with_generation( } pub(super) fn run_plugin_doctor_json( - host: IntegrationHost, + host: CodingAgent, plugin_root: &Path, setup_runner: &dyn PluginSetupRunner, ) -> Result { setup_runner.doctor_json(host, DEFAULT_GATEWAY_URL, plugin_root) } -pub(super) fn setup_action_description(host: IntegrationHost, action: &str) -> String { +pub(super) fn setup_action_description(host: CodingAgent, action: &str) -> String { match (host, action) { - (IntegrationHost::Codex, "configure") => { + (CodingAgent::Codex, "configure") => { "configure Codex provider and trust plugin-owned hooks".into() } - (IntegrationHost::Codex, "restore") => "remove Codex provider and plugin hook trust".into(), - (IntegrationHost::Codex, "doctor") => "check Codex provider and plugin-owned hooks".into(), - (IntegrationHost::ClaudeCode, "configure") => { + (CodingAgent::Codex, "restore") => "remove Codex provider and plugin hook trust".into(), + (CodingAgent::Codex, "doctor") => "check Codex provider and plugin-owned hooks".into(), + (CodingAgent::ClaudeCode, "configure") => { "enable Claude Code provider routing through NeMo Relay".into() } - (IntegrationHost::ClaudeCode, "restore") => { + (CodingAgent::ClaudeCode, "restore") => { "restore Claude Code provider routing from NeMo Relay backup".into() } - (IntegrationHost::ClaudeCode, "doctor") => "check Claude Code provider routing".into(), - (IntegrationHost::All, _) => unreachable!("all is expanded before plugin setup"), + (CodingAgent::ClaudeCode, "doctor") => "check Claude Code provider routing".into(), (_, _) => unreachable!("unsupported setup action"), } } pub(super) trait PluginSetupRunner { - fn snapshot(&self, _host: IntegrationHost) -> Result, String> { + fn snapshot(&self, _host: CodingAgent) -> Result, String> { Ok(None) } @@ -119,15 +117,11 @@ pub(super) trait PluginSetupRunner { Ok(()) } - fn setup( - &self, - host: IntegrationHost, - gateway_url: &str, - plugin_root: &Path, - ) -> Result<(), String>; + fn setup(&self, host: CodingAgent, gateway_url: &str, plugin_root: &Path) + -> Result<(), String>; fn setup_with_generation( &self, - host: IntegrationHost, + host: CodingAgent, gateway_url: &str, plugin_root: &Path, _generation_token: Option<&str>, @@ -136,19 +130,19 @@ pub(super) trait PluginSetupRunner { } fn uninstall( &self, - host: IntegrationHost, + host: CodingAgent, gateway_url: &str, plugin_root: &Path, ) -> Result<(), String>; fn doctor( &self, - host: IntegrationHost, + host: CodingAgent, gateway_url: &str, plugin_root: &Path, ) -> Result<(), String>; fn doctor_with_generation( &self, - host: IntegrationHost, + host: CodingAgent, gateway_url: &str, plugin_root: &Path, _generation_token: Option<&str>, @@ -157,7 +151,7 @@ pub(super) trait PluginSetupRunner { } fn doctor_json( &self, - host: IntegrationHost, + host: CodingAgent, gateway_url: &str, plugin_root: &Path, ) -> Result; @@ -173,15 +167,15 @@ pub(super) enum PluginSetupSnapshot { } impl PluginSetupRunner for RealPluginSetupRunner { - fn snapshot(&self, host: IntegrationHost) -> Result, String> { + fn snapshot(&self, host: CodingAgent) -> Result, String> { match host { - IntegrationHost::Codex => host::snapshot_codex_setup() + CodingAgent::Codex => host::snapshot_codex_setup() .map(PluginSetupSnapshot::Codex) .map(Some), - IntegrationHost::ClaudeCode => host::snapshot_claude_setup() + CodingAgent::ClaudeCode => host::snapshot_claude_setup() .map(PluginSetupSnapshot::Claude) .map(Some), - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Hermes => { unreachable!("all is expanded before plugin setup") } } @@ -202,7 +196,7 @@ impl PluginSetupRunner for RealPluginSetupRunner { fn setup( &self, - host: IntegrationHost, + host: CodingAgent, gateway_url: &str, plugin_root: &Path, ) -> Result<(), String> { @@ -211,19 +205,19 @@ impl PluginSetupRunner for RealPluginSetupRunner { fn setup_with_generation( &self, - host: IntegrationHost, + host: CodingAgent, gateway_url: &str, plugin_root: &Path, generation_token: Option<&str>, ) -> Result<(), String> { match host { - IntegrationHost::Codex => host::install_codex_plugin_with_generation( + CodingAgent::Codex => host::install_codex_plugin_with_generation( gateway_url, plugin_root, generation_token, ), - IntegrationHost::ClaudeCode => host::enable_claude_provider(gateway_url), - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::ClaudeCode => host::enable_claude_provider(gateway_url), + CodingAgent::Hermes => { unreachable!("all is expanded before plugin setup") } } @@ -231,14 +225,14 @@ impl PluginSetupRunner for RealPluginSetupRunner { fn uninstall( &self, - host: IntegrationHost, + host: CodingAgent, gateway_url: &str, plugin_root: &Path, ) -> Result<(), String> { match host { - IntegrationHost::Codex => host::uninstall_codex_plugin(gateway_url, plugin_root), - IntegrationHost::ClaudeCode => host::restore_claude_provider(gateway_url), - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Codex => host::uninstall_codex_plugin(gateway_url, plugin_root), + CodingAgent::ClaudeCode => host::restore_claude_provider(gateway_url), + CodingAgent::Hermes => { unreachable!("all is expanded before plugin uninstall") } } @@ -246,7 +240,7 @@ impl PluginSetupRunner for RealPluginSetupRunner { fn doctor( &self, - host: IntegrationHost, + host: CodingAgent, gateway_url: &str, plugin_root: &Path, ) -> Result<(), String> { @@ -255,22 +249,22 @@ impl PluginSetupRunner for RealPluginSetupRunner { fn doctor_with_generation( &self, - host: IntegrationHost, + host: CodingAgent, gateway_url: &str, plugin_root: &Path, generation_token: Option<&str>, ) -> Result<(), String> { match host { - IntegrationHost::Codex => host::doctor_plugin_with_generation( + CodingAgent::Codex => host::doctor_plugin_with_generation( CodingAgent::Codex, gateway_url, plugin_root, generation_token, ), - IntegrationHost::ClaudeCode => { + CodingAgent::ClaudeCode => { host::doctor_plugin(CodingAgent::ClaudeCode, gateway_url, plugin_root) } - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Hermes => { unreachable!("all is expanded before plugin doctor") } } @@ -278,18 +272,18 @@ impl PluginSetupRunner for RealPluginSetupRunner { fn doctor_json( &self, - host: IntegrationHost, + host: CodingAgent, gateway_url: &str, plugin_root: &Path, ) -> Result { match host { - IntegrationHost::Codex => { + CodingAgent::Codex => { host::doctor_plugin_json(CodingAgent::Codex, gateway_url, plugin_root) } - IntegrationHost::ClaudeCode => { + CodingAgent::ClaudeCode => { host::doctor_plugin_json(CodingAgent::ClaudeCode, gateway_url, plugin_root) } - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Hermes => { unreachable!("all is expanded before plugin doctor") } } diff --git a/crates/cli/src/agents/install/state.rs b/crates/cli/src/agents/install/state.rs index ca41dcdfc..845f47b6d 100644 --- a/crates/cli/src/agents/install/state.rs +++ b/crates/cli/src/agents/install/state.rs @@ -10,7 +10,7 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; -use crate::installation::IntegrationHost; +use crate::agents::CodingAgent; use crate::installation::generation::GENERATION_FILE_NAME; use super::PLUGIN_NAME; @@ -44,7 +44,7 @@ impl HostRegistrationProgress { #[derive(Debug, Clone)] pub(super) struct PluginLayout { - pub(super) host: IntegrationHost, + pub(super) host: CodingAgent, pub(super) marketplace_root: PathBuf, pub(super) marketplace_manifest: PathBuf, pub(super) plugin_root: PathBuf, @@ -57,32 +57,34 @@ pub(super) struct PluginLayout { } impl PluginLayout { - pub(super) fn new(host: IntegrationHost, install_dir: &Path) -> Self { - let marketplace_root = install_dir.join(format!("{}-marketplace", host.as_arg())); + pub(super) fn new(host: CodingAgent, install_dir: &Path) -> Self { + let marketplace_root = install_dir.join(format!("{}-marketplace", host.install_arg())); let marketplace_manifest = match host { - IntegrationHost::Codex => marketplace_root + CodingAgent::Codex => marketplace_root .join(".agents") .join("plugins") .join("marketplace.json"), - IntegrationHost::ClaudeCode => marketplace_root + CodingAgent::ClaudeCode => marketplace_root .join(".claude-plugin") .join("marketplace.json"), - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Hermes => { unreachable!("all is expanded before layout resolution") } }; let plugin_root = marketplace_root.join("plugins").join(PLUGIN_NAME); let plugin_manifest = match host { - IntegrationHost::Codex => plugin_root.join(".codex-plugin").join("plugin.json"), - IntegrationHost::ClaudeCode => plugin_root.join(".claude-plugin").join("plugin.json"), - IntegrationHost::Hermes | IntegrationHost::All => { + CodingAgent::Codex => plugin_root.join(".codex-plugin").join("plugin.json"), + CodingAgent::ClaudeCode => plugin_root.join(".claude-plugin").join("plugin.json"), + CodingAgent::Hermes => { unreachable!("all is expanded before layout resolution") } }; let mcp_config = plugin_root.join(".mcp.json"); let generation_fence = plugin_root.join(GENERATION_FILE_NAME); - let generation_lock = - install_dir.join(format!(".nemo-relay-{}-mcp-generation.lock", host.as_arg())); + let generation_lock = install_dir.join(format!( + ".nemo-relay-{}-mcp-generation.lock", + host.install_arg() + )); let hooks_path = plugin_root.join("hooks").join("hooks.json"); let state_path = state_path(host, install_dir); Self { @@ -219,7 +221,7 @@ pub(super) fn write_state( } pub(super) fn mark_plugin_setup_installed( - host: IntegrationHost, + host: CodingAgent, layout: &PluginLayout, options: &PluginInstallOptions, ) -> Result<(), String> { @@ -235,7 +237,7 @@ pub(super) fn mark_plugin_setup_installed( } pub(super) fn write_state_for_host( - host: IntegrationHost, + host: CodingAgent, state: &PluginState, install_dir: &Path, options: &PluginInstallOptions, @@ -248,7 +250,7 @@ pub(super) fn write_state_for_host( write_json( &path, &json!({ - "host": host.as_arg(), + "host": host.install_arg(), "marketplaceRoot": state.marketplace_root, "pluginRoot": state.plugin_root, "hostUnregistered": state.host_plugin_removed && state.host_marketplace_removed, @@ -259,7 +261,7 @@ pub(super) fn write_state_for_host( ) } -pub(super) fn read_state(host: IntegrationHost, install_dir: &Path) -> Option { +pub(super) fn read_state(host: CodingAgent, install_dir: &Path) -> Option { let raw = fs::read_to_string(state_path(host, install_dir)).ok()?; let value = serde_json::from_str::(&raw).ok()?; let legacy_host_unregistered = value @@ -284,8 +286,8 @@ pub(super) fn read_state(host: IntegrationHost, install_dir: &Path) -> Option PathBuf { - install_dir.join(format!("{}.json", host.as_arg())) +pub(super) fn state_path(host: CodingAgent, install_dir: &Path) -> PathBuf { + install_dir.join(format!("{}.json", host.install_arg())) } pub(super) fn write_json(path: &Path, value: &Value) -> Result<(), String> { diff --git a/crates/cli/src/commands/diagnostics.rs b/crates/cli/src/commands/diagnostics.rs index 0f5d5ef28..9a77584a4 100644 --- a/crates/cli/src/commands/diagnostics.rs +++ b/crates/cli/src/commands/diagnostics.rs @@ -6,7 +6,7 @@ use std::process::ExitCode; use clap::Args; -use super::install::IntegrationHost; +use super::install::InstallTarget; use super::root::AgentArg; use crate::error::CliError; @@ -15,7 +15,7 @@ pub(crate) struct DoctorCommand { #[arg(value_enum)] pub(crate) agent: Option, #[arg(long, value_enum)] - pub(crate) plugin: Option, + pub(crate) plugin: Option, #[arg(long)] pub(crate) install_dir: Option, #[arg(long)] @@ -30,7 +30,13 @@ pub(crate) struct AgentsCommand { pub(super) async fn execute(command: DoctorCommand) -> Result { if let Some(plugin) = command.plugin { - crate::agents::install::doctor(plugin.into(), command.install_dir, command.json) + let candidates = plugin.agents(); + let agents = if plugin.is_all() { + crate::agents::install::installed_agents(&candidates, command.install_dir.as_deref())? + } else { + candidates + }; + crate::agents::install::doctor(&agents, command.install_dir, command.json) } else { crate::diagnostics::run_doctor(command.agent.map(Into::into), command.json).await } diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index 5af38c1b0..ca24d871f 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -6,12 +6,13 @@ use std::process::ExitCode; use clap::{Args, ValueEnum}; +use crate::agents::CodingAgent; use crate::error::CliError; #[derive(Debug, Clone, Args)] pub(crate) struct InstallCommand { #[arg(value_enum)] - pub(crate) host: IntegrationHost, + pub(crate) host: InstallTarget, #[arg(long)] pub(crate) install_dir: Option, #[arg(long)] @@ -25,7 +26,7 @@ pub(crate) struct InstallCommand { #[derive(Debug, Clone, Args)] pub(crate) struct UninstallCommand { #[arg(value_enum)] - pub(crate) host: IntegrationHost, + pub(crate) host: InstallTarget, #[arg(long)] pub(crate) install_dir: Option, #[arg(long)] @@ -34,7 +35,7 @@ pub(crate) struct UninstallCommand { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] #[value(rename_all = "kebab-case")] -pub(crate) enum IntegrationHost { +pub(crate) enum InstallTarget { Codex, #[value(name = "claude-code", alias = "claude")] ClaudeCode, @@ -42,21 +43,28 @@ pub(crate) enum IntegrationHost { All, } -impl From for crate::installation::IntegrationHost { - fn from(value: IntegrationHost) -> Self { - match value { - IntegrationHost::Codex => Self::Codex, - IntegrationHost::ClaudeCode => Self::ClaudeCode, - IntegrationHost::Hermes => Self::Hermes, - IntegrationHost::All => Self::All, +impl InstallTarget { + pub(crate) fn agents(self) -> Vec { + match self { + Self::Codex => vec![CodingAgent::Codex], + Self::ClaudeCode => vec![CodingAgent::ClaudeCode], + Self::Hermes => vec![CodingAgent::Hermes], + Self::All => vec![ + CodingAgent::Codex, + CodingAgent::ClaudeCode, + CodingAgent::Hermes, + ], } } + + pub(crate) const fn is_all(self) -> bool { + matches!(self, Self::All) + } } impl InstallCommand { pub(crate) fn into_runtime(self) -> crate::installation::InstallRequest { crate::installation::InstallRequest { - host: self.host.into(), install_dir: self.install_dir, force: self.force, dry_run: self.dry_run, @@ -68,7 +76,6 @@ impl InstallCommand { impl UninstallCommand { pub(crate) fn into_runtime(self) -> crate::installation::UninstallRequest { crate::installation::UninstallRequest { - host: self.host.into(), install_dir: self.install_dir, dry_run: self.dry_run, } @@ -76,9 +83,41 @@ impl UninstallCommand { } pub(super) fn install(command: InstallCommand) -> Result { - crate::agents::install::install(command.into_runtime()) + let target = command.host; + let request = command.into_runtime(); + let candidates = target.agents(); + let agents = if target.is_all() { + crate::agents::install::detected_install_agents(&candidates)? + } else { + candidates + }; + if agents.is_empty() { + return Err(CliError::Install( + "no supported Claude Code, Codex, or Hermes host CLI was detected".into(), + )); + } + for agent in agents { + crate::agents::install::install(agent, request.clone())?; + } + Ok(ExitCode::SUCCESS) } pub(super) fn uninstall(command: UninstallCommand) -> Result { - crate::agents::install::uninstall(command.into_runtime()) + let target = command.host; + let request = command.into_runtime(); + let candidates = target.agents(); + let agents = if target.is_all() { + crate::agents::install::installed_agents(&candidates, request.install_dir.as_deref())? + } else { + candidates + }; + if agents.is_empty() { + return Err(CliError::Install( + "no installed Claude Code, Codex, or Hermes integration state was found".into(), + )); + } + for agent in agents { + crate::agents::install::uninstall(agent, request.clone())?; + } + Ok(ExitCode::SUCCESS) } diff --git a/crates/cli/src/installation/mod.rs b/crates/cli/src/installation/mod.rs index b9b62b437..8da04c8a6 100644 --- a/crates/cli/src/installation/mod.rs +++ b/crates/cli/src/installation/mod.rs @@ -5,53 +5,10 @@ use std::path::PathBuf; -use crate::agents::CodingAgent; - pub(crate) mod generation; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) enum IntegrationHost { - Codex, - ClaudeCode, - Hermes, - All, -} - -impl IntegrationHost { - pub(crate) const fn agent(self) -> Option { - match self { - Self::Codex => Some(CodingAgent::Codex), - Self::ClaudeCode => Some(CodingAgent::ClaudeCode), - Self::Hermes => Some(CodingAgent::Hermes), - Self::All => None, - } - } - - pub(crate) const fn as_arg(self) -> &'static str { - match self.agent() { - Some(agent) => agent.install_arg(), - None => "all", - } - } - - pub(crate) const fn label(self) -> &'static str { - match self.agent() { - Some(agent) => agent.label(), - None => "all", - } - } - - pub(crate) const fn executable(self) -> Option<&'static str> { - match self.agent() { - Some(agent) => Some(agent.executable()), - None => None, - } - } -} - #[derive(Debug, Clone)] pub(crate) struct InstallRequest { - pub(crate) host: IntegrationHost, pub(crate) install_dir: Option, pub(crate) force: bool, pub(crate) dry_run: bool, @@ -60,7 +17,6 @@ pub(crate) struct InstallRequest { #[derive(Debug, Clone)] pub(crate) struct UninstallRequest { - pub(crate) host: IntegrationHost, pub(crate) install_dir: Option, pub(crate) dry_run: bool, } diff --git a/crates/cli/tests/coverage/agents/plugin_install_setup_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_setup_tests.rs index d63ce31c9..d098688c4 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_setup_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_setup_tests.rs @@ -16,7 +16,7 @@ struct GenerationAwareRunner { impl PluginSetupRunner for DefaultsOnlyRunner { fn setup( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -25,7 +25,7 @@ impl PluginSetupRunner for DefaultsOnlyRunner { fn uninstall( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -34,7 +34,7 @@ impl PluginSetupRunner for DefaultsOnlyRunner { fn doctor( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -43,7 +43,7 @@ impl PluginSetupRunner for DefaultsOnlyRunner { fn doctor_json( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result { @@ -54,7 +54,7 @@ impl PluginSetupRunner for DefaultsOnlyRunner { impl PluginSetupRunner for GenerationAwareRunner { fn setup( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -63,7 +63,7 @@ impl PluginSetupRunner for GenerationAwareRunner { fn setup_with_generation( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, generation_token: Option<&str>, @@ -76,7 +76,7 @@ impl PluginSetupRunner for GenerationAwareRunner { fn uninstall( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -85,7 +85,7 @@ impl PluginSetupRunner for GenerationAwareRunner { fn doctor( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -94,7 +94,7 @@ impl PluginSetupRunner for GenerationAwareRunner { fn doctor_with_generation( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, generation_token: Option<&str>, @@ -107,7 +107,7 @@ impl PluginSetupRunner for GenerationAwareRunner { fn doctor_json( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result { @@ -119,7 +119,7 @@ impl PluginSetupRunner for GenerationAwareRunner { fn setup_runner_defaults_are_explicit_no_ops() { let runner = DefaultsOnlyRunner; - assert!(runner.snapshot(IntegrationHost::Codex).unwrap().is_none()); + assert!(runner.snapshot(CodingAgent::Codex).unwrap().is_none()); runner.restore_snapshot(&PluginSetupSnapshot::Mock).unwrap(); runner.refresh_gateway().unwrap(); } @@ -127,7 +127,7 @@ fn setup_runner_defaults_are_explicit_no_ops() { #[test] fn setup_and_doctor_receive_the_installer_verified_generation() { let dir = tempfile::tempdir().unwrap(); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let options = PluginInstallOptions { install_dir: dir.path().to_owned(), operation_lock_dir: dir.path().join("locks"), @@ -138,7 +138,7 @@ fn setup_and_doctor_receive_the_installer_verified_generation() { let runner = GenerationAwareRunner::default(); run_plugin_setup_with_generation( - IntegrationHost::Codex, + CodingAgent::Codex, &layout, &options, &runner, @@ -146,7 +146,7 @@ fn setup_and_doctor_receive_the_installer_verified_generation() { ) .unwrap(); run_plugin_doctor_with_generation( - IntegrationHost::Codex, + CodingAgent::Codex, &layout.plugin_root, &options, &runner, @@ -161,41 +161,9 @@ fn setup_and_doctor_receive_the_installer_verified_generation() { } #[test] -fn setup_descriptions_reject_unexpanded_hosts_and_unknown_actions() { +fn setup_descriptions_reject_unknown_actions() { assert!( - std::panic::catch_unwind(|| setup_action_description(IntegrationHost::All, "configure")) + std::panic::catch_unwind(|| setup_action_description(CodingAgent::Codex, "unknown")) .is_err() ); - assert!( - std::panic::catch_unwind(|| setup_action_description(IntegrationHost::Codex, "unknown")) - .is_err() - ); - - let runner = RealPluginSetupRunner; - let root = Path::new("unused"); - assert!(std::panic::catch_unwind(|| runner.snapshot(IntegrationHost::All)).is_err()); - assert!( - std::panic::catch_unwind(|| runner.setup(IntegrationHost::All, DEFAULT_GATEWAY_URL, root)) - .is_err() - ); - assert!( - std::panic::catch_unwind(|| runner.uninstall( - IntegrationHost::All, - DEFAULT_GATEWAY_URL, - root - )) - .is_err() - ); - assert!( - std::panic::catch_unwind(|| runner.doctor(IntegrationHost::All, DEFAULT_GATEWAY_URL, root)) - .is_err() - ); - assert!( - std::panic::catch_unwind(|| runner.doctor_json( - IntegrationHost::All, - DEFAULT_GATEWAY_URL, - root - )) - .is_err() - ); } diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index cd4749641..01228b96f 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -77,8 +77,7 @@ fn windows_verbatim_relay_paths_are_normalized_for_mcp_config() { #[test] fn readiness_worker_returns_a_report_and_handles_channel_disconnects() { let dir = tempdir().unwrap(); - let pending = - spawn_default_host_plugin_readiness(IntegrationHost::Codex, dir.path().to_path_buf()); + let pending = spawn_default_host_plugin_readiness(CodingAgent::Codex, dir.path().to_path_buf()); let readiness = receive_host_plugin_readiness(pending, Duration::from_secs(5)); assert_eq!(readiness.host, "codex"); assert!(!readiness.checks.is_empty()); @@ -87,7 +86,7 @@ fn readiness_worker_returns_a_report_and_handles_channel_disconnects() { drop(sender); let readiness = receive_host_plugin_readiness( PendingHostPluginReadiness { - host: IntegrationHost::ClaudeCode, + host: CodingAgent::ClaudeCode, state_path: dir.path().join("claude-state.json"), receiver, }, @@ -101,7 +100,7 @@ fn readiness_worker_returns_a_report_and_handles_channel_disconnects() { ); let hermes = failed_host_plugin_readiness( - IntegrationHost::Hermes, + CodingAgent::Hermes, dir.path().join("config.yaml"), "fixture failure", ); @@ -144,12 +143,12 @@ fn dry_run_cleanup_and_rollback_cover_absent_install_state() { let dir = tempdir().unwrap(); let mut dry_run = options(dir.path()); dry_run.dry_run = true; - let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); let runner = MockRunner::default(); let setup_runner = MockSetupRunner::default(); force_cleanup_existing_install( - IntegrationHost::ClaudeCode, + CodingAgent::ClaudeCode, &layout, &dry_run, &runner, @@ -157,7 +156,7 @@ fn dry_run_cleanup_and_rollback_cover_absent_install_state() { ) .unwrap(); rollback_install( - IntegrationHost::ClaudeCode, + CodingAgent::ClaudeCode, &layout, HostRegistrationProgress::default(), false, @@ -174,11 +173,11 @@ fn staged_marketplace_promotion_reports_the_source_and_target() { let staged_parent = dir.path().join("stage"); let target_parent = dir.path().join("target"); let staged = StagedPluginMarketplace { - layout: PluginLayout::new(IntegrationHost::Codex, &staged_parent), + layout: PluginLayout::new(CodingAgent::Codex, &staged_parent), parent: staged_parent, generation_lock_created: false, }; - let target = PluginLayout::new(IntegrationHost::Codex, &target_parent); + let target = PluginLayout::new(CodingAgent::Codex, &target_parent); let error = staged.promote(&target).unwrap_err(); @@ -200,7 +199,7 @@ fn replacement_generation_guard_removes_an_owned_lock_after_marker_removal() { let lock = dir.path().join("generation.lock"); crate::installation::generation::write_new_generation_with_token_at(&marker, &lock).unwrap(); let guard = - acquire_replacement_generation_lock(IntegrationHost::Codex, &marker, &lock, true).unwrap(); + acquire_replacement_generation_lock(CodingAgent::Codex, &marker, &lock, true).unwrap(); std::fs::remove_file(marker).unwrap(); drop(guard); @@ -215,7 +214,7 @@ fn replacement_generation_guard_retains_its_lock_when_marker_state_is_uncertain( let lock = dir.path().join("generation.lock"); crate::installation::generation::write_new_generation_with_token_at(&marker, &lock).unwrap(); let guard = - acquire_replacement_generation_lock(IntegrationHost::Codex, &marker, &lock, true).unwrap(); + acquire_replacement_generation_lock(CodingAgent::Codex, &marker, &lock, true).unwrap(); std::fs::remove_file(&marker).unwrap(); std::fs::create_dir(&marker).unwrap(); @@ -231,13 +230,13 @@ fn codex_plugin_requires_version_with_complete_hook_support() { let supported = MockRunner::default() .with_executable("codex", "/bin/codex") .with_capture_output("/bin/codex --version", "codex-cli 0.143.0\n"); - validate_host_version(IntegrationHost::Codex, &normal, &supported).unwrap(); + validate_host_version(CodingAgent::Codex, &normal, &supported).unwrap(); let old = MockRunner::default() .with_executable("codex", "/bin/codex") .with_capture_output("/bin/codex --version", "codex-cli 0.142.9\n"); assert!( - validate_host_version(IntegrationHost::Codex, &normal, &old) + validate_host_version(CodingAgent::Codex, &normal, &old) .unwrap_err() .contains("requires codex-cli 0.143.0") ); @@ -246,7 +245,7 @@ fn codex_plugin_requires_version_with_complete_hook_support() { .with_executable("codex", "/bin/codex") .with_capture_output("/bin/codex --version", "codex nightly\n"); assert!( - validate_host_version(IntegrationHost::Codex, &normal, &invalid) + validate_host_version(CodingAgent::Codex, &normal, &invalid) .unwrap_err() .contains("could not parse") ); @@ -255,7 +254,7 @@ fn codex_plugin_requires_version_with_complete_hook_support() { .with_executable("codex", "/bin/codex") .with_capture_output("/bin/codex --version", "codex-cli 0.143.0-alpha.1\n"); assert!( - validate_host_version(IntegrationHost::Codex, &normal, &prerelease) + validate_host_version(CodingAgent::Codex, &normal, &prerelease) .unwrap_err() .contains("codex-cli 0.143.0-alpha.1 is unsupported") ); @@ -269,7 +268,7 @@ fn codex_plugin_requires_version_with_complete_hook_support() { .with_executable("codex", "/bin/codex") .with_capture_output("/bin/codex --version", malformed); assert!( - validate_host_version(IntegrationHost::Codex, &normal, &runner) + validate_host_version(CodingAgent::Codex, &normal, &runner) .unwrap_err() .contains("could not parse"), "unexpectedly parsed {malformed:?}" @@ -284,14 +283,14 @@ fn claude_plugin_requires_version_with_always_load_support() { let supported = MockRunner::default() .with_executable("claude", "/bin/claude") .with_capture_output("/bin/claude --version", "2.1.121 (Claude Code)\n"); - validate_host_version(IntegrationHost::ClaudeCode, &normal, &supported).unwrap(); + validate_host_version(CodingAgent::ClaudeCode, &normal, &supported).unwrap(); for unsupported in ["2.1.120 (Claude Code)\n", "2.1.121-beta (Claude Code)\n"] { let runner = MockRunner::default() .with_executable("claude", "/bin/claude") .with_capture_output("/bin/claude --version", unsupported); assert!( - validate_host_version(IntegrationHost::ClaudeCode, &normal, &runner) + validate_host_version(CodingAgent::ClaudeCode, &normal, &runner) .unwrap_err() .contains("requires Claude Code 2.1.121"), "unexpectedly accepted {unsupported:?}" @@ -303,7 +302,7 @@ fn claude_plugin_requires_version_with_always_load_support() { .with_executable("claude", "/bin/claude") .with_capture_output("/bin/claude --version", malformed); assert!( - validate_host_version(IntegrationHost::ClaudeCode, &normal, &runner) + validate_host_version(CodingAgent::ClaudeCode, &normal, &runner) .unwrap_err() .contains("could not parse"), "unexpectedly parsed {malformed:?}" @@ -714,7 +713,7 @@ struct FailStateWriteAfterRefresh { } impl PluginSetupRunner for FailStateWriteAfterRefresh { - fn snapshot(&self, _host: IntegrationHost) -> Result, String> { + fn snapshot(&self, _host: CodingAgent) -> Result, String> { Ok(Some(PluginSetupSnapshot::Mock)) } @@ -731,7 +730,7 @@ impl PluginSetupRunner for FailStateWriteAfterRefresh { fn setup( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -740,7 +739,7 @@ impl PluginSetupRunner for FailStateWriteAfterRefresh { fn uninstall( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -749,7 +748,7 @@ impl PluginSetupRunner for FailStateWriteAfterRefresh { fn doctor( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -758,7 +757,7 @@ impl PluginSetupRunner for FailStateWriteAfterRefresh { fn doctor_json( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result { @@ -767,7 +766,7 @@ impl PluginSetupRunner for FailStateWriteAfterRefresh { } impl PluginSetupRunner for BlockingRefreshFailure { - fn snapshot(&self, _host: IntegrationHost) -> Result, String> { + fn snapshot(&self, _host: CodingAgent) -> Result, String> { Ok(Some(PluginSetupSnapshot::Mock)) } @@ -783,7 +782,7 @@ impl PluginSetupRunner for BlockingRefreshFailure { fn setup( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -792,7 +791,7 @@ impl PluginSetupRunner for BlockingRefreshFailure { fn uninstall( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -801,7 +800,7 @@ impl PluginSetupRunner for BlockingRefreshFailure { fn doctor( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -810,7 +809,7 @@ impl PluginSetupRunner for BlockingRefreshFailure { fn doctor_json( &self, - _host: IntegrationHost, + _host: CodingAgent, _gateway_url: &str, _plugin_root: &Path, ) -> Result { @@ -829,8 +828,8 @@ impl MockSetupRunner { } impl PluginSetupRunner for MockSetupRunner { - fn snapshot(&self, host: IntegrationHost) -> Result, String> { - self.record(format!("snapshot {}", host.as_arg()))?; + fn snapshot(&self, host: CodingAgent) -> Result, String> { + self.record(format!("snapshot {}", host.install_arg()))?; Ok(Some(PluginSetupSnapshot::Mock)) } @@ -844,41 +843,41 @@ impl PluginSetupRunner for MockSetupRunner { fn setup( &self, - host: IntegrationHost, + host: CodingAgent, gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { - self.record(format!("setup {} {gateway_url}", host.as_arg())) + self.record(format!("setup {} {gateway_url}", host.install_arg())) } fn uninstall( &self, - host: IntegrationHost, + host: CodingAgent, gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { - self.record(format!("uninstall {} {gateway_url}", host.as_arg())) + self.record(format!("uninstall {} {gateway_url}", host.install_arg())) } fn doctor( &self, - host: IntegrationHost, + host: CodingAgent, gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { - self.record(format!("doctor {} {gateway_url}", host.as_arg())) + self.record(format!("doctor {} {gateway_url}", host.install_arg())) } fn doctor_json( &self, - host: IntegrationHost, + host: CodingAgent, gateway_url: &str, plugin_root: &Path, ) -> Result { self.doctor_roots .borrow_mut() .push(plugin_root.to_path_buf()); - self.record(format!("doctor-json {} {gateway_url}", host.as_arg()))?; + self.record(format!("doctor-json {} {gateway_url}", host.install_arg()))?; Ok(json!({ "ok": true, "checks": {} @@ -915,7 +914,7 @@ fn relay_mcp_validation_command() -> String { "/bin/nemo-relay mcp --help".into() } -fn write_installed_state(host: IntegrationHost, dir: &Path) { +fn write_installed_state(host: CodingAgent, dir: &Path) { let layout = PluginLayout::new(host, dir); write_plugin_marketplace(host, &layout, Path::new("/bin/nemo-relay"), &options(dir)).unwrap(); write_state(&layout, &options(dir)).unwrap(); @@ -937,16 +936,16 @@ fn replace_generation_with_legacy_marker(layout: &PluginLayout) -> (String, Path } fn write_relocated_codex_install(selected_dir: &Path, relocated_dir: &Path) -> PluginLayout { - let relocated = PluginLayout::new(IntegrationHost::Codex, relocated_dir); + let relocated = PluginLayout::new(CodingAgent::Codex, relocated_dir); write_plugin_marketplace( - IntegrationHost::Codex, + CodingAgent::Codex, &relocated, Path::new("/bin/nemo-relay"), &options(selected_dir), ) .unwrap(); write_state_for_host( - IntegrationHost::Codex, + CodingAgent::Codex, &PluginState { marketplace_root: relocated.marketplace_root.clone(), plugin_root: relocated.plugin_root.clone(), @@ -1107,7 +1106,7 @@ fn cross_process_lock_holder() { PathBuf::from(std::env::var_os(OPERATION_LOCK_HELPER_GLOBAL_DIR_ENV).unwrap()); _operation_lock = Some( PluginOperationLock::acquire( - IntegrationHost::Codex, + CodingAgent::Codex, &global_lock_dir, Path::new(&path), Duration::from_secs(5), @@ -1145,7 +1144,7 @@ fn concurrent_install_install_times_out_without_mutating() { let setup_runner = MockSetupRunner::default(); let error = install_host_with_operation_timeout( - IntegrationHost::Codex, + CodingAgent::Codex, &install_options, &runner, &setup_runner, @@ -1174,7 +1173,7 @@ fn concurrent_install_uninstall_times_out_without_mutating() { let setup_runner = MockSetupRunner::default(); let error = uninstall_host_with_operation_timeout( - IntegrationHost::Codex, + CodingAgent::Codex, &install_options, &runner, &setup_runner, @@ -1207,7 +1206,7 @@ fn concurrent_different_install_roots_share_the_global_host_lock() { second_options.operation_lock_dir = global_lock_dir; let install_error = install_host_with_operation_timeout( - IntegrationHost::Codex, + CodingAgent::Codex, &second_options, &runner, &setup_runner, @@ -1279,26 +1278,26 @@ fn default_install_dir_follows_platform_conventions() { #[test] fn plugin_manifests_and_hooks_use_path_based_relay_command() { assert_eq!( - marketplace_manifest(IntegrationHost::Codex)["name"], + marketplace_manifest(CodingAgent::Codex)["name"], json!(MARKETPLACE_NAME) ); assert_eq!( - marketplace_manifest(IntegrationHost::ClaudeCode)["plugins"][0]["source"], + marketplace_manifest(CodingAgent::ClaudeCode)["plugins"][0]["source"], json!("./plugins/nemo-relay-plugin") ); assert_eq!( - plugin_manifest(IntegrationHost::Codex)["name"], + plugin_manifest(CodingAgent::Codex)["name"], json!(PLUGIN_NAME) ); assert_eq!( - plugin_manifest(IntegrationHost::Codex)["mcpServers"], + plugin_manifest(CodingAgent::Codex)["mcpServers"], json!("./.mcp.json") ); let generation_fence = std::env::current_dir() .unwrap() .join("plugins/nemo-relay-plugin/.nemo-relay-generation"); let mcp = plugin_mcp_config( - IntegrationHost::Codex, + CodingAgent::Codex, Path::new("/bin/nemo-relay"), &generation_fence, TEST_GENERATION_TOKEN, @@ -1324,7 +1323,7 @@ fn plugin_manifests_and_hooks_use_path_based_relay_command() { .contains(&json!("OPENAI_API_KEY")) ); let claude_mcp = plugin_mcp_config( - IntegrationHost::ClaudeCode, + CodingAgent::ClaudeCode, Path::new("/bin/nemo-relay"), &generation_fence, TEST_GENERATION_TOKEN, @@ -1343,7 +1342,7 @@ fn plugin_manifests_and_hooks_use_path_based_relay_command() { ); assert_eq!( plugin_hooks( - IntegrationHost::Codex, + CodingAgent::Codex, Path::new("/bin/nemo-relay"), &generation_fence, TEST_GENERATION_TOKEN, @@ -1361,7 +1360,7 @@ fn plugin_manifests_and_hooks_use_path_based_relay_command() { ); assert_eq!( plugin_hooks( - IntegrationHost::ClaudeCode, + CodingAgent::ClaudeCode, Path::new("/bin/nemo-relay"), &generation_fence, TEST_GENERATION_TOKEN, @@ -1394,7 +1393,7 @@ fn relay_identity_uses_running_executable_when_path_points_elsewhere() { assert_eq!(relay, PathBuf::from("/opt/nemo-relay/current/nemo-relay")); assert_eq!( plugin_hooks( - IntegrationHost::Codex, + CodingAgent::Codex, &relay, &generation, TEST_GENERATION_TOKEN, @@ -1412,7 +1411,7 @@ fn relay_identity_uses_running_executable_when_path_points_elsewhere() { ); assert_eq!( plugin_mcp_config( - IntegrationHost::Codex, + CodingAgent::Codex, &relay, &generation, TEST_GENERATION_TOKEN, @@ -1589,30 +1588,30 @@ fn codex_mcp_env_vars_match_and_deduplicate_names_using_platform_semantics() { #[test] fn plugin_setup_delegates_and_dry_run_skips_runner_calls() { let dir = tempdir().unwrap(); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let setup_runner = MockSetupRunner::default(); let dry_run = PluginInstallOptions { dry_run: true, ..options(dir.path()) }; - run_plugin_setup(IntegrationHost::Codex, &layout, &dry_run, &setup_runner).unwrap(); + run_plugin_setup(CodingAgent::Codex, &layout, &dry_run, &setup_runner).unwrap(); run_plugin_uninstall( - IntegrationHost::ClaudeCode, + CodingAgent::ClaudeCode, &layout.plugin_root, &dry_run, &setup_runner, ) .unwrap(); run_plugin_doctor( - IntegrationHost::Codex, + CodingAgent::Codex, &layout.plugin_root, &dry_run, &setup_runner, ) .unwrap(); uninstall_host_locked( - IntegrationHost::Codex, + CodingAgent::Codex, &dry_run, &MockRunner::default(), &setup_runner, @@ -1621,27 +1620,24 @@ fn plugin_setup_delegates_and_dry_run_skips_runner_calls() { assert!(setup_runner.calls().is_empty()); let normal = options(dir.path()); - run_plugin_setup(IntegrationHost::Codex, &layout, &normal, &setup_runner).unwrap(); + run_plugin_setup(CodingAgent::Codex, &layout, &normal, &setup_runner).unwrap(); run_plugin_uninstall( - IntegrationHost::ClaudeCode, + CodingAgent::ClaudeCode, &layout.plugin_root, &normal, &setup_runner, ) .unwrap(); run_plugin_doctor( - IntegrationHost::Codex, + CodingAgent::Codex, &layout.plugin_root, &normal, &setup_runner, ) .unwrap(); - let report = run_plugin_doctor_json( - IntegrationHost::ClaudeCode, - &layout.plugin_root, - &setup_runner, - ) - .unwrap(); + let report = + run_plugin_doctor_json(CodingAgent::ClaudeCode, &layout.plugin_root, &setup_runner) + .unwrap(); assert_eq!( setup_runner.calls(), @@ -1663,65 +1659,49 @@ fn real_plugin_setup_runner_uses_temp_home_for_claude_paths() { let plugin_root = dir.path().join("plugin"); runner - .setup( - IntegrationHost::ClaudeCode, - DEFAULT_GATEWAY_URL, - &plugin_root, - ) + .setup(CodingAgent::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) .unwrap(); assert!( runner - .doctor( - IntegrationHost::ClaudeCode, - DEFAULT_GATEWAY_URL, - &plugin_root - ) + .doctor(CodingAgent::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) .is_ok() ); let claude_report = runner - .doctor_json( - IntegrationHost::ClaudeCode, - DEFAULT_GATEWAY_URL, - &plugin_root, - ) + .doctor_json(CodingAgent::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) .unwrap(); assert_eq!( claude_report["checks"]["claude_provider_routing"], json!(true) ); runner - .uninstall( - IntegrationHost::ClaudeCode, - DEFAULT_GATEWAY_URL, - &plugin_root, - ) + .uninstall(CodingAgent::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) .unwrap(); } #[test] fn setup_action_descriptions_cover_supported_hosts_and_actions() { assert_eq!( - setup_action_description(IntegrationHost::Codex, "configure"), + setup_action_description(CodingAgent::Codex, "configure"), "configure Codex provider and trust plugin-owned hooks" ); assert_eq!( - setup_action_description(IntegrationHost::Codex, "restore"), + setup_action_description(CodingAgent::Codex, "restore"), "remove Codex provider and plugin hook trust" ); assert_eq!( - setup_action_description(IntegrationHost::Codex, "doctor"), + setup_action_description(CodingAgent::Codex, "doctor"), "check Codex provider and plugin-owned hooks" ); assert_eq!( - setup_action_description(IntegrationHost::ClaudeCode, "configure"), + setup_action_description(CodingAgent::ClaudeCode, "configure"), "enable Claude Code provider routing through NeMo Relay" ); assert_eq!( - setup_action_description(IntegrationHost::ClaudeCode, "restore"), + setup_action_description(CodingAgent::ClaudeCode, "restore"), "restore Claude Code provider routing from NeMo Relay backup" ); assert_eq!( - setup_action_description(IntegrationHost::ClaudeCode, "doctor"), + setup_action_description(CodingAgent::ClaudeCode, "doctor"), "check Claude Code provider routing" ); } @@ -1739,8 +1719,8 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { require_relay(&dry_run, &runner).unwrap(), PathBuf::from(RELAY_COMMAND) ); - require_host_cli(IntegrationHost::Codex, &dry_run, &runner).unwrap(); - validate_host_version(IntegrationHost::ClaudeCode, &dry_run, &runner).unwrap(); + require_host_cli(CodingAgent::Codex, &dry_run, &runner).unwrap(); + validate_host_version(CodingAgent::ClaudeCode, &dry_run, &runner).unwrap(); validate_relay_hook_forward(Path::new("nemo-relay"), &dry_run, &runner).unwrap(); validate_relay_mcp(Path::new("nemo-relay"), &dry_run, &runner).unwrap(); run_command( @@ -1759,7 +1739,7 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { .unwrap(); let capture = run_capture_command("codex", &["plugin".into()], &dry_run, &runner).unwrap(); assert_eq!(capture.stdout, "null\n"); - let report = host_registration_report(IntegrationHost::Codex, &dry_run, &runner).unwrap(); + let report = host_registration_report(CodingAgent::Codex, &dry_run, &runner).unwrap(); assert!(report.ok()); assert_eq!(report.to_json()["ok"], json!(true)); assert_eq!( @@ -1778,7 +1758,7 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { .contains("nemo-relay") ); assert!( - require_host_cli(IntegrationHost::Codex, &normal, &runner) + require_host_cli(CodingAgent::Codex, &normal, &runner) .unwrap_err() .contains("codex") ); @@ -1851,7 +1831,7 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { .with_executable("codex", "/bin/codex") .with_capture_output("/bin/codex plugin list", "PLUGIN STATUS VERSION PATH\n") .with_capture_output("/bin/codex plugin marketplace list", "MARKETPLACE ROOT\n"); - let error = validate_host_registration(IntegrationHost::Codex, &normal, &runner).unwrap_err(); + let error = validate_host_registration(CodingAgent::Codex, &normal, &runner).unwrap_err(); assert!( error.contains("host plugin") && error.contains("host marketplace"), "error was: {error}" @@ -1888,8 +1868,7 @@ fn host_registration_report_accepts_claude_and_codex_shape_variants() { "/bin/claude plugin marketplace list --json", json!([marketplace_entry]).to_string(), ); - let report = - host_registration_report(IntegrationHost::ClaudeCode, &normal, &runner).unwrap(); + let report = host_registration_report(CodingAgent::ClaudeCode, &normal, &runner).unwrap(); assert!(report.ok()); assert!(report.host_plugin_registered); assert!(report.host_marketplace_registered); @@ -1905,7 +1884,7 @@ fn host_registration_report_accepts_claude_and_codex_shape_variants() { "/bin/codex plugin marketplace list", format!("{MARKETPLACE_NAME} /tmp/nemo-relay-local\n"), ); - let report = host_registration_report(IntegrationHost::Codex, &normal, &runner).unwrap(); + let report = host_registration_report(CodingAgent::Codex, &normal, &runner).unwrap(); assert!(report.ok()); let runner = MockRunner::default() @@ -1918,7 +1897,7 @@ fn host_registration_report_accepts_claude_and_codex_shape_variants() { "/bin/codex plugin marketplace list", format!("{MARKETPLACE_NAME} /tmp/nemo-relay-local\n"), ); - let report = host_registration_report(IntegrationHost::Codex, &normal, &runner).unwrap(); + let report = host_registration_report(CodingAgent::Codex, &normal, &runner).unwrap(); assert!(!report.host_plugin_registered); assert!(report.host_marketplace_registered); @@ -1929,7 +1908,7 @@ fn host_registration_report_accepts_claude_and_codex_shape_variants() { format!("{PLUGIN_NAME}@other installed, enabled 0.4.0 /tmp/other\n"), ) .with_capture_output("/bin/codex plugin marketplace list", "other /tmp/other\n"); - let report = host_registration_report(IntegrationHost::Codex, &normal, &runner).unwrap(); + let report = host_registration_report(CodingAgent::Codex, &normal, &runner).unwrap(); assert!(!report.ok()); assert!(!report.host_plugin_registered); assert!(!report.host_marketplace_registered); @@ -1944,7 +1923,7 @@ fn host_registration_report_surfaces_capture_status_and_stderr_variants() { .with_executable("claude", "/bin/claude") .with_capture_output("/bin/claude plugin list --json", "not json"); assert!( - host_registration_report(IntegrationHost::ClaudeCode, &normal, &runner) + host_registration_report(CodingAgent::ClaudeCode, &normal, &runner) .unwrap_err() .contains("failed to parse") ); @@ -1957,8 +1936,7 @@ fn host_registration_report_surfaces_capture_status_and_stderr_variants() { "ignored stdout", " noisy failure \n", ); - let error = - host_registration_report(IntegrationHost::ClaudeCode, &normal, &runner).unwrap_err(); + let error = host_registration_report(CodingAgent::ClaudeCode, &normal, &runner).unwrap_err(); assert!(error.contains("exit code 4: noisy failure")); let runner = MockRunner::default() @@ -1973,8 +1951,7 @@ fn host_registration_report_surfaces_capture_status_and_stderr_variants() { "ignored stdout", "", ); - let error = - host_registration_report(IntegrationHost::ClaudeCode, &normal, &runner).unwrap_err(); + let error = host_registration_report(CodingAgent::ClaudeCode, &normal, &runner).unwrap_err(); assert!(error.contains("exit code 5")); assert!(!error.contains("exit code 5:")); } @@ -1986,65 +1963,48 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { std::fs::create_dir_all(&empty_path).unwrap(); let _path = PathScope::set_isolated(&empty_path, &dir.path().join("home")); - let install_error = install(crate::installation::InstallRequest { - host: IntegrationHost::All, - install_dir: Some(dir.path().join("install")), - force: false, - dry_run: false, - skip_doctor: true, - }) - .unwrap_err() - .to_string(); assert!( - install_error.contains("no supported Claude Code, Codex, or Hermes host CLI"), - "error was: {install_error}" + detected_install_agents(&CodingAgent::ALL) + .unwrap() + .is_empty() ); - - let uninstall_error = uninstall(crate::installation::UninstallRequest { - host: IntegrationHost::All, - install_dir: Some(dir.path().join("install")), - dry_run: false, - }) - .unwrap_err() - .to_string(); assert!( - uninstall_error.contains("no installed Claude Code, Codex, or Hermes integration state"), - "error was: {uninstall_error}" + installed_agents(&CodingAgent::ALL, Some(&dir.path().join("install"))) + .unwrap() + .is_empty() ); - let doctor_error = doctor(IntegrationHost::All, Some(dir.path().join("install")), true) + let doctor_error = doctor(&[], Some(dir.path().join("install")), true) .unwrap_err() .to_string(); assert!( doctor_error.contains("no installed Claude Code, Codex, or Hermes integration state"), "error was: {doctor_error}" ); - let doctor_human_error = doctor( - IntegrationHost::All, - Some(dir.path().join("install")), - false, - ) - .unwrap_err() - .to_string(); + let doctor_human_error = doctor(&[], Some(dir.path().join("install")), false) + .unwrap_err() + .to_string(); assert!( doctor_human_error.contains("no installed Claude Code, Codex, or Hermes integration state"), "error was: {doctor_human_error}" ); assert_eq!( - install(crate::installation::InstallRequest { - host: IntegrationHost::Codex, - install_dir: Some(dir.path().join("dry-run-install")), - force: false, - dry_run: true, - skip_doctor: true, - }) + install( + CodingAgent::Codex, + crate::installation::InstallRequest { + install_dir: Some(dir.path().join("dry-run-install")), + force: false, + dry_run: true, + skip_doctor: true, + } + ) .unwrap(), std::process::ExitCode::SUCCESS ); let codex_doctor_error = doctor( - IntegrationHost::Codex, + &[CodingAgent::Codex], Some(dir.path().join("install")), false, ) @@ -2055,15 +2015,15 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { "error was: {codex_doctor_error}" ); - assert_eq!(IntegrationHost::All.as_arg(), "all"); - assert_eq!(IntegrationHost::All.label(), "all"); + assert_eq!(CodingAgent::Codex.as_arg(), "codex"); + assert_eq!(CodingAgent::Codex.label(), "Codex"); print_json(&json!({"ok": true})).unwrap(); assert_eq!( with_schema(json!({"ok": true})), json!({"ok": true, "schema_version": 1}) ); assert_eq!(with_schema(json!("not-an-object")), json!("not-an-object")); - assert_eq!(IntegrationHost::All.executable(), None); + assert_eq!(CodingAgent::Codex.executable(), "codex"); } #[test] @@ -2073,49 +2033,49 @@ fn select_all_uses_operation_specific_inputs() { std::fs::create_dir_all(home.join(".hermes")).unwrap(); let _home = HomeScope::enter(&home); let runner = MockRunner::default().with_executable("codex", "/bin/codex"); - let selected = select_hosts( - IntegrationHost::All, + let selected = select_available_agents( + &CodingAgent::ALL, HostSelectionMode::Install, - &options(dir.path()), + dir.path(), &runner, ) .unwrap(); - assert_eq!(selected, vec![IntegrationHost::Codex]); + assert_eq!(selected, vec![CodingAgent::Codex]); std::fs::write( - state_path(IntegrationHost::ClaudeCode, dir.path()), + state_path(CodingAgent::ClaudeCode, dir.path()), r#"{"marketplaceRoot":"/tmp/m","pluginRoot":"/tmp/p"}"#, ) .unwrap(); - let selected = select_hosts( - IntegrationHost::All, + let selected = select_available_agents( + &CodingAgent::ALL, HostSelectionMode::Install, - &options(dir.path()), + dir.path(), &runner, ) .unwrap(); - assert_eq!(selected, vec![IntegrationHost::Codex]); + assert_eq!(selected, vec![CodingAgent::Codex]); - let selected = select_hosts( - IntegrationHost::All, + let selected = select_available_agents( + &CodingAgent::ALL, HostSelectionMode::InstalledState, - &options(dir.path()), + dir.path(), &runner, ) .unwrap(); - assert_eq!(selected, vec![IntegrationHost::ClaudeCode]); + assert_eq!(selected, vec![CodingAgent::ClaudeCode]); let unrelated_hermes_config = b"# user-owned formatting\nmodel: custom\n"; let hermes_config = home.join(".hermes/config.yaml"); std::fs::write(&hermes_config, unrelated_hermes_config).unwrap(); - let selected = select_hosts( - IntegrationHost::All, + let selected = select_available_agents( + &CodingAgent::ALL, HostSelectionMode::InstalledState, - &options(dir.path()), + dir.path(), &runner, ) .unwrap(); - assert_eq!(selected, vec![IntegrationHost::ClaudeCode]); + assert_eq!(selected, vec![CodingAgent::ClaudeCode]); assert_eq!( std::fs::read(&hermes_config).unwrap(), unrelated_hermes_config @@ -2242,20 +2202,20 @@ fn install_codex_generates_marketplace_and_runs_setup() { let setup_runner = MockSetupRunner::default(); install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, ) .unwrap(); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let generation = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); assert_eq!( serde_json::from_str::(&std::fs::read_to_string(&layout.hooks_path).unwrap()) .unwrap(), plugin_hooks( - IntegrationHost::Codex, + CodingAgent::Codex, Path::new("/bin/nemo-relay"), &layout.generation_fence, generation.token(), @@ -2280,7 +2240,7 @@ fn install_codex_generates_marketplace_and_runs_setup() { serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) .unwrap(), plugin_mcp_config( - IntegrationHost::Codex, + CodingAgent::Codex, Path::new("/bin/nemo-relay"), &layout.generation_fence, generation.token(), @@ -2300,13 +2260,13 @@ fn install_prunes_stale_managed_plugin_root() { .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("claude", "/bin/claude"); let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); let stale = layout.plugin_root.join("bin").join("nemo-relay"); std::fs::create_dir_all(stale.parent().unwrap()).unwrap(); std::fs::write(&stale, "stale").unwrap(); install_host( - IntegrationHost::ClaudeCode, + CodingAgent::ClaudeCode, &options(dir.path()), &runner, &setup_runner, @@ -2325,15 +2285,15 @@ fn ordinary_codex_reinstall_refuses_a_fenced_install_without_mutating_it() { .with_executable("codex", "/bin/codex") .with_codex_registration(true, true); let setup_runner = MockSetupRunner::default(); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let sentinel = layout.plugin_root.join("existing-install"); std::fs::write(&sentinel, b"preserve").unwrap(); let state = std::fs::read(&layout.state_path).unwrap(); let generation = std::fs::read(&layout.generation_fence).unwrap(); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -2361,13 +2321,13 @@ fn ordinary_codex_reinstall_refuses_a_legacy_install_before_staging() { .with_executable("codex", "/bin/codex") .with_codex_registration(false, false); let setup_runner = MockSetupRunner::default(); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); std::fs::remove_file(&layout.generation_fence).unwrap(); let state = std::fs::read(&layout.state_path).unwrap(); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -2392,7 +2352,7 @@ fn ordinary_codex_reinstall_refuses_a_registration_without_local_state() { let setup_runner = MockSetupRunner::default(); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -2413,13 +2373,13 @@ fn ordinary_codex_reinstall_refuses_a_corrupt_install_before_staging() { .with_executable("codex", "/bin/codex") .with_codex_registration(false, false); let setup_runner = MockSetupRunner::default(); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); std::fs::write(&layout.generation_fence, b"").unwrap(); let state = std::fs::read(&layout.state_path).unwrap(); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -2447,9 +2407,9 @@ fn force_install_unregisters_existing_host_before_reinstall() { force: true, ..options(dir.path()) }; - write_installed_state(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); - install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap(); + install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap(); let commands = runner.commands(); let remove_index = commands @@ -2510,8 +2470,8 @@ fn force_install_retires_previous_mcp_generation() { force: true, ..options(dir.path()) }; - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); let previous_token = previous.token().to_string(); let cached_mcp = @@ -2522,7 +2482,7 @@ fn force_install_retires_previous_mcp_generation() { ) .unwrap(); - install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap(); + install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap(); let error = previous.verify_current().unwrap_err(); assert!(error.contains("has been retired")); @@ -2571,19 +2531,19 @@ fn force_install_reuses_the_same_windows_lock_after_install_dir_canonicalization let setup_runner = MockSetupRunner::default(); install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(&first_install_dir), &runner, &setup_runner, ) .unwrap(); - let first_layout = PluginLayout::new(IntegrationHost::Codex, &first_install_dir); + let first_layout = PluginLayout::new(CodingAgent::Codex, &first_install_dir); let previous = InstallGeneration::capture(first_layout.generation_fence).unwrap(); let canonical_install_dir = requested_install_dir.canonicalize().unwrap(); assert_ne!(first_install_dir, canonical_install_dir); install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &PluginInstallOptions { force: true, ..options(&canonical_install_dir) @@ -2594,7 +2554,7 @@ fn force_install_reuses_the_same_windows_lock_after_install_dir_canonicalization .unwrap(); assert!(previous.verify_current().unwrap_err().contains("retired")); - let current_layout = PluginLayout::new(IntegrationHost::Codex, &canonical_install_dir); + let current_layout = PluginLayout::new(CodingAgent::Codex, &canonical_install_dir); assert!(current_layout.generation_lock.exists()); InstallGeneration::capture(current_layout.generation_fence).unwrap(); } @@ -2608,12 +2568,12 @@ fn force_install_migrates_a_legacy_sibling_lock_before_moving_the_marketplace() .with_executable("codex", "/bin/codex") .with_codex_registration(true, true); let setup_runner = MockSetupRunner::default(); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let (previous_token, legacy_lock) = replace_generation_with_legacy_marker(&layout); install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &PluginInstallOptions { force: true, ..options(dir.path()) @@ -2637,12 +2597,12 @@ fn uninstall_releases_a_legacy_sibling_lock_before_removing_the_marketplace() { .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let (_, legacy_lock) = replace_generation_with_legacy_marker(&layout); uninstall_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -2666,12 +2626,12 @@ fn legacy_force_install_rollback_restores_the_sibling_lock_without_external_resi failing_call: Some(format!("doctor codex {DEFAULT_GATEWAY_URL}")), ..MockSetupRunner::default() }; - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let (previous_token, legacy_lock) = replace_generation_with_legacy_marker(&layout); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &PluginInstallOptions { force: true, skip_doctor: false, @@ -2701,21 +2661,15 @@ fn claude_force_install_retires_and_replaces_its_mcp_generation() { force: true, ..options(dir.path()) }; - write_installed_state(IntegrationHost::ClaudeCode, dir.path()); - let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); + write_installed_state(CodingAgent::ClaudeCode, dir.path()); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); let previous_token = previous.token().to_string(); let cached_mcp = serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) .unwrap(); - install_host( - IntegrationHost::ClaudeCode, - &options, - &runner, - &setup_runner, - ) - .unwrap(); + install_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap(); assert!(previous.verify_current().unwrap_err().contains("retired")); let current = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); @@ -2758,20 +2712,15 @@ fn claude_force_install_rollback_restores_generation_files_and_setup_snapshot() skip_doctor: false, ..options(dir.path()) }; - write_installed_state(IntegrationHost::ClaudeCode, dir.path()); - let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); + write_installed_state(CodingAgent::ClaudeCode, dir.path()); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); let sentinel = layout.plugin_root.join("previous-install"); std::fs::write(&sentinel, "restore-exactly").unwrap(); let original_state = std::fs::read(&layout.state_path).unwrap(); let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); - let error = install_host( - IntegrationHost::ClaudeCode, - &options, - &runner, - &setup_runner, - ) - .unwrap_err(); + let error = + install_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); assert!(error.contains("doctor claude-code"), "{error}"); assert_eq!( @@ -2810,29 +2759,23 @@ fn claude_force_install_migrates_a_legacy_hook_only_plugin() { force: true, ..options(dir.path()) }; - let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); std::fs::create_dir_all(layout.plugin_manifest.parent().unwrap()).unwrap(); write_json( &layout.marketplace_manifest, - &marketplace_manifest(IntegrationHost::ClaudeCode), + &marketplace_manifest(CodingAgent::ClaudeCode), ) .unwrap(); - let mut legacy_manifest = plugin_manifest(IntegrationHost::ClaudeCode); + let mut legacy_manifest = plugin_manifest(CodingAgent::ClaudeCode); legacy_manifest .as_object_mut() .unwrap() .remove("mcpServers"); write_json(&layout.plugin_manifest, &legacy_manifest).unwrap(); write_state(&layout, &options).unwrap(); - mark_plugin_setup_installed(IntegrationHost::ClaudeCode, &layout, &options).unwrap(); + mark_plugin_setup_installed(CodingAgent::ClaudeCode, &layout, &options).unwrap(); - install_host( - IntegrationHost::ClaudeCode, - &options, - &runner, - &setup_runner, - ) - .unwrap(); + install_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap(); InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); assert!(layout.mcp_config.is_file()); @@ -2850,12 +2793,12 @@ fn ordinary_claude_reinstall_requires_force_for_a_fenced_plugin() { .with_executable("claude", "/bin/claude") .with_claude_registration(true, true); let setup_runner = MockSetupRunner::default(); - write_installed_state(IntegrationHost::ClaudeCode, dir.path()); - let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); + write_installed_state(CodingAgent::ClaudeCode, dir.path()); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); let original_generation = std::fs::read(&layout.generation_fence).unwrap(); let error = install_host( - IntegrationHost::ClaudeCode, + CodingAgent::ClaudeCode, &options(dir.path()), &runner, &setup_runner, @@ -2889,15 +2832,10 @@ fn force_install_rejects_persisted_roots_outside_selected_layout() { ..options(&selected_dir) }; - let error = install_host( - IntegrationHost::Codex, - &install_options, - &runner, - &setup_runner, - ) - .unwrap_err(); + let error = + install_host(CodingAgent::Codex, &install_options, &runner, &setup_runner).unwrap_err(); - let current = PluginLayout::new(IntegrationHost::Codex, &selected_dir); + let current = PluginLayout::new(CodingAgent::Codex, &selected_dir); assert!( error.contains("outside the selected install layout"), "{error}" @@ -2921,7 +2859,7 @@ fn uninstall_rejects_persisted_roots_outside_selected_layout() { let relocated = write_relocated_codex_install(&selected_dir, &relocated_dir); let sentinel = relocated.plugin_root.join("relocated-install"); std::fs::write(&sentinel, "restore-exactly").unwrap(); - let original_state = std::fs::read(state_path(IntegrationHost::Codex, &selected_dir)).unwrap(); + let original_state = std::fs::read(state_path(CodingAgent::Codex, &selected_dir)).unwrap(); let runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex") @@ -2929,7 +2867,7 @@ fn uninstall_rejects_persisted_roots_outside_selected_layout() { let setup_runner = MockSetupRunner::default(); let error = uninstall_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(&selected_dir), &runner, &setup_runner, @@ -2940,7 +2878,7 @@ fn uninstall_rejects_persisted_roots_outside_selected_layout() { error.contains("outside the selected install layout"), "{error}" ); - let current = PluginLayout::new(IntegrationHost::Codex, &selected_dir); + let current = PluginLayout::new(CodingAgent::Codex, &selected_dir); assert!(!current.marketplace_root.exists()); assert!(!current.generation_lock.exists()); assert!(relocated.marketplace_root.exists()); @@ -2950,7 +2888,7 @@ fn uninstall_rejects_persisted_roots_outside_selected_layout() { "restore-exactly" ); assert_eq!( - std::fs::read(state_path(IntegrationHost::Codex, &selected_dir)).unwrap(), + std::fs::read(state_path(CodingAgent::Codex, &selected_dir)).unwrap(), original_state ); assert!(runner.commands().is_empty()); @@ -2979,7 +2917,7 @@ fn force_install_rejects_registered_legacy_plugin_without_generation_fence() { ..options(dir.path()) }; - let error = install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + let error = install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); assert!( error.contains("MCP generation marker is missing"), @@ -3004,11 +2942,11 @@ fn force_install_rejects_unregistered_legacy_plugin_without_generation_fence() { force: true, ..options(dir.path()) }; - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); std::fs::remove_file(&layout.generation_fence).unwrap(); - let error = install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + let error = install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); assert!( error.contains("MCP generation marker is missing"), @@ -3038,12 +2976,11 @@ fn force_install_rejects_corrupt_generation_marker_without_mutating() { force: true, ..options(dir.path()) }; - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); corrupt_generation_fence(&layout.generation_fence, corruption); - let error = - install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + let error = install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); assert_actionable_generation_error(&error, "is invalid or unreadable"); assert!(error.contains(cause), "{corruption}: {error}"); @@ -3068,9 +3005,9 @@ fn force_install_allows_a_clean_first_install_without_generation_fence() { ..options(dir.path()) }; - install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap(); + install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap(); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); assert!(layout.generation_fence.exists()); assert!(layout.state_path.exists()); } @@ -3087,9 +3024,9 @@ fn force_install_uses_live_absent_registration_instead_of_stale_installed_state( force: true, ..options(dir.path()) }; - write_installed_state(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); - install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap(); + install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap(); let commands = runner.commands(); assert!( @@ -3118,10 +3055,10 @@ fn force_install_uses_live_present_registration_instead_of_stale_removed_state() force: true, ..options(dir.path()) }; - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); write_state_for_host( - IntegrationHost::Codex, + CodingAgent::Codex, &PluginState { marketplace_root: layout.marketplace_root.clone(), plugin_root: layout.plugin_root.clone(), @@ -3134,7 +3071,7 @@ fn force_install_uses_live_present_registration_instead_of_stale_removed_state() ) .unwrap(); - install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap(); + install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap(); let commands = runner.commands(); assert!(commands.iter().any(|command| { @@ -3192,11 +3129,11 @@ fn force_install_keeps_existing_registration_when_gateway_refresh_fails() { force: true, ..options(dir.path()) }; - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); - let error = install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + let error = install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); assert!(error.contains("refresh gateway failed")); assert!(layout.state_path.exists()); @@ -3223,8 +3160,8 @@ fn force_install_keeps_existing_registration_when_gateway_refresh_fails() { #[test] fn failed_force_refresh_hides_transient_generation_retirement_from_mcp() { let dir = tempdir().unwrap(); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); let install_dir = dir.path().to_path_buf(); let (entered_tx, entered_rx) = std::sync::mpsc::channel(); @@ -3243,7 +3180,7 @@ fn failed_force_refresh_hides_transient_generation_retirement_from_mcp() { force: true, ..options(&install_dir) }; - install_host(IntegrationHost::Codex, &options, &runner, &setup_runner) + install_host(CodingAgent::Codex, &options, &runner, &setup_runner) }); entered_rx.recv_timeout(Duration::from_secs(1)).unwrap(); @@ -3270,8 +3207,8 @@ fn failed_force_refresh_hides_transient_generation_retirement_from_mcp() { #[test] fn replacement_retirement_aggregates_refresh_and_restore_failures_without_rewriting_new_tree() { let dir = tempdir().unwrap(); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let backup = dir.path().join("replacement-backup"); let install_dir = dir.path().to_path_buf(); let retirement_layout = layout.clone(); @@ -3284,7 +3221,7 @@ fn replacement_retirement_aggregates_refresh_and_restore_failures_without_rewrit continue_refresh: continue_rx, }; retire_replacement_before_rollback( - IntegrationHost::Codex, + CodingAgent::Codex, &retirement_layout, &options(&install_dir), &setup_runner, @@ -3331,12 +3268,11 @@ fn uninstall_restores_mcp_generation_when_gateway_refresh_fails() { ..MockSetupRunner::default() }; let options = options(dir.path()); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); - let error = - uninstall_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + let error = uninstall_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); assert!(error.contains("refresh gateway failed")); previous.verify_current().unwrap(); @@ -3362,13 +3298,13 @@ fn force_install_restores_previous_install_after_doctor_failure() { skip_doctor: false, ..options(dir.path()) }; - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let sentinel = layout.plugin_root.join("previous-install"); std::fs::write(&sentinel, "preserve").unwrap(); let original_state = std::fs::read(&layout.state_path).unwrap(); - let error = install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + let error = install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); assert!(error.contains("doctor codex"), "{error}"); assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "preserve"); @@ -3414,8 +3350,8 @@ fn force_install_restores_previous_install_after_state_write_failure() { force: true, ..options(dir.path()) }; - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let sentinel = layout.plugin_root.join("previous-install"); std::fs::write(&sentinel, "preserve").unwrap(); let original_state = std::fs::read(&layout.state_path).unwrap(); @@ -3425,13 +3361,8 @@ fn force_install_restores_previous_install_after_state_write_failure() { injected: Cell::new(false), }; - let error = install_host( - IntegrationHost::Codex, - &install_options, - &runner, - &setup_runner, - ) - .unwrap_err(); + let error = + install_host(CodingAgent::Codex, &install_options, &runner, &setup_runner).unwrap_err(); assert!(error.contains("injected test failure"), "{error}"); assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "preserve"); @@ -3446,11 +3377,11 @@ fn first_install_cleans_generated_marketplace_after_state_write_failure() { let runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); crate::filesystem::fail_next_atomic_write(&layout.state_path); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &MockSetupRunner::default(), @@ -3468,7 +3399,7 @@ fn force_replacement_restoration_aggregates_independent_cleanup_failures() { let dir = tempdir().unwrap(); let install_file = dir.path().join("install-file"); std::fs::write(&install_file, "not a directory").unwrap(); - let layout = PluginLayout::new(IntegrationHost::Codex, &install_file); + let layout = PluginLayout::new(CodingAgent::Codex, &install_file); let original_marketplace_root = dir.path().join("original-marketplace"); let original_plugin_root = dir.path().join("original-plugin"); let mut snapshot = ForceInstallSnapshot { @@ -3499,7 +3430,7 @@ fn force_replacement_restoration_aggregates_independent_cleanup_failures() { }; let error = restore_force_replacement_after_error::<()>( - IntegrationHost::Codex, + CodingAgent::Codex, &layout, &mut snapshot, &options(&install_file), @@ -3525,7 +3456,7 @@ fn force_replacement_restoration_aggregates_independent_cleanup_failures() { #[test] fn force_replacement_restoration_reports_failed_host_reregistration() { let dir = tempdir().unwrap(); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let original_marketplace_root = dir.path().join("original-marketplace"); let mut snapshot = ForceInstallSnapshot { state_bytes: None, @@ -3554,7 +3485,7 @@ fn force_replacement_restoration_reports_failed_host_reregistration() { ]; let error = restore_force_replacement( - IntegrationHost::Codex, + CodingAgent::Codex, &layout, &mut snapshot, &options(dir.path()), @@ -3580,7 +3511,7 @@ fn force_replacement_moves_and_restores_a_separate_plugin_tree() { ) .unwrap(); std::fs::write(previous_plugin_root.join("plugin.json"), "plugin").unwrap(); - let target = PluginLayout::new(IntegrationHost::Codex, &dir.path().join("target")); + let target = PluginLayout::new(CodingAgent::Codex, &dir.path().join("target")); let preflight = PluginInstallPreflight { persisted: None, state_bytes: None, @@ -3596,7 +3527,7 @@ fn force_replacement_moves_and_restores_a_separate_plugin_tree() { let setup_runner = MockSetupRunner::default(); let runner = MockRunner::default().with_executable("codex", "/bin/codex"); let mut snapshot = begin_force_replacement( - IntegrationHost::Codex, + CodingAgent::Codex, &target, preflight, &options(dir.path()), @@ -3611,7 +3542,7 @@ fn force_replacement_moves_and_restores_a_separate_plugin_tree() { assert!(!previous_plugin_root.exists()); restore_force_replacement( - IntegrationHost::Codex, + CodingAgent::Codex, &target, &mut snapshot, &options(dir.path()), @@ -3645,13 +3576,13 @@ fn force_install_cleans_only_the_previous_setup_after_replacement_setup_failure( force: true, ..options(dir.path()) }; - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let sentinel = layout.plugin_root.join("previous-install"); std::fs::write(&sentinel, "preserve").unwrap(); let original_state = std::fs::read(&layout.state_path).unwrap(); - let error = install_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + let error = install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); assert!(error.contains("setup codex"), "{error}"); assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "preserve"); @@ -3680,11 +3611,11 @@ fn first_install_removes_a_partially_written_marketplace() { .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); crate::filesystem::fail_next_atomic_write(&layout.plugin_manifest); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -3702,13 +3633,13 @@ fn first_install_removes_a_partially_written_marketplace() { #[test] fn failed_staging_removes_a_new_external_generation_lock() { let dir = tempdir().unwrap(); - let target = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let target = PluginLayout::new(CodingAgent::Codex, dir.path()); let stage_parent = dir.path().join("deterministic-stage"); - let staged = PluginLayout::new(IntegrationHost::Codex, &stage_parent); + let staged = PluginLayout::new(CodingAgent::Codex, &stage_parent); crate::filesystem::fail_next_atomic_write(&staged.mcp_config); let error = match stage_plugin_marketplace_at( - IntegrationHost::Codex, + CodingAgent::Codex, Path::new("/bin/nemo-relay"), &target, true, @@ -3727,7 +3658,7 @@ fn failed_staging_removes_a_new_external_generation_lock() { #[test] fn failed_staging_preserves_a_preexisting_external_generation_lock() { let dir = tempdir().unwrap(); - let target = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let target = PluginLayout::new(CodingAgent::Codex, dir.path()); let orphan_marker = dir.path().join("orphan-generation"); crate::installation::generation::write_new_generation_with_token_at( &orphan_marker, @@ -3737,11 +3668,11 @@ fn failed_staging_preserves_a_preexisting_external_generation_lock() { std::fs::remove_file(orphan_marker).unwrap(); let original_lock = std::fs::read(&target.generation_lock).unwrap(); let stage_parent = dir.path().join("deterministic-existing-lock-stage"); - let staged = PluginLayout::new(IntegrationHost::Codex, &stage_parent); + let staged = PluginLayout::new(CodingAgent::Codex, &stage_parent); crate::filesystem::fail_next_atomic_write(&staged.mcp_config); let error = match stage_plugin_marketplace_at( - IntegrationHost::Codex, + CodingAgent::Codex, Path::new("/bin/nemo-relay"), &target, true, @@ -3766,15 +3697,15 @@ fn failed_staging_preserves_a_preexisting_dangling_generation_lock_symlink() { use std::os::unix::fs::symlink; let dir = tempdir().unwrap(); - let target = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let target = PluginLayout::new(CodingAgent::Codex, dir.path()); let symlink_target = dir.path().join("generation-lock-target"); symlink(&symlink_target, &target.generation_lock).unwrap(); let stage_parent = dir.path().join("deterministic-symlink-stage"); - let staged = PluginLayout::new(IntegrationHost::Codex, &stage_parent); + let staged = PluginLayout::new(CodingAgent::Codex, &stage_parent); crate::filesystem::fail_next_atomic_write(&staged.mcp_config); let error = match stage_plugin_marketplace_at( - IntegrationHost::Codex, + CodingAgent::Codex, Path::new("/bin/nemo-relay"), &target, true, @@ -3805,14 +3736,14 @@ fn install_claude_enables_provider_routing() { let setup_runner = MockSetupRunner::default(); install_host( - IntegrationHost::ClaudeCode, + CodingAgent::ClaudeCode, &options(dir.path()), &runner, &setup_runner, ) .unwrap(); - let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); assert_eq!( runner.commands(), vec![ @@ -3843,7 +3774,7 @@ fn install_claude_rejects_hosts_without_always_load_support_before_writing() { let setup_runner = MockSetupRunner::default(); let error = install_host( - IntegrationHost::ClaudeCode, + CodingAgent::ClaudeCode, &options(dir.path()), &runner, &setup_runner, @@ -3851,7 +3782,7 @@ fn install_claude_rejects_hosts_without_always_load_support_before_writing() { .unwrap_err(); assert!(error.contains("requires Claude Code 2.1.121"), "{error}"); - let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); assert!(!layout.marketplace_root.exists()); assert!(!layout.state_path.exists()); assert!(runner.commands().is_empty()); @@ -3865,7 +3796,7 @@ fn missing_relay_path_fails_before_generating_plugin() { let setup_runner = MockSetupRunner::default(); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -3874,7 +3805,7 @@ fn missing_relay_path_fails_before_generating_plugin() { assert!(error.contains("nemo-relay")); assert!( - !PluginLayout::new(IntegrationHost::Codex, dir.path()) + !PluginLayout::new(CodingAgent::Codex, dir.path()) .marketplace_root .exists() ); @@ -3890,7 +3821,7 @@ fn unsupported_relay_path_fails_before_generating_plugin() { let setup_runner = MockSetupRunner::default(); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -3899,7 +3830,7 @@ fn unsupported_relay_path_fails_before_generating_plugin() { assert!(error.contains("hook-forward")); assert!( - !PluginLayout::new(IntegrationHost::Codex, dir.path()) + !PluginLayout::new(CodingAgent::Codex, dir.path()) .marketplace_root .exists() ); @@ -3915,7 +3846,7 @@ fn relay_without_native_mcp_fails_codex_install_before_generating_plugin() { let setup_runner = MockSetupRunner::default(); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -3924,7 +3855,7 @@ fn relay_without_native_mcp_fails_codex_install_before_generating_plugin() { assert!(error.contains("native `nemo-relay mcp` support")); assert!( - !PluginLayout::new(IntegrationHost::Codex, dir.path()) + !PluginLayout::new(CodingAgent::Codex, dir.path()) .marketplace_root .exists() ); @@ -3942,7 +3873,7 @@ fn setup_failure_rolls_back_generated_files_and_registration() { }; let error = install_host( - IntegrationHost::ClaudeCode, + CodingAgent::ClaudeCode, &options(dir.path()), &runner, &setup_runner, @@ -3950,7 +3881,7 @@ fn setup_failure_rolls_back_generated_files_and_registration() { .unwrap_err(); assert!(error.contains("setup claude-code")); - let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); assert!(!layout.marketplace_root.exists()); assert!(!layout.generation_lock.exists()); assert!( @@ -3982,16 +3913,11 @@ fn doctor_failure_fails_install_and_rolls_back() { ..options(dir.path()) }; - let error = install_host( - IntegrationHost::ClaudeCode, - &options, - &runner, - &setup_runner, - ) - .unwrap_err(); + let error = + install_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); assert!(error.contains("doctor claude-code")); - let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); assert!(!layout.marketplace_root.exists()); assert!(!layout.generation_lock.exists()); } @@ -4007,7 +3933,7 @@ fn registration_failure_does_not_restore_plugin_setup_that_never_ran() { let install_dir = dir.path().join("failure"); let error = install_host( - IntegrationHost::ClaudeCode, + CodingAgent::ClaudeCode, &options(&install_dir), &runner, &setup_runner, @@ -4020,7 +3946,7 @@ fn registration_failure_does_not_restore_plugin_setup_that_never_ran() { "setup rollback should not run before setup was attempted" ); assert!( - !PluginLayout::new(IntegrationHost::ClaudeCode, &install_dir) + !PluginLayout::new(CodingAgent::ClaudeCode, &install_dir) .marketplace_root .exists() ); @@ -4034,10 +3960,10 @@ fn plugin_registration_failure_rolls_back_marketplace_without_plugin_removal() { .with_executable("codex", "/bin/codex"); runner.failing_suffix = Some("plugin add nemo-relay-plugin@nemo-relay-local".into()); let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -4069,7 +3995,7 @@ fn plugin_registration_failure_rolls_back_marketplace_without_plugin_removal() { #[test] fn failed_marketplace_registration_rolls_back_observed_host_side_effects() { let dir = tempdir().unwrap(); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let mut runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex") @@ -4078,7 +4004,7 @@ fn failed_marketplace_registration_rolls_back_observed_host_side_effects() { let setup_runner = MockSetupRunner::default(); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -4106,10 +4032,10 @@ fn failed_plugin_registration_rolls_back_observed_plugin_side_effects() { .with_codex_registration_sequence(&[(false, false), (true, true)]); runner.failing_suffix = Some("plugin add nemo-relay-plugin@nemo-relay-local".into()); let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -4131,7 +4057,7 @@ fn failed_plugin_registration_rolls_back_observed_plugin_side_effects() { #[test] fn unverifiable_registration_failure_preserves_the_install_tree() { let dir = tempdir().unwrap(); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let mut runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); @@ -4154,7 +4080,7 @@ fn unverifiable_registration_failure_preserves_the_install_tree() { let setup_runner = MockSetupRunner::default(); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -4182,11 +4108,11 @@ fn invalid_existing_state_fails_before_generating_marketplace() { .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); std::fs::create_dir_all(&layout.state_path).unwrap(); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -4213,7 +4139,7 @@ fn retry_after_partial_registration_rollback_does_not_restore_uninstalled_setup( let setup_runner = MockSetupRunner::default(); let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -4221,7 +4147,7 @@ fn retry_after_partial_registration_rollback_does_not_restore_uninstalled_setup( .unwrap_err(); assert!(error.contains("additionally failed to roll back install")); - let state = read_state(IntegrationHost::Codex, dir.path()).unwrap(); + let state = read_state(CodingAgent::Codex, dir.path()).unwrap(); assert!(state.host_plugin_removed); assert!(!state.host_marketplace_removed); assert!(!state.plugin_setup_installed); @@ -4230,7 +4156,7 @@ fn retry_after_partial_registration_rollback_does_not_restore_uninstalled_setup( .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); uninstall_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -4257,7 +4183,7 @@ fn retry_after_failed_codex_setup_does_not_uninstall_restored_setup() { }; let error = install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -4265,7 +4191,7 @@ fn retry_after_failed_codex_setup_does_not_uninstall_restored_setup() { .unwrap_err(); assert!(error.contains("additionally failed to roll back install")); - let state = read_state(IntegrationHost::Codex, dir.path()).unwrap(); + let state = read_state(CodingAgent::Codex, dir.path()).unwrap(); assert!(state.host_plugin_removed); assert!(!state.host_marketplace_removed); assert!(!state.plugin_setup_installed); @@ -4274,7 +4200,7 @@ fn retry_after_failed_codex_setup_does_not_uninstall_restored_setup() { .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); uninstall_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -4297,17 +4223,17 @@ fn uninstall_uses_installed_state_and_removes_marketplace() { .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, ) .unwrap(); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); assert!(layout.marketplace_root.exists()); uninstall_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -4336,12 +4262,12 @@ fn uninstall_rejects_registered_legacy_plugin_without_generation_fence() { .with_executable("codex", "/bin/codex") .with_codex_registration(true, true); let setup_runner = MockSetupRunner::default(); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); std::fs::remove_file(&layout.generation_fence).unwrap(); let error = uninstall_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -4367,12 +4293,12 @@ fn uninstall_rejects_unregistered_legacy_plugin_without_generation_fence() { .with_executable("codex", "/bin/codex") .with_codex_registration(false, false); let setup_runner = MockSetupRunner::default(); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); std::fs::remove_file(&layout.generation_fence).unwrap(); let error = uninstall_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -4402,12 +4328,12 @@ fn uninstall_rejects_each_corrupt_generation_marker_actionably() { .with_executable("codex", "/bin/codex") .with_codex_registration(false, false); let setup_runner = MockSetupRunner::default(); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); corrupt_generation_fence(&layout.generation_fence, corruption); let error = uninstall_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -4428,11 +4354,11 @@ fn uninstall_continues_when_relay_is_missing() { let dir = tempdir().unwrap(); let runner = MockRunner::default().with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); - write_installed_state(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); uninstall_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -4466,10 +4392,10 @@ fn doctor_json_uses_quiet_plugin_report() { ); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); let report = - doctor_host_json_value(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap(); + doctor_host_json_value(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap(); assert_eq!( setup_runner.calls(), @@ -4497,12 +4423,12 @@ fn doctor_uses_plugin_root_persisted_in_install_state() { .with_codex_registration(true, true); let setup_runner = MockSetupRunner::default(); let install_options = options(dir.path()); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let relocated_root = dir.path().join("relocated-plugin-root"); std::fs::rename(&layout.plugin_root, &relocated_root).unwrap(); write_state_for_host( - IntegrationHost::Codex, + CodingAgent::Codex, &PluginState { marketplace_root: layout.marketplace_root.clone(), plugin_root: relocated_root.clone(), @@ -4515,12 +4441,8 @@ fn doctor_uses_plugin_root_persisted_in_install_state() { ) .unwrap(); - let _readiness = collect_host_plugin_readiness( - IntegrationHost::Codex, - &install_options, - &runner, - &setup_runner, - ); + let _readiness = + collect_host_plugin_readiness(CodingAgent::Codex, &install_options, &runner, &setup_runner); assert_eq!(setup_runner.doctor_roots(), vec![relocated_root]); } @@ -4539,11 +4461,10 @@ fn codex_doctor_reports_upgrade_remediation_for_old_and_malformed_versions() { .with_capture_output("/bin/codex --version", version_output); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); let report = - doctor_host_json_value(IntegrationHost::Codex, &options, &runner, &setup_runner) - .unwrap(); + doctor_host_json_value(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap(); let version_check = report["readiness_checks"] .as_array() .unwrap() @@ -4566,7 +4487,7 @@ fn codex_doctor_reports_upgrade_remediation_for_old_and_malformed_versions() { ); let text_error = - doctor_host(IntegrationHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + doctor_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); assert!(text_error.contains("remediation: upgrade to codex-cli")); assert!(text_error.contains("codex-cli 0.143.0 or newer")); } @@ -4586,15 +4507,11 @@ fn claude_doctor_reports_upgrade_remediation_for_old_and_malformed_versions() { .with_capture_output("/bin/claude --version", version_output); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::ClaudeCode, dir.path()); + write_installed_state(CodingAgent::ClaudeCode, dir.path()); - let report = doctor_host_json_value( - IntegrationHost::ClaudeCode, - &options, - &runner, - &setup_runner, - ) - .unwrap(); + let report = + doctor_host_json_value(CodingAgent::ClaudeCode, &options, &runner, &setup_runner) + .unwrap(); let version_check = report["readiness_checks"] .as_array() .unwrap() @@ -4616,13 +4533,8 @@ fn claude_doctor_reports_upgrade_remediation_for_old_and_malformed_versions() { ) ); - let text_error = doctor_host( - IntegrationHost::ClaudeCode, - &options, - &runner, - &setup_runner, - ) - .unwrap_err(); + let text_error = + doctor_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); assert!(text_error.contains("remediation: upgrade to Claude Code")); assert!(text_error.contains("upgrade to Claude Code 2.1.121 or newer")); } @@ -4644,12 +4556,12 @@ fn readiness_report_marks_missing_generated_plugin_files_as_failed() { ); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); std::fs::remove_file(layout.plugin_manifest).unwrap(); let report = - collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); assert!(!report.ok()); assert!(report.checks.iter().any(|check| { @@ -4669,12 +4581,12 @@ fn readiness_report_rejects_missing_generated_mcp_server() { .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); std::fs::remove_file(layout.mcp_config).unwrap(); let report = - collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); assert!(!report.ok()); assert!(report.checks.iter().any(|check| { @@ -4690,12 +4602,12 @@ fn readiness_report_rejects_missing_mcp_generation_fence() { .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); std::fs::remove_file(layout.generation_fence).unwrap(); let report = - collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); assert!(!report.ok()); assert!(report.checks.iter().any(|check| { @@ -4714,17 +4626,13 @@ fn claude_readiness_requires_its_mcp_server_and_generation_fence() { .with_claude_registration(true, true); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::ClaudeCode, dir.path()); - let layout = PluginLayout::new(IntegrationHost::ClaudeCode, dir.path()); + write_installed_state(CodingAgent::ClaudeCode, dir.path()); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); std::fs::remove_file(layout.mcp_config).unwrap(); std::fs::remove_file(layout.generation_fence).unwrap(); - let report = collect_host_plugin_readiness( - IntegrationHost::ClaudeCode, - &options, - &runner, - &setup_runner, - ); + let report = + collect_host_plugin_readiness(CodingAgent::ClaudeCode, &options, &runner, &setup_runner); assert!(!report.ok()); for name in ["Generated MCP server", "MCP generation fence"] { @@ -4746,13 +4654,13 @@ fn readiness_report_rejects_mcp_server_for_different_binary() { .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let generation = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); write_json( &layout.mcp_config, &plugin_mcp_config( - IntegrationHost::Codex, + CodingAgent::Codex, Path::new("/tmp/other-relay"), &layout.generation_fence, generation.token(), @@ -4762,7 +4670,7 @@ fn readiness_report_rejects_mcp_server_for_different_binary() { .unwrap(); let report = - collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); assert!(!report.ok()); assert!(report.checks.iter().any(|check| { @@ -4778,8 +4686,8 @@ fn readiness_report_rejects_a_stale_mcp_generation_identity() { .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let mut mcp = serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) .unwrap(); @@ -4787,7 +4695,7 @@ fn readiness_report_rejects_a_stale_mcp_generation_identity() { write_json(&layout.mcp_config, &mcp).unwrap(); let report = - collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); assert!(!report.ok()); let check = report @@ -4818,7 +4726,7 @@ fn generated_codex_mcp_check_allows_previously_captured_environment_names() { .push(json!("NEMO_RELAY_PREVIOUSLY_DEFINED")); write_json(&path, &installed).unwrap(); - let result = generated_mcp_config_check(IntegrationHost::Codex, &path, &expected); + let result = generated_mcp_config_check(CodingAgent::Codex, &path, &expected); assert_eq!(result.unwrap(), format!("valid at {}", path.display())); } @@ -4844,7 +4752,7 @@ fn generated_codex_mcp_check_accepts_a_windows_allowlist_with_a_historical_name( write_json(&path, &installed).unwrap(); let result = - generated_mcp_config_check_for_platform(IntegrationHost::Codex, &path, &expected, true); + generated_mcp_config_check_for_platform(CodingAgent::Codex, &path, &expected, true); assert_eq!(result.unwrap(), format!("valid at {}", path.display())); } @@ -4874,7 +4782,7 @@ fn generated_codex_mcp_check_rejects_malformed_or_unapproved_environment_superse .push(invalid); write_json(&path, &installed).unwrap(); - let error = generated_mcp_config_check(IntegrationHost::Codex, &path, &expected) + let error = generated_mcp_config_check(CodingAgent::Codex, &path, &expected) .expect_err("invalid environment superset passed doctor validation"); assert!(error.contains("unexpected MCP server manifest"), "{error}"); assert!( @@ -4899,8 +4807,7 @@ fn generated_mcp_check_rejects_host_shape_and_non_environment_drift() { let mut wrong_command = expected.clone(); wrong_command["nemo-relay"]["command"] = json!("/bin/foreign-relay"); write_json(&path, &wrong_command).unwrap(); - let error = - generated_mcp_config_check(IntegrationHost::ClaudeCode, &path, &expected).unwrap_err(); + let error = generated_mcp_config_check(CodingAgent::ClaudeCode, &path, &expected).unwrap_err(); assert!(error.contains("install claude-code --force"), "{error}"); let expected_without_vars = json!({ @@ -4910,8 +4817,8 @@ fn generated_mcp_check_rejects_host_shape_and_non_environment_drift() { } }); write_json(&path, &wrong_command).unwrap(); - let error = generated_mcp_config_check(IntegrationHost::Codex, &path, &expected_without_vars) - .unwrap_err(); + let error = + generated_mcp_config_check(CodingAgent::Codex, &path, &expected_without_vars).unwrap_err(); assert!(error.contains("unexpected MCP server manifest"), "{error}"); let mut actual_without_vars = expected.clone(); @@ -4920,11 +4827,11 @@ fn generated_mcp_check_rejects_host_shape_and_non_environment_drift() { .unwrap() .remove("env_vars"); write_json(&path, &actual_without_vars).unwrap(); - let error = generated_mcp_config_check(IntegrationHost::Codex, &path, &expected).unwrap_err(); + let error = generated_mcp_config_check(CodingAgent::Codex, &path, &expected).unwrap_err(); assert!(error.contains("unexpected MCP server manifest"), "{error}"); write_json(&path, &wrong_command).unwrap(); - let error = generated_mcp_config_check(IntegrationHost::Codex, &path, &expected).unwrap_err(); + let error = generated_mcp_config_check(CodingAgent::Codex, &path, &expected).unwrap_err(); assert!(error.contains("unexpected MCP server manifest"), "{error}"); assert!(error.contains("install codex --force"), "{error}"); } @@ -4934,11 +4841,11 @@ fn legacy_claude_manifest_inspection_distinguishes_absent_unreadable_and_malform let dir = tempdir().unwrap(); let plugin_root = dir.path().join("plugin"); std::fs::create_dir_all(&plugin_root).unwrap(); - assert!(!legacy_plugin_without_mcp(IntegrationHost::ClaudeCode, &plugin_root).unwrap()); + assert!(!legacy_plugin_without_mcp(CodingAgent::ClaudeCode, &plugin_root).unwrap()); - let manifest = plugin_manifest_path(IntegrationHost::ClaudeCode, &plugin_root); + let manifest = plugin_manifest_path(CodingAgent::ClaudeCode, &plugin_root); std::fs::create_dir_all(&manifest).unwrap(); - let error = legacy_plugin_without_mcp(IntegrationHost::ClaudeCode, &plugin_root).unwrap_err(); + let error = legacy_plugin_without_mcp(CodingAgent::ClaudeCode, &plugin_root).unwrap_err(); assert!( error.contains("failed to inspect legacy plugin manifest"), "{error}" @@ -4946,16 +4853,16 @@ fn legacy_claude_manifest_inspection_distinguishes_absent_unreadable_and_malform std::fs::remove_dir(&manifest).unwrap(); std::fs::write(&manifest, "{not-json").unwrap(); - let error = legacy_plugin_without_mcp(IntegrationHost::ClaudeCode, &plugin_root).unwrap_err(); + let error = legacy_plugin_without_mcp(CodingAgent::ClaudeCode, &plugin_root).unwrap_err(); assert!( error.contains("failed to inspect legacy plugin manifest"), "{error}" ); std::fs::write(&manifest, r#"{"name":"legacy"}"#).unwrap(); - assert!(legacy_plugin_without_mcp(IntegrationHost::ClaudeCode, &plugin_root).unwrap()); + assert!(legacy_plugin_without_mcp(CodingAgent::ClaudeCode, &plugin_root).unwrap()); std::fs::write(&manifest, r#"{"mcpServers":{}}"#).unwrap(); - assert!(!legacy_plugin_without_mcp(IntegrationHost::ClaudeCode, &plugin_root).unwrap()); + assert!(!legacy_plugin_without_mcp(CodingAgent::ClaudeCode, &plugin_root).unwrap()); } #[test] @@ -4966,8 +4873,8 @@ fn readiness_report_names_newly_required_mcp_env_vars_and_force_remediation() { .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let mut mcp: Value = serde_json::from_str(&std::fs::read_to_string(&layout.mcp_config).unwrap()).unwrap(); mcp["nemo-relay"]["env_vars"] @@ -4977,7 +4884,7 @@ fn readiness_report_names_newly_required_mcp_env_vars_and_force_remediation() { write_json(&layout.mcp_config, &mcp).unwrap(); let report = - collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); assert!(!report.ok()); let check = report @@ -5006,8 +4913,8 @@ fn readiness_report_rejects_invalid_generated_manifest_contents() { ); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); std::fs::write( &layout.marketplace_manifest, r#"{"name":"wrong-marketplace"}"#, @@ -5015,7 +4922,7 @@ fn readiness_report_rejects_invalid_generated_manifest_contents() { .unwrap(); let report = - collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); assert!(!report.ok()); assert!(report.checks.iter().any(|check| { @@ -5039,9 +4946,9 @@ fn readiness_report_accepts_generated_plugin_manifest_from_an_older_version() { ); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); - let mut manifest = plugin_manifest(IntegrationHost::Codex); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let mut manifest = plugin_manifest(CodingAgent::Codex); manifest["version"] = json!("0.0.0"); std::fs::write( &layout.plugin_manifest, @@ -5050,7 +4957,7 @@ fn readiness_report_accepts_generated_plugin_manifest_from_an_older_version() { .unwrap(); let report = - collect_host_plugin_readiness(IntegrationHost::Codex, &options, &runner, &setup_runner); + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); assert!(report.ok()); assert!( @@ -5066,10 +4973,10 @@ fn doctor_json_preserves_unknown_host_registration_state() { let dir = tempdir().unwrap(); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); let report = doctor_host_json_value( - IntegrationHost::Codex, + CodingAgent::Codex, &options, &MockRunner::default(), &setup_runner, @@ -5089,7 +4996,7 @@ fn timed_out_host_plugin_readiness_is_actionable() { let report = receive_host_plugin_readiness( PendingHostPluginReadiness { - host: IntegrationHost::Codex, + host: CodingAgent::Codex, state_path: state_path.clone(), receiver, }, @@ -5172,15 +5079,9 @@ fn doctor_validates_claude_host_registration_before_setup_doctor() { ); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::ClaudeCode, dir.path()); + write_installed_state(CodingAgent::ClaudeCode, dir.path()); - doctor_host( - IntegrationHost::ClaudeCode, - &options, - &runner, - &setup_runner, - ) - .unwrap(); + doctor_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap(); assert_eq!( setup_runner.calls(), @@ -5212,15 +5113,9 @@ fn doctor_fails_when_claude_host_plugin_is_missing() { ); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::ClaudeCode, dir.path()); + write_installed_state(CodingAgent::ClaudeCode, dir.path()); - let error = doctor_host( - IntegrationHost::ClaudeCode, - &options, - &runner, - &setup_runner, - ) - .unwrap_err(); + let error = doctor_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); assert!(error.contains("nemo-relay install claude-code --force")); assert_eq!( @@ -5248,15 +5143,9 @@ fn doctor_fails_when_claude_host_marketplace_is_missing() { ); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); - write_installed_state(IntegrationHost::ClaudeCode, dir.path()); + write_installed_state(CodingAgent::ClaudeCode, dir.path()); - let error = doctor_host( - IntegrationHost::ClaudeCode, - &options, - &runner, - &setup_runner, - ) - .unwrap_err(); + let error = doctor_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); assert!(error.contains("nemo-relay install claude-code --force")); assert_eq!( @@ -5275,7 +5164,7 @@ fn uninstall_cleans_up_plugin_setup_before_host_removal_failure() { let setup_runner = MockSetupRunner::default(); let error = uninstall_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -5301,11 +5190,11 @@ fn force_install_recovers_from_a_generation_retired_by_partial_uninstall() { .with_codex_registration(true, true); failing_runner.failing_suffix = Some("plugin remove nemo-relay-plugin@nemo-relay-local".into()); let setup_runner = MockSetupRunner::default(); - write_installed_state(IntegrationHost::Codex, dir.path()); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); let error = uninstall_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &failing_runner, &setup_runner, @@ -5323,7 +5212,7 @@ fn force_install_recovers_from_a_generation_retired_by_partial_uninstall() { .with_executable("codex", "/bin/codex") .with_codex_registration(true, true); install_host( - IntegrationHost::Codex, + CodingAgent::Codex, &PluginInstallOptions { force: true, ..options(dir.path()) @@ -5347,10 +5236,10 @@ fn uninstall_does_not_unregister_host_when_plugin_cleanup_fails() { failing_call: Some(format!("uninstall codex {DEFAULT_GATEWAY_URL}")), ..MockSetupRunner::default() }; - write_installed_state(IntegrationHost::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); let error = uninstall_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -5358,7 +5247,7 @@ fn uninstall_does_not_unregister_host_when_plugin_cleanup_fails() { .unwrap_err(); assert!(error.contains("uninstall codex")); - let state = read_state(IntegrationHost::Codex, dir.path()).unwrap(); + let state = read_state(CodingAgent::Codex, dir.path()).unwrap(); assert!(!state.host_plugin_removed); assert!(!state.host_marketplace_removed); assert!(state.plugin_setup_installed); @@ -5370,9 +5259,9 @@ fn uninstall_retry_skips_host_removal_after_prior_success() { let mut runner = MockRunner::default().with_executable("nemo-relay", "/bin/nemo-relay"); runner.failing_suffix = Some("plugin remove nemo-relay-plugin@nemo-relay-local".into()); let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); write_state_for_host( - IntegrationHost::Codex, + CodingAgent::Codex, &PluginState { marketplace_root: layout.marketplace_root.clone(), plugin_root: layout.plugin_root.clone(), @@ -5387,7 +5276,7 @@ fn uninstall_retry_skips_host_removal_after_prior_success() { crate::installation::generation::write_new_generation(&layout.generation_fence).unwrap(); uninstall_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -5417,12 +5306,12 @@ fn uninstall_retry_skips_plugin_removal_after_marketplace_failure() { .with_executable("codex", "/bin/codex"); runner.failing_suffix = Some("plugin marketplace remove nemo-relay-local".into()); let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(IntegrationHost::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); write_state(&layout, &options(dir.path())).unwrap(); crate::installation::generation::write_new_generation(&layout.generation_fence).unwrap(); let error = uninstall_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, @@ -5430,7 +5319,7 @@ fn uninstall_retry_skips_plugin_removal_after_marketplace_failure() { .unwrap_err(); assert!(error.contains("plugin marketplace remove")); - let state = read_state(IntegrationHost::Codex, dir.path()).unwrap(); + let state = read_state(CodingAgent::Codex, dir.path()).unwrap(); assert!(state.host_plugin_removed); assert!(!state.host_marketplace_removed); @@ -5438,7 +5327,7 @@ fn uninstall_retry_skips_plugin_removal_after_marketplace_failure() { .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); uninstall_host( - IntegrationHost::Codex, + CodingAgent::Codex, &options(dir.path()), &runner, &setup_runner, From 893173329f6d402eaf581f3de248baab95d8a1b1 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 22:34:14 -0400 Subject: [PATCH 042/102] refactor(cli): move install assets into agent slices Signed-off-by: Will Killian --- crates/cli/src/agents/claude/assets.rs | 42 ++++++ crates/cli/src/agents/claude/mod.rs | 1 + crates/cli/src/agents/codex/assets.rs | 68 +++++++++ crates/cli/src/agents/codex/mod.rs | 1 + crates/cli/src/agents/install/marketplace.rs | 129 +----------------- crates/cli/src/agents/install/mod.rs | 7 +- crates/cli/src/agents/mod.rs | 39 ++++++ crates/cli/src/installation/mod.rs | 1 + .../operation_lock.rs | 33 ++--- .../coverage/agents/plugin_install_tests.rs | 2 +- 10 files changed, 174 insertions(+), 149 deletions(-) create mode 100644 crates/cli/src/agents/claude/assets.rs create mode 100644 crates/cli/src/agents/codex/assets.rs rename crates/cli/src/{agents/install => installation}/operation_lock.rs (72%) diff --git a/crates/cli/src/agents/claude/assets.rs b/crates/cli/src/agents/claude/assets.rs new file mode 100644 index 000000000..377845617 --- /dev/null +++ b/crates/cli/src/agents/claude/assets.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use serde_json::{Value, json}; + +use crate::mcp::SERVER_NAME; + +pub(crate) fn marketplace_manifest(marketplace: &str, plugin: &str) -> Value { + json!({ + "name": marketplace, + "metadata": { "description": "Local NeMo Relay plugins for Claude Code." }, + "owner": { "name": "NVIDIA Corporation and Affiliates", "email": "noreply@nvidia.com" }, + "plugins": [{ + "name": plugin, + "description": "Run the shared native Relay gateway and capture Claude Code lifecycle events.", + "source": "./plugins/nemo-relay-plugin", + "category": "development" + }] + }) +} + +pub(crate) fn plugin_manifest(plugin: &str) -> Value { + json!({ + "name": plugin, + "version": env!("CARGO_PKG_VERSION"), + "description": "Native Relay gateway lifecycle and Claude Code hooks for complete local observability.", + "author": { "name": "NVIDIA Corporation and Affiliates", "url": "https://github.com/NVIDIA/NeMo-Relay" }, + "homepage": "https://github.com/NVIDIA/NeMo-Relay", + "repository": "https://github.com/NVIDIA/NeMo-Relay", + "license": "Apache-2.0", + "keywords": ["nemo-relay", "claude-code", "hooks", "observability"], + "mcpServers": "./.mcp.json" + }) +} + +pub(crate) fn mcp_config(mut server: Value) -> Value { + server + .as_object_mut() + .expect("persistent MCP server is a JSON object") + .insert("alwaysLoad".into(), json!(true)); + json!({ "mcpServers": { (SERVER_NAME): server } }) +} diff --git a/crates/cli/src/agents/claude/mod.rs b/crates/cli/src/agents/claude/mod.rs index cf1bb6338..8eaa5f938 100644 --- a/crates/cli/src/agents/claude/mod.rs +++ b/crates/cli/src/agents/claude/mod.rs @@ -5,6 +5,7 @@ use semver::Version; use super::AgentDescriptor; +pub(super) mod assets; pub(super) mod host; pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { diff --git a/crates/cli/src/agents/codex/assets.rs b/crates/cli/src/agents/codex/assets.rs new file mode 100644 index 000000000..a66412e56 --- /dev/null +++ b/crates/cli/src/agents/codex/assets.rs @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use serde_json::{Value, json}; + +use crate::mcp::SERVER_NAME; + +pub(crate) fn marketplace_manifest(marketplace: &str, plugin: &str) -> Value { + json!({ + "name": marketplace, + "interface": { "displayName": "NeMo Relay Local" }, + "plugins": [{ + "name": plugin, + "source": { "source": "local", "path": "./plugins/nemo-relay-plugin" }, + "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, + "category": "Coding" + }] + }) +} + +pub(crate) fn plugin_manifest(plugin: &str) -> Value { + json!({ + "name": plugin, + "version": env!("CARGO_PKG_VERSION"), + "description": "Native Relay gateway lifecycle and Codex hooks for complete local observability.", + "author": { "name": "NVIDIA Corporation and Affiliates", "url": "https://github.com/NVIDIA/NeMo-Relay" }, + "homepage": "https://github.com/NVIDIA/NeMo-Relay", + "repository": "https://github.com/NVIDIA/NeMo-Relay", + "license": "Apache-2.0", + "keywords": ["nemo-relay", "codex", "hooks", "observability"], + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "NeMo Relay Plugin", + "shortDescription": "Run the native Relay gateway and capture Codex lifecycle events.", + "longDescription": "Starts the native nemo-relay gateway through a required lifecycle-bound MCP server, routes model traffic through it, and installs command hooks that preserve canonical Codex lifecycle payloads.", + "developerName": "NVIDIA", + "category": "Coding", + "capabilities": ["Read"], + "defaultPrompt": ["Capture this Codex session with NeMo Relay observability."], + "websiteURL": "https://github.com/NVIDIA/NeMo-Relay", + "brandColor": "#76B900" + } + }) +} + +pub(crate) fn mcp_config(mut server: Value) -> Result { + let fields = server + .as_object_mut() + .expect("persistent MCP server is a JSON object"); + fields.insert("env_vars".into(), json!(mcp_env_vars()?)); + fields.insert("required".into(), json!(true)); + fields.insert("startup_timeout_sec".into(), json!(20)); + Ok(json!({ (SERVER_NAME): server })) +} + +pub(crate) fn mcp_env_vars() -> Result, String> { + let environment = std::env::vars_os().filter_map(|(name, _)| name.into_string().ok()); + let config = + crate::configuration::user_plugin_runtime_config().map_err(|error| error.to_string())?; + Ok(mcp_env_vars_from(environment, config.as_ref())) +} + +pub(crate) fn mcp_env_vars_from( + environment: impl IntoIterator, + config: Option<&Value>, +) -> Vec { + crate::mcp_environment::forwarded_names(environment, config) +} diff --git a/crates/cli/src/agents/codex/mod.rs b/crates/cli/src/agents/codex/mod.rs index e983d7c0e..144fcd3c3 100644 --- a/crates/cli/src/agents/codex/mod.rs +++ b/crates/cli/src/agents/codex/mod.rs @@ -6,6 +6,7 @@ use semver::Version; use super::AgentDescriptor; pub(super) mod app_server; +pub(super) mod assets; pub(super) mod host; pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { diff --git a/crates/cli/src/agents/install/marketplace.rs b/crates/cli/src/agents/install/marketplace.rs index 964bfba0d..b2ff3e056 100644 --- a/crates/cli/src/agents/install/marketplace.rs +++ b/crates/cli/src/agents/install/marketplace.rs @@ -7,7 +7,7 @@ use std::env; use std::fs; use std::path::Path; -use serde_json::{Value, json}; +use serde_json::Value; use crate::agents::CodingAgent; use crate::hooks::generated_hooks; @@ -81,96 +81,11 @@ pub(super) fn write_plugin_marketplace_for_generation( } pub(super) fn marketplace_manifest(host: CodingAgent) -> Value { - match host { - CodingAgent::Codex => json!({ - "name": MARKETPLACE_NAME, - "interface": { - "displayName": "NeMo Relay Local" - }, - "plugins": [{ - "name": PLUGIN_NAME, - "source": { - "source": "local", - "path": "./plugins/nemo-relay-plugin" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "Coding" - }] - }), - CodingAgent::ClaudeCode => json!({ - "name": MARKETPLACE_NAME, - "metadata": { - "description": "Local NeMo Relay plugins for Claude Code." - }, - "owner": { - "name": "NVIDIA Corporation and Affiliates", - "email": "noreply@nvidia.com" - }, - "plugins": [{ - "name": PLUGIN_NAME, - "description": "Run the shared native Relay gateway and capture Claude Code lifecycle events.", - "source": "./plugins/nemo-relay-plugin", - "category": "development" - }] - }), - CodingAgent::Hermes => { - unreachable!("all is expanded before manifest generation") - } - } + crate::agents::marketplace_manifest(host, MARKETPLACE_NAME, PLUGIN_NAME) } pub(super) fn plugin_manifest(host: CodingAgent) -> Value { - let description = match host { - CodingAgent::Codex => { - "Native Relay gateway lifecycle and Codex hooks for complete local observability." - } - CodingAgent::ClaudeCode => { - "Native Relay gateway lifecycle and Claude Code hooks for complete local observability." - } - CodingAgent::Hermes => { - unreachable!("all is expanded before manifest generation") - } - }; - let keywords = match host { - CodingAgent::Codex => json!(["nemo-relay", "codex", "hooks", "observability"]), - CodingAgent::ClaudeCode => { - json!(["nemo-relay", "claude-code", "hooks", "observability"]) - } - CodingAgent::Hermes => { - unreachable!("all is expanded before manifest generation") - } - }; - let mut manifest = json!({ - "name": PLUGIN_NAME, - "version": env!("CARGO_PKG_VERSION"), - "description": description, - "author": { - "name": "NVIDIA Corporation and Affiliates", - "url": "https://github.com/NVIDIA/NeMo-Relay" - }, - "homepage": "https://github.com/NVIDIA/NeMo-Relay", - "repository": "https://github.com/NVIDIA/NeMo-Relay", - "license": "Apache-2.0", - "keywords": keywords - }); - manifest["mcpServers"] = json!("./.mcp.json"); - if matches!(host, CodingAgent::Codex) { - manifest["interface"] = json!({ - "displayName": "NeMo Relay Plugin", - "shortDescription": "Run the native Relay gateway and capture Codex lifecycle events.", - "longDescription": "Starts the native nemo-relay gateway through a required lifecycle-bound MCP server, routes model traffic through it, and installs command hooks that preserve canonical Codex lifecycle payloads.", - "developerName": "NVIDIA", - "category": "Coding", - "capabilities": ["Read"], - "defaultPrompt": ["Capture this Codex session with NeMo Relay observability."], - "websiteURL": "https://github.com/NVIDIA/NeMo-Relay", - "brandColor": "#76B900" - }); - } - manifest + crate::agents::plugin_manifest(host, PLUGIN_NAME) } pub(super) fn plugin_mcp_config( @@ -180,32 +95,8 @@ pub(super) fn plugin_mcp_config( generation_token: &str, ) -> Result { let generation_fence = absolute_or_self(generation_fence); - let mut server = crate::mcp::persistent_server(relay, &generation_fence, generation_token); - let fields = server - .as_object_mut() - .expect("persistent MCP server is a JSON object"); - match host { - CodingAgent::Codex => { - fields.insert("env_vars".into(), json!(plugin_mcp_env_vars()?)); - fields.insert("required".into(), json!(true)); - fields.insert("startup_timeout_sec".into(), json!(20)); - } - CodingAgent::ClaudeCode => { - fields.insert("alwaysLoad".into(), json!(true)); - } - CodingAgent::Hermes => { - unreachable!("all is expanded before MCP generation") - } - } - Ok(match host { - CodingAgent::Codex => json!({ (crate::mcp::SERVER_NAME): server }), - CodingAgent::ClaudeCode => { - json!({ "mcpServers": { (crate::mcp::SERVER_NAME): server } }) - } - CodingAgent::Hermes => { - unreachable!("all is expanded before MCP generation") - } - }) + let server = crate::mcp::persistent_server(relay, &generation_fence, generation_token); + crate::agents::plugin_mcp_config(host, server) } fn absolute_or_self(path: &Path) -> std::path::PathBuf { @@ -236,16 +127,10 @@ pub(super) fn plugin_hooks( )) } -pub(super) fn plugin_mcp_env_vars() -> Result, String> { - let environment = env::vars_os().filter_map(|(name, _)| name.into_string().ok()); - let config = - crate::configuration::user_plugin_runtime_config().map_err(|error| error.to_string())?; - Ok(plugin_mcp_env_vars_from(environment, config.as_ref())) -} - +#[cfg(test)] pub(super) fn plugin_mcp_env_vars_from( environment: impl IntoIterator, config: Option<&Value>, ) -> Vec { - crate::mcp_environment::forwarded_names(environment, config) + crate::agents::codex_mcp_env_vars_from(environment, config) } diff --git a/crates/cli/src/agents/install/mod.rs b/crates/cli/src/agents/install/mod.rs index c6fd247e2..9713f6833 100644 --- a/crates/cli/src/agents/install/mod.rs +++ b/crates/cli/src/agents/install/mod.rs @@ -5,7 +5,6 @@ mod host; mod marketplace; -mod operation_lock; mod setup; mod state; @@ -25,6 +24,7 @@ use crate::installation::generation::{ }; use crate::installation::{InstallRequest, UninstallRequest}; +use crate::installation::operation_lock::{DEFAULT_OPERATION_LOCK_TIMEOUT, PluginOperationLock}; use host::{ CommandRunner, RealCommandRunner, host_registration_report, require_host_cli, require_relay, run_host_marketplace_registration, run_host_marketplace_removal, run_host_plugin_registration, @@ -34,7 +34,6 @@ use marketplace::{ marketplace_manifest, plugin_hooks, plugin_manifest, plugin_mcp_config, write_plugin_marketplace, write_plugin_marketplace_for_generation, }; -use operation_lock::{DEFAULT_OPERATION_LOCK_TIMEOUT, PluginOperationLock}; use setup::{ PluginSetupRunner, PluginSetupSnapshot, RealPluginSetupRunner, run_plugin_doctor_json, run_plugin_doctor_with_generation, run_plugin_setup_with_generation, run_plugin_uninstall, @@ -628,7 +627,7 @@ fn install_host_with_operation_timeout( let _operation_lock = (!options.dry_run) .then(|| { PluginOperationLock::acquire( - host, + host.install_arg(), &options.operation_lock_dir, &options.install_dir, lock_timeout, @@ -979,7 +978,7 @@ fn uninstall_host_with_operation_timeout( let _operation_lock = (!options.dry_run) .then(|| { PluginOperationLock::acquire( - host, + host.install_arg(), &options.operation_lock_dir, &options.install_dir, lock_timeout, diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 43ae30cc3..d8646e3b3 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -146,6 +146,45 @@ impl CodingAgent { } } +pub(crate) fn marketplace_manifest( + agent: CodingAgent, + marketplace: &str, + plugin: &str, +) -> serde_json::Value { + match agent { + CodingAgent::Codex => codex::assets::marketplace_manifest(marketplace, plugin), + CodingAgent::ClaudeCode => claude::assets::marketplace_manifest(marketplace, plugin), + CodingAgent::Hermes => unreachable!("Hermes does not install a marketplace plugin"), + } +} + +pub(crate) fn plugin_manifest(agent: CodingAgent, plugin: &str) -> serde_json::Value { + match agent { + CodingAgent::Codex => codex::assets::plugin_manifest(plugin), + CodingAgent::ClaudeCode => claude::assets::plugin_manifest(plugin), + CodingAgent::Hermes => unreachable!("Hermes does not install a marketplace plugin"), + } +} + +pub(crate) fn plugin_mcp_config( + agent: CodingAgent, + server: serde_json::Value, +) -> Result { + match agent { + CodingAgent::Codex => codex::assets::mcp_config(server), + CodingAgent::ClaudeCode => Ok(claude::assets::mcp_config(server)), + CodingAgent::Hermes => unreachable!("Hermes does not install a marketplace plugin"), + } +} + +#[cfg(test)] +pub(crate) fn codex_mcp_env_vars_from( + environment: impl IntoIterator, + config: Option<&serde_json::Value>, +) -> Vec { + codex::assets::mcp_env_vars_from(environment, config) +} + pub(crate) use claude::host::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; pub(crate) use codex::host::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; pub(crate) use shared::host::portable_executable_path; diff --git a/crates/cli/src/installation/mod.rs b/crates/cli/src/installation/mod.rs index 8da04c8a6..f55e2bafc 100644 --- a/crates/cli/src/installation/mod.rs +++ b/crates/cli/src/installation/mod.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; pub(crate) mod generation; +pub(crate) mod operation_lock; #[derive(Debug, Clone)] pub(crate) struct InstallRequest { diff --git a/crates/cli/src/agents/install/operation_lock.rs b/crates/cli/src/installation/operation_lock.rs similarity index 72% rename from crates/cli/src/agents/install/operation_lock.rs rename to crates/cli/src/installation/operation_lock.rs index ef30ee737..67dd8b273 100644 --- a/crates/cli/src/agents/install/operation_lock.rs +++ b/crates/cli/src/installation/operation_lock.rs @@ -8,27 +8,26 @@ use std::path::{Path, PathBuf}; use std::thread; use std::time::{Duration, Instant}; -use crate::agents::CodingAgent; use crate::filesystem::{LockAttempt, try_lock_exclusive}; -pub(super) const DEFAULT_OPERATION_LOCK_TIMEOUT: Duration = Duration::from_secs(5); +pub(crate) const DEFAULT_OPERATION_LOCK_TIMEOUT: Duration = Duration::from_secs(5); const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25); -pub(super) struct PluginOperationLock { +pub(crate) struct PluginOperationLock { _global_file: File, _root_file: File, } impl PluginOperationLock { - pub(super) fn acquire( - host: CodingAgent, + pub(crate) fn acquire( + installation_key: &str, global_lock_dir: &Path, install_dir: &Path, timeout: Duration, ) -> Result { let deadline = Instant::now() + timeout; - let global_file = acquire_lock_file(host, global_lock_dir, deadline, "global")?; - let root_file = acquire_lock_file(host, install_dir, deadline, "install-root")?; + let global_file = acquire_lock_file(installation_key, global_lock_dir, deadline, "global")?; + let root_file = acquire_lock_file(installation_key, install_dir, deadline, "install-root")?; Ok(Self { _global_file: global_file, _root_file: root_file, @@ -37,7 +36,7 @@ impl PluginOperationLock { } fn acquire_lock_file( - host: CodingAgent, + installation_key: &str, directory: &Path, deadline: Instant, scope: &str, @@ -48,7 +47,7 @@ fn acquire_lock_file( directory.display() ) })?; - let path = operation_lock_path(host, directory); + let path = operation_lock_path(installation_key, directory); let mut options = OpenOptions::new(); options.create(true).truncate(false).read(true).write(true); #[cfg(unix)] @@ -69,7 +68,7 @@ fn acquire_lock_file( if Instant::now() >= deadline { return Err(format!( "timed out waiting for another {} plugin install or uninstall operation on the {scope} lock at {}; wait for it to finish and retry", - host_name(host), + installation_key, directory.display() )); } @@ -87,16 +86,6 @@ fn acquire_lock_file( } } -pub(super) fn operation_lock_path(host: CodingAgent, install_dir: &Path) -> PathBuf { - install_dir.join(format!(".nemo-relay-{}-operation.lock", host_name(host))) -} - -fn host_name(host: CodingAgent) -> &'static str { - match host { - CodingAgent::Codex => "codex", - CodingAgent::ClaudeCode => "claude-code", - CodingAgent::Hermes => { - unreachable!("all is expanded before operation locking") - } - } +pub(crate) fn operation_lock_path(installation_key: &str, install_dir: &Path) -> PathBuf { + install_dir.join(format!(".nemo-relay-{installation_key}-operation.lock")) } diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index 01228b96f..b80053b08 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -1106,7 +1106,7 @@ fn cross_process_lock_holder() { PathBuf::from(std::env::var_os(OPERATION_LOCK_HELPER_GLOBAL_DIR_ENV).unwrap()); _operation_lock = Some( PluginOperationLock::acquire( - CodingAgent::Codex, + CodingAgent::Codex.install_arg(), &global_lock_dir, Path::new(&path), Duration::from_secs(5), From 5d9c0fc7d4b768713203e936d7aeec4abe9a44be Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 22:37:55 -0400 Subject: [PATCH 043/102] refactor(cli): move Claude launch preparation into agent slice Signed-off-by: Will Killian --- crates/cli/src/agents/claude/launch.rs | 183 ++++++++++++++++++ crates/cli/src/agents/claude/mod.rs | 1 + crates/cli/src/process/launcher.rs | 181 ++--------------- crates/cli/src/process/mod.rs | 2 + crates/cli/src/process/prepared.rs | 13 ++ .../tests/coverage/agents/launcher_tests.rs | 69 ++++--- 6 files changed, 251 insertions(+), 198 deletions(-) create mode 100644 crates/cli/src/agents/claude/launch.rs create mode 100644 crates/cli/src/process/prepared.rs diff --git a/crates/cli/src/agents/claude/launch.rs b/crates/cli/src/agents/claude/launch.rs new file mode 100644 index 000000000..2e10b5ccf --- /dev/null +++ b/crates/cli/src/agents/claude/launch.rs @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::agents::CodingAgent; +use crate::error::CliError; +use crate::hooks::{generated_hooks, transparent_hook_forward_command}; +use crate::process::PreparedAgentLaunch; + +pub(crate) fn prepare( + launch: &mut PreparedAgentLaunch, + gateway_url: &str, + dry_run: bool, +) -> Result<(), CliError> { + if dry_run { + insert_after_host( + &mut launch.argv, + launch.host_index, + [ + "--plugin-dir".into(), + "".into(), + "--settings".into(), + "".into(), + ], + ); + launch + .env + .push(("ANTHROPIC_BASE_URL".into(), gateway_url.to_string())); + launch + .notes + .push("would generate a temporary Claude Code plugin directory".into()); + return Ok(()); + } + + let root = temp_dir("nemo-relay-claude-plugin")?; + std::fs::create_dir_all(root.join(".claude-plugin"))?; + std::fs::create_dir_all(root.join("hooks"))?; + std::fs::write( + root.join(".claude-plugin/plugin.json"), + serde_json::to_vec_pretty(&json!({ + "name": "nemo-relay-cli", + "version": env!("CARGO_PKG_VERSION"), + "description": "Temporary NeMo Relay gateway hooks" + })) + .map_err(|error| CliError::Launch(error.to_string()))?, + )?; + let hook_command = transparent_hook_forward_command( + &transparent_hook_executable(), + CodingAgent::ClaudeCode, + gateway_url, + ) + .map_err(CliError::Launch)?; + write_hooks( + &root.join("hooks/hooks.json"), + generated_hooks(CodingAgent::ClaudeCode, &hook_command), + )?; + let settings_path = root.join("settings.json"); + let settings = settings_overlay(&launch.argv, launch.host_index, gateway_url)?; + let settings_bytes = serde_json::to_vec_pretty(&settings) + .map_err(|error| CliError::Launch(error.to_string()))?; + crate::filesystem::atomic_write_private(&settings_path, &settings_bytes) + .map_err(CliError::Launch)?; + insert_after_host( + &mut launch.argv, + launch.host_index, + [ + "--plugin-dir".into(), + root.display().to_string(), + "--settings".into(), + settings_path.display().to_string(), + ], + ); + launch + .env + .push(("ANTHROPIC_BASE_URL".into(), gateway_url.to_string())); + launch.temp_dirs.push(root); + Ok(()) +} + +pub(crate) fn settings_overlay( + argv: &[String], + host_index: usize, + gateway_url: &str, +) -> Result { + let mut settings = match first_settings(argv, host_index)? { + Some(source) => read_settings(source)?, + None => json!({}), + }; + let object = settings.as_object_mut().ok_or_else(|| { + CliError::Launch("Claude Code --settings must contain a JSON object".into()) + })?; + let environment = object.entry("env").or_insert_with(|| json!({})); + let environment = environment.as_object_mut().ok_or_else(|| { + CliError::Launch("Claude Code --settings field `env` must be a JSON object".into()) + })?; + environment.insert( + "ANTHROPIC_BASE_URL".into(), + Value::String(gateway_url.into()), + ); + Ok(settings) +} + +fn first_settings(argv: &[String], host_index: usize) -> Result, CliError> { + let boundary = argv + .iter() + .skip(host_index + 1) + .position(|argument| argument == "--") + .map_or(argv.len(), |offset| host_index + 1 + offset); + let mut index = host_index + 1; + while index < boundary { + if argv[index] == "--settings" { + if index + 1 >= boundary || argv[index + 1].is_empty() { + return Err(CliError::Launch( + "Claude Code --settings is missing its value".into(), + )); + } + return Ok(Some(argv[index + 1].as_str())); + } + if let Some(value) = argv[index].strip_prefix("--settings=") { + if value.is_empty() { + return Err(CliError::Launch( + "Claude Code --settings is missing its value".into(), + )); + } + return Ok(Some(value)); + } + index += 1; + } + Ok(None) +} + +fn read_settings(source: &str) -> Result { + let raw = if source.trim_start().starts_with('{') { + source.to_string() + } else { + std::fs::read_to_string(source).map_err(|error| { + CliError::Launch(format!( + "failed to read Claude Code settings {}: {error}", + Path::new(source).display() + )) + })? + }; + serde_json::from_str(&raw).map_err(|error| { + CliError::Launch(format!( + "failed to parse Claude Code --settings JSON: {error}" + )) + }) +} + +fn transparent_hook_executable() -> PathBuf { + std::env::current_exe() + .map(|path| path.canonicalize().unwrap_or(path)) + .map(crate::agents::portable_executable_path) + .unwrap_or_else(|_| PathBuf::from("nemo-relay")) +} + +fn insert_after_host( + argv: &mut Vec, + host_index: usize, + values: impl IntoIterator, +) { + argv.splice( + host_index.saturating_add(1)..host_index.saturating_add(1), + values, + ); +} + +pub(crate) fn write_hooks(path: &Path, hooks: Value) -> Result<(), CliError> { + std::fs::write( + path, + serde_json::to_vec_pretty(&hooks).map_err(|error| CliError::Launch(error.to_string()))?, + )?; + Ok(()) +} + +fn temp_dir(prefix: &str) -> Result { + let path = std::env::temp_dir().join(format!("{prefix}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path)?; + Ok(path) +} diff --git a/crates/cli/src/agents/claude/mod.rs b/crates/cli/src/agents/claude/mod.rs index 8eaa5f938..2ee283965 100644 --- a/crates/cli/src/agents/claude/mod.rs +++ b/crates/cli/src/agents/claude/mod.rs @@ -7,6 +7,7 @@ use super::AgentDescriptor; pub(super) mod assets; pub(super) mod host; +pub(crate) mod launch; pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { argument: "claude", diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index 0891baae3..7472fa5d0 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -9,7 +9,9 @@ use nemo_relay::observability::plugin_component::{ AtifStorageConfig, OBSERVABILITY_PLUGIN_KIND, ObservabilityConfig, }; use nemo_relay::plugin::PluginConfig; -use serde_json::{Value, json}; +use serde_json::Value; +#[cfg(test)] +use serde_json::json; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio::task::JoinHandle; @@ -24,7 +26,7 @@ use crate::plugins::lifecycle::ActiveDynamicPluginComponent; use crate::server; use crate::server::GatewayOverrides; -use super::RunOverrides; +use super::{PreparedAgentLaunch, RunOverrides}; /// Runs a child coding-agent command behind an ephemeral local gateway. /// @@ -87,7 +89,7 @@ pub(crate) async fn easy_path( struct TransparentRun { agent: CodingAgent, - prepared: PreparedRun, + prepared: PreparedAgentLaunch, resolved: ResolvedConfig, dynamic_plugins: Vec, listener: TcpListener, @@ -129,7 +131,8 @@ impl TransparentRun { let gateway_url = format!("http://{address}"); resolved.gateway.bind = address; - let prepared = PreparedRun::from_invocation(invocation, &gateway_url, &resolved, dry_run)?; + let prepared = + PreparedAgentLaunch::from_invocation(invocation, &gateway_url, &resolved, dry_run)?; Ok(Self { agent, prepared, @@ -176,7 +179,7 @@ async fn execute_live_run( listener: TcpListener, gateway_config: GatewayConfig, gateway_url: &str, - prepared: PreparedRun, + prepared: PreparedAgentLaunch, ) -> Result { execute_live_run_with_dynamic(listener, gateway_config, Vec::new(), gateway_url, prepared).await } @@ -186,7 +189,7 @@ async fn execute_live_run_with_dynamic( gateway_config: GatewayConfig, dynamic_plugins: Vec, gateway_url: &str, - prepared: PreparedRun, + prepared: PreparedAgentLaunch, ) -> Result { let bootstrap_fingerprint = crate::configuration::transparent_gateway_fingerprint(gateway_url); let running_server = RunningGateway::start( @@ -206,7 +209,7 @@ async fn execute_live_run_with_dynamic( } async fn supervise_prepared_run( - prepared: &PreparedRun, + prepared: &PreparedAgentLaunch, mut running_server: RunningGateway, ) -> Result { let mut child = match prepared.spawn().await { @@ -352,14 +355,6 @@ fn configured_command(agent: CodingAgent, agents: &AgentConfigs) -> Option, - host_index: usize, - env: Vec<(String, String)>, - temp_dirs: Vec, - notes: Vec, -} - struct RunningGateway { shutdown_tx: oneshot::Sender<()>, task: JoinHandle>, @@ -404,7 +399,7 @@ impl RunningGateway { } } -impl PreparedRun { +impl PreparedAgentLaunch { fn from_invocation( invocation: AgentInvocation, gateway_url: &str, @@ -469,11 +464,7 @@ impl PreparedRun { } match agent { CodingAgent::ClaudeCode => { - if dry_run { - run.prepare_claude_dry(gateway_url)?; - } else { - run.prepare_claude(gateway_url)?; - } + crate::agents::claude::launch::prepare(&mut run, gateway_url, dry_run)? } CodingAgent::Codex => run.prepare_codex(gateway_url)?, CodingAgent::Hermes => { @@ -487,74 +478,6 @@ impl PreparedRun { Ok(run) } - // Records the Claude Code argv/env changes that would be made during a real run. The temporary - // plugin path is symbolic so printed dry-run output is deterministic and non-mutating. - fn prepare_claude_dry(&mut self, gateway_url: &str) -> Result<(), CliError> { - insert_after_host( - &mut self.argv, - self.host_index, - [ - "--plugin-dir".into(), - "".into(), - "--settings".into(), - "".into(), - ], - ); - self.env - .push(("ANTHROPIC_BASE_URL".into(), gateway_url.to_string())); - self.notes - .push("would generate a temporary Claude Code plugin directory".into()); - Ok(()) - } - - // Creates a temporary Claude Code plugin containing gateway hooks and a process-private - // settings overlay. Claude applies the first `--settings` argument, so the overlay preserves - // the caller's first explicit settings source while overriding only the gateway URL. - fn prepare_claude(&mut self, gateway_url: &str) -> Result<(), CliError> { - let root = temp_dir("nemo-relay-claude-plugin")?; - std::fs::create_dir_all(root.join(".claude-plugin"))?; - std::fs::create_dir_all(root.join("hooks"))?; - std::fs::write( - root.join(".claude-plugin/plugin.json"), - serde_json::to_vec_pretty(&json!({ - "name": "nemo-relay-cli", - "version": env!("CARGO_PKG_VERSION"), - "description": "Temporary NeMo Relay gateway hooks" - })) - .map_err(|error| CliError::Launch(error.to_string()))?, - )?; - let hook_command = transparent_hook_forward_command( - &transparent_hook_executable(), - CodingAgent::ClaudeCode, - gateway_url, - ) - .map_err(CliError::Launch)?; - write_hooks( - &root.join("hooks/hooks.json"), - generated_hooks(CodingAgent::ClaudeCode, &hook_command), - )?; - let settings_path = root.join("settings.json"); - let settings = claude_settings_overlay(&self.argv, self.host_index, gateway_url)?; - let settings_bytes = serde_json::to_vec_pretty(&settings) - .map_err(|error| CliError::Launch(error.to_string()))?; - crate::filesystem::atomic_write_private(&settings_path, &settings_bytes) - .map_err(CliError::Launch)?; - insert_after_host( - &mut self.argv, - self.host_index, - [ - "--plugin-dir".into(), - root.display().to_string(), - "--settings".into(), - settings_path.display().to_string(), - ], - ); - self.env - .push(("ANTHROPIC_BASE_URL".into(), gateway_url.to_string())); - self.temp_dirs.push(root); - Ok(()) - } - // Injects Codex hook and provider configuration through repeated `--config` flags. Codex // reserves built-in provider IDs, so run mode installs a temporary provider alias instead of // overriding `model_providers.openai`. Uses `features.hooks=true` introduced in codex-cli @@ -762,76 +685,6 @@ impl PreparedRun { // Claude Code honors only the first `--settings` source. Preserve that source in the generated // overlay so inserting Relay's process-private gateway setting cannot discard user configuration. -fn claude_settings_overlay( - argv: &[String], - host_index: usize, - gateway_url: &str, -) -> Result { - let mut settings = match first_claude_settings(argv, host_index)? { - Some(source) => read_claude_settings(source)?, - None => json!({}), - }; - let object = settings.as_object_mut().ok_or_else(|| { - CliError::Launch("Claude Code --settings must contain a JSON object".into()) - })?; - let environment = object.entry("env").or_insert_with(|| json!({})); - let environment = environment.as_object_mut().ok_or_else(|| { - CliError::Launch("Claude Code --settings field `env` must be a JSON object".into()) - })?; - environment.insert( - "ANTHROPIC_BASE_URL".into(), - Value::String(gateway_url.into()), - ); - Ok(settings) -} - -fn first_claude_settings(argv: &[String], host_index: usize) -> Result, CliError> { - let boundary = argv - .iter() - .skip(host_index + 1) - .position(|argument| argument == "--") - .map_or(argv.len(), |offset| host_index + 1 + offset); - let mut index = host_index + 1; - while index < boundary { - if argv[index] == "--settings" { - if index + 1 >= boundary || argv[index + 1].is_empty() { - return Err(CliError::Launch( - "Claude Code --settings is missing its value".into(), - )); - } - return Ok(Some(argv[index + 1].as_str())); - } - if let Some(source) = argv[index].strip_prefix("--settings=") { - if source.is_empty() { - return Err(CliError::Launch( - "Claude Code --settings is missing its value".into(), - )); - } - return Ok(Some(source)); - } - index += 1; - } - Ok(None) -} - -fn read_claude_settings(source: &str) -> Result { - let raw = if source.trim_start().starts_with('{') { - source.to_string() - } else { - std::fs::read_to_string(source).map_err(|error| { - CliError::Launch(format!( - "failed to read Claude Code settings {}: {error}", - Path::new(source).display() - )) - })? - }; - serde_json::from_str(&raw).map_err(|error| { - CliError::Launch(format!( - "failed to parse Claude Code --settings JSON: {error}" - )) - }) -} - // Session hook definitions and their exact trust state share Codex's process-local CLI layer. This // authorizes only the generated Relay command without rewriting the active user profile or using // the process-wide hook-trust bypass. @@ -1229,16 +1082,6 @@ fn insert_after_host( argv.splice(host_index + 1..host_index + 1, args); } -// Writes pretty JSON hook config to a path whose parent has already been created by the caller. -// Serialization errors are converted to launch errors to keep temporary setup failures contextual. -fn write_hooks(path: &Path, hooks: Value) -> Result<(), CliError> { - std::fs::write( - path, - serde_json::to_vec_pretty(&hooks).map_err(|error| CliError::Launch(error.to_string()))?, - )?; - Ok(()) -} - // Creates a per-process Hermes home whose user state points at the original profile while the // config and hook approval files remain private to this transparent run. Hermes has no standalone // config-file override, so `HERMES_HOME` is its supported process-scoped configuration boundary. diff --git a/crates/cli/src/process/mod.rs b/crates/cli/src/process/mod.rs index 1a1119300..c56cdbd56 100644 --- a/crates/cli/src/process/mod.rs +++ b/crates/cli/src/process/mod.rs @@ -5,8 +5,10 @@ pub(crate) mod detached; pub(crate) mod launcher; +mod prepared; mod types; +pub(crate) use prepared::PreparedAgentLaunch; pub(crate) use types::RunOverrides; use std::ffi::{OsStr, OsString}; diff --git a/crates/cli/src/process/prepared.rs b/crates/cli/src/process/prepared.rs new file mode 100644 index 000000000..e6e4f5237 --- /dev/null +++ b/crates/cli/src/process/prepared.rs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +/// Fully resolved child-process launch plan produced by one agent integration. +pub(crate) struct PreparedAgentLaunch { + pub(crate) argv: Vec, + pub(crate) host_index: usize, + pub(crate) env: Vec<(String, String)>, + pub(crate) temp_dirs: Vec, + pub(crate) notes: Vec, +} diff --git a/crates/cli/tests/coverage/agents/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs index 604232d2c..182508725 100644 --- a/crates/cli/tests/coverage/agents/launcher_tests.rs +++ b/crates/cli/tests/coverage/agents/launcher_tests.rs @@ -254,7 +254,7 @@ fn prepares_codex_config_overrides() { agents: AgentConfigs::default(), ..ResolvedConfig::default() }; - let prepared = PreparedRun::new( + let prepared = PreparedAgentLaunch::new( CodingAgent::Codex, vec!["codex".into()], "http://127.0.0.1:1234", @@ -353,7 +353,7 @@ fn prepares_codex_with_hooks_when_auth_missing() { ..ResolvedConfig::default() }; - let prepared = PreparedRun::new( + let prepared = PreparedAgentLaunch::new( CodingAgent::Codex, vec!["codex".into()], "http://127.0.0.1:1234", @@ -408,7 +408,7 @@ fn codex_preserves_profiles_and_prompt_arguments_without_temporary_config() { agents: AgentConfigs::default(), ..ResolvedConfig::default() }; - let prepared = PreparedRun::new( + let prepared = PreparedAgentLaunch::new( CodingAgent::Codex, vec![ "codex".into(), @@ -695,7 +695,7 @@ fn prepares_claude_dry_run_without_writing_plugin() { agents: AgentConfigs::default(), ..ResolvedConfig::default() }; - let prepared = PreparedRun::new( + let prepared = PreparedAgentLaunch::new( CodingAgent::ClaudeCode, vec!["claude".into()], "http://127.0.0.1:1234", @@ -721,7 +721,7 @@ fn prepares_claude_dry_inserts_plugin_dir_after_authoritative_agent_executable() agents: AgentConfigs::default(), ..ResolvedConfig::default() }; - let prepared = PreparedRun::new( + let prepared = PreparedAgentLaunch::new( CodingAgent::ClaudeCode, vec![ "wrapper".into(), @@ -774,7 +774,7 @@ fn prepares_hermes_hook_environment() { dynamic_plugins: Vec::new(), ..ResolvedConfig::default() }; - let prepared = PreparedRun::new( + let prepared = PreparedAgentLaunch::new( CodingAgent::Hermes, vec!["hermes".into(), "chat".into()], "http://127.0.0.1:1234", @@ -863,7 +863,7 @@ fn concurrent_hermes_runs_use_independent_overlays_without_mutating_user_config( ..ResolvedConfig::default() }; - let first = PreparedRun::new( + let first = PreparedAgentLaunch::new( CodingAgent::Hermes, vec!["hermes".into()], "http://127.0.0.1:4001", @@ -871,7 +871,7 @@ fn concurrent_hermes_runs_use_independent_overlays_without_mutating_user_config( false, ) .unwrap(); - let second = PreparedRun::new( + let second = PreparedAgentLaunch::new( CodingAgent::Hermes, vec!["hermes".into()], "http://127.0.0.1:4002", @@ -879,7 +879,7 @@ fn concurrent_hermes_runs_use_independent_overlays_without_mutating_user_config( false, ) .unwrap(); - let overlay = |run: &PreparedRun| { + let overlay = |run: &PreparedAgentLaunch| { run.env .iter() .find_map(|(name, value)| (name == "HERMES_HOME").then(|| PathBuf::from(value))) @@ -959,7 +959,7 @@ fn prepares_hermes_dry_uses_home_path_without_writing_hooks() { ..ResolvedConfig::default() }; - let prepared = PreparedRun::new( + let prepared = PreparedAgentLaunch::new( CodingAgent::Hermes, vec!["hermes".into()], "http://127.0.0.1:1234", @@ -1041,7 +1041,7 @@ hooks: ..ResolvedConfig::default() }; - let prepared = PreparedRun::new( + let prepared = PreparedAgentLaunch::new( CodingAgent::Hermes, vec!["hermes".into(), "chat".into()], "http://s", @@ -1087,7 +1087,7 @@ fn prepares_claude_temp_plugin() { agents: AgentConfigs::default(), ..ResolvedConfig::default() }; - let prepared = PreparedRun::new( + let prepared = PreparedAgentLaunch::new( CodingAgent::ClaudeCode, vec!["claude".into()], "http://127.0.0.1:1234", @@ -1144,7 +1144,7 @@ fn claude_transparent_run_preserves_user_settings_and_prompt_boundary() { agents: AgentConfigs::default(), ..ResolvedConfig::default() }; - let prepared = PreparedRun::new( + let prepared = PreparedAgentLaunch::new( CodingAgent::ClaudeCode, vec![ "claude".into(), @@ -1206,7 +1206,9 @@ fn claude_settings_overlay_handles_inline_json_and_rejects_malformed_sources() { "claude".into(), "--settings={\"model\":\"kept\",\"env\":{\"PRIVATE\":\"yes\"}}".into(), ]; - let overlay = claude_settings_overlay(&inline, 0, "http://127.0.0.1:4321").unwrap(); + let overlay = + crate::agents::claude::launch::settings_overlay(&inline, 0, "http://127.0.0.1:4321") + .unwrap(); assert_eq!(overlay["model"], "kept"); assert_eq!(overlay["env"]["PRIVATE"], "yes"); assert_eq!( @@ -1220,12 +1222,17 @@ fn claude_settings_overlay_handles_inline_json_and_rejects_malformed_sources() { "--settings".into(), "prompt-value".into(), ]; - let overlay = claude_settings_overlay(&after_separator, 0, "http://127.0.0.1:4321").unwrap(); + let overlay = crate::agents::claude::launch::settings_overlay( + &after_separator, + 0, + "http://127.0.0.1:4321", + ) + .unwrap(); assert_eq!(overlay.as_object().unwrap().len(), 1); let missing = vec!["claude".into(), "--settings".into(), "--".into()]; assert!( - claude_settings_overlay(&missing, 0, "http://127.0.0.1:4321") + crate::agents::claude::launch::settings_overlay(&missing, 0, "http://127.0.0.1:4321") .unwrap_err() .to_string() .contains("missing its value") @@ -1233,7 +1240,7 @@ fn claude_settings_overlay_handles_inline_json_and_rejects_malformed_sources() { let malformed_env = vec!["claude".into(), "--settings={\"env\":true}".into()]; assert!( - claude_settings_overlay(&malformed_env, 0, "http://127.0.0.1:4321") + crate::agents::claude::launch::settings_overlay(&malformed_env, 0, "http://127.0.0.1:4321") .unwrap_err() .to_string() .contains("field `env` must be a JSON object") @@ -1247,7 +1254,7 @@ fn claude_settings_overlay_handles_inline_json_and_rejects_malformed_sources() { format!("--settings={}", non_object_path.display()), ]; assert!( - claude_settings_overlay(&non_object, 0, "http://127.0.0.1:4321") + crate::agents::claude::launch::settings_overlay(&non_object, 0, "http://127.0.0.1:4321") .unwrap_err() .to_string() .contains("must contain a JSON object") @@ -1255,7 +1262,7 @@ fn claude_settings_overlay_handles_inline_json_and_rejects_malformed_sources() { let empty_inline = vec!["claude".into(), "--settings=".into()]; assert!( - claude_settings_overlay(&empty_inline, 0, "http://127.0.0.1:4321") + crate::agents::claude::launch::settings_overlay(&empty_inline, 0, "http://127.0.0.1:4321") .unwrap_err() .to_string() .contains("missing its value") @@ -1271,7 +1278,7 @@ fn claude_settings_overlay_handles_inline_json_and_rejects_malformed_sources() { .to_string(), ]; assert!( - claude_settings_overlay(&missing_file, 0, "http://127.0.0.1:4321") + crate::agents::claude::launch::settings_overlay(&missing_file, 0, "http://127.0.0.1:4321") .unwrap_err() .to_string() .contains("failed to read Claude Code settings") @@ -1279,10 +1286,14 @@ fn claude_settings_overlay_handles_inline_json_and_rejects_malformed_sources() { let malformed_json = vec!["claude".into(), "--settings={not-json".into()]; assert!( - claude_settings_overlay(&malformed_json, 0, "http://127.0.0.1:4321") - .unwrap_err() - .to_string() - .contains("failed to parse Claude Code --settings JSON") + crate::agents::claude::launch::settings_overlay( + &malformed_json, + 0, + "http://127.0.0.1:4321" + ) + .unwrap_err() + .to_string() + .contains("failed to parse Claude Code --settings JSON") ); } @@ -1379,7 +1390,7 @@ fn claude_prompt_named_like_the_host_does_not_capture_relay_flags() { agents: AgentConfigs::default(), ..ResolvedConfig::default() }; - let prepared = PreparedRun::new( + let prepared = PreparedAgentLaunch::new( CodingAgent::ClaudeCode, vec!["claude".into(), "--".into(), "claude".into()], "http://127.0.0.1:1234", @@ -1397,7 +1408,7 @@ fn hook_write_helpers_cover_toml_escaping() { let temp = tempfile::tempdir().unwrap(); let written_hooks = temp.path().join("written/hooks.json"); std::fs::create_dir_all(written_hooks.parent().unwrap()).unwrap(); - write_hooks(&written_hooks, json!({"hooks": []})).unwrap(); + crate::agents::claude::launch::write_hooks(&written_hooks, json!({"hooks": []})).unwrap(); assert!( std::fs::read_to_string(&written_hooks) .unwrap() @@ -1593,7 +1604,7 @@ async fn gateway_failure_terminates_the_agent_and_restores_private_state() { make_executable(&script); let overlay = temp.path().join("private-overlay"); std::fs::create_dir_all(&overlay).unwrap(); - let prepared = PreparedRun { + let prepared = PreparedAgentLaunch { argv: vec![ script.display().to_string(), wrapper_pid_path.display().to_string(), @@ -1659,7 +1670,7 @@ async fn execute_live_run_reports_gateway_startup_error_when_health_check_fails( agents: AgentConfigs::default(), ..ResolvedConfig::default() }; - let prepared = PreparedRun::new( + let prepared = PreparedAgentLaunch::new( CodingAgent::ClaudeCode, vec!["claude".into()], "http://127.0.0.1:1234", @@ -1717,7 +1728,7 @@ async fn execute_live_run_removes_hermes_overlay_when_health_check_fails() { }, ..ResolvedConfig::default() }; - let prepared = PreparedRun::new( + let prepared = PreparedAgentLaunch::new( CodingAgent::Hermes, vec!["hermes".into(), "chat".into()], "http://127.0.0.1:1234", From d274ba08c4e54b71cd4e8ff9c50e177034059621 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 22:41:45 -0400 Subject: [PATCH 044/102] refactor(cli): move Codex launch preparation into agent slice Signed-off-by: Will Killian --- crates/cli/src/agents/claude/launch.rs | 13 +- crates/cli/src/agents/codex/launch.rs | 231 ++++++++++++++++ crates/cli/src/agents/codex/mod.rs | 1 + crates/cli/src/process/launcher.rs | 256 +----------------- crates/cli/src/process/mod.rs | 1 + crates/cli/src/process/prepared.rs | 9 + .../tests/coverage/agents/launcher_tests.rs | 28 +- 7 files changed, 263 insertions(+), 276 deletions(-) create mode 100644 crates/cli/src/agents/codex/launch.rs diff --git a/crates/cli/src/agents/claude/launch.rs b/crates/cli/src/agents/claude/launch.rs index 2e10b5ccf..b86b3e61f 100644 --- a/crates/cli/src/agents/claude/launch.rs +++ b/crates/cli/src/agents/claude/launch.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use crate::agents::CodingAgent; use crate::error::CliError; use crate::hooks::{generated_hooks, transparent_hook_forward_command}; -use crate::process::PreparedAgentLaunch; +use crate::process::{PreparedAgentLaunch, insert_after_host}; pub(crate) fn prepare( launch: &mut PreparedAgentLaunch, @@ -157,17 +157,6 @@ fn transparent_hook_executable() -> PathBuf { .unwrap_or_else(|_| PathBuf::from("nemo-relay")) } -fn insert_after_host( - argv: &mut Vec, - host_index: usize, - values: impl IntoIterator, -) { - argv.splice( - host_index.saturating_add(1)..host_index.saturating_add(1), - values, - ); -} - pub(crate) fn write_hooks(path: &Path, hooks: Value) -> Result<(), CliError> { std::fs::write( path, diff --git a/crates/cli/src/agents/codex/launch.rs b/crates/cli/src/agents/codex/launch.rs new file mode 100644 index 000000000..0fcd0978b --- /dev/null +++ b/crates/cli/src/agents/codex/launch.rs @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +use serde_json::Value; + +use crate::agents::CodingAgent; +use crate::configuration::{RELAY_PLUGIN_ID, RELAY_SOURCE_PLUGIN_ID}; +use crate::error::CliError; +use crate::hooks::{generated_hooks, transparent_hook_forward_command}; +use crate::process::{PreparedAgentLaunch, insert_after_host}; + +pub(crate) fn prepare(launch: &mut PreparedAgentLaunch, gateway_url: &str) -> Result<(), CliError> { + let has_openai_key = std::env::var("OPENAI_API_KEY") + .ok() + .is_some_and(|value| !value.is_empty()); + let has_codex_auth = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(|home| PathBuf::from(home).join(".codex/auth.json").exists()) + .unwrap_or(false); + if !has_openai_key && !has_codex_auth { + eprintln!( + "warning: No OpenAI credentials found. Either export OPENAI_API_KEY \ + (e.g. `export OPENAI_API_KEY=sk-...`), log in to codex (`codex --login`), \ + or pass `--openai-base-url` to an upstream that needs no key." + ); + } + let hook_command = transparent_hook_forward_command( + &transparent_hook_executable(), + CodingAgent::Codex, + gateway_url, + ) + .map_err(CliError::Launch)?; + let hook_groups = generated_hooks(CodingAgent::Codex, &hook_command); + let mut args = vec![ + "--config".to_string(), + "features.hooks=true".to_string(), + "--config".to_string(), + "model_provider=\"nemo-relay-openai\"".to_string(), + "--config".to_string(), + gateway_provider_config(gateway_url), + ]; + for (event, groups) in hook_groups["hooks"].as_object().into_iter().flatten() { + args.push("--config".to_string()); + args.push(format!("hooks.{event}={}", hook_groups_toml(groups))); + } + args.push("--config".to_string()); + args.push(session_hook_state_override(&hook_groups)?); + insert_after_host(&mut launch.argv, launch.host_index, args); + Ok(()) +} + +pub(crate) fn session_hook_state_override(generated: &Value) -> Result { + let events = generated + .get("hooks") + .and_then(Value::as_object) + .ok_or_else(|| CliError::Launch("generated Codex hooks were malformed".into()))?; + let mut states = Vec::new(); + for (event, groups) in events { + let groups = groups.as_array().ok_or_else(|| { + CliError::Launch(format!( + "generated Codex {event} hook groups were malformed" + )) + })?; + let event_key = hook_event_key(event); + for (group_index, group) in groups.iter().enumerate() { + let group = group.as_object().ok_or_else(|| { + CliError::Launch(format!("generated Codex {event} hook group was malformed")) + })?; + let handlers = group + .get("hooks") + .and_then(Value::as_array) + .ok_or_else(|| { + CliError::Launch(format!( + "generated Codex {event} hook handlers were malformed" + )) + })?; + for (handler_index, handler) in handlers.iter().enumerate() { + let hash = command_hook_hash(&event_key, group, handler)?; + let key = format!( + "//config.toml:{event_key}:{group_index}:{handler_index}" + ); + states.push(format!( + "{}={{trusted_hash={},enabled=true}}", + toml_string(&key), + toml_string(&hash) + )); + for plugin_id in [RELAY_PLUGIN_ID, RELAY_SOURCE_PLUGIN_ID] { + let key = format!( + "{plugin_id}:hooks/hooks.json:{event_key}:{group_index}:{handler_index}" + ); + states.push(format!("{}={{enabled=false}}", toml_string(&key))); + } + } + } + } + Ok(format!("hooks.state={{{}}}", states.join(","))) +} + +fn hook_event_key(event: &str) -> String { + let mut normalized = String::with_capacity(event.len() + 2); + for (index, character) in event.chars().enumerate() { + if character.is_ascii_uppercase() { + if index > 0 { + normalized.push('_'); + } + normalized.push(character.to_ascii_lowercase()); + } else { + normalized.push(character); + } + } + normalized +} + +pub(crate) fn command_hook_hash( + event_key: &str, + group: &serde_json::Map, + handler: &Value, +) -> Result { + use sha2::{Digest, Sha256}; + + let handler = handler.as_object().ok_or_else(|| { + CliError::Launch(format!( + "generated Codex {event_key} command hook was malformed" + )) + })?; + if handler.get("type").and_then(Value::as_str) != Some("command") { + return Err(CliError::Launch(format!( + "generated Codex {event_key} hook was not a command" + ))); + } + let command = handler + .get(if cfg!(windows) { + "commandWindows" + } else { + "command" + }) + .or_else(|| handler.get("command")) + .and_then(Value::as_str) + .ok_or_else(|| { + CliError::Launch(format!( + "generated Codex {event_key} hook command was missing" + )) + })?; + let timeout = handler + .get("timeout") + .and_then(Value::as_u64) + .unwrap_or(600) + .max(1); + let mut normalized_handler = serde_json::Map::new(); + normalized_handler.insert("type".into(), Value::String("command".into())); + normalized_handler.insert("command".into(), Value::String(command.into())); + normalized_handler.insert("timeout".into(), Value::Number(timeout.into())); + normalized_handler.insert("async".into(), Value::Bool(false)); + if let Some(status) = handler.get("statusMessage").and_then(Value::as_str) { + normalized_handler.insert("statusMessage".into(), Value::String(status.into())); + } + let mut identity = serde_json::Map::new(); + identity.insert("event_name".into(), Value::String(event_key.into())); + if let Some(matcher) = group.get("matcher").and_then(Value::as_str) { + identity.insert("matcher".into(), Value::String(matcher.into())); + } + identity.insert( + "hooks".into(), + Value::Array(vec![Value::Object(normalized_handler)]), + ); + let bytes = serde_json::to_vec(&canonical_json(Value::Object(identity))) + .map_err(|error| CliError::Launch(format!("failed to hash Codex hook: {error}")))?; + let digest = Sha256::digest(bytes); + Ok(format!( + "sha256:{}", + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + )) +} + +fn canonical_json(value: Value) -> Value { + match value { + Value::Object(object) => { + let mut entries = object.into_iter().collect::>(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + Value::Object( + entries + .into_iter() + .map(|(key, value)| (key, canonical_json(value))) + .collect(), + ) + } + Value::Array(values) => Value::Array(values.into_iter().map(canonical_json).collect()), + other => other, + } +} + +fn gateway_provider_config(gateway_url: &str) -> String { + format!( + "model_providers.nemo-relay-openai={{name=\"NeMo Relay OpenAI\",base_url={},wire_api=\"responses\",requires_openai_auth=true,supports_websockets=false}}", + toml_string(gateway_url) + ) +} + +pub(crate) fn hook_groups_toml(value: &Value) -> String { + let mut groups = Vec::new(); + for group in value.as_array().into_iter().flatten() { + let matcher = group + .get("matcher") + .and_then(Value::as_str) + .map(|matcher| format!("matcher={},", toml_string(matcher))) + .unwrap_or_default(); + let command = group["hooks"][0]["command"].as_str().unwrap_or_default(); + groups.push(format!( + "{{{matcher}hooks=[{{type=\"command\",command={},timeout=30}}]}}", + toml_string(command) + )); + } + format!("[{}]", groups.join(",")) +} + +pub(crate) fn toml_string(value: &str) -> String { + let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + +fn transparent_hook_executable() -> PathBuf { + std::env::current_exe() + .map(|path| path.canonicalize().unwrap_or(path)) + .map(crate::agents::portable_executable_path) + .unwrap_or_else(|_| PathBuf::from("nemo-relay")) +} diff --git a/crates/cli/src/agents/codex/mod.rs b/crates/cli/src/agents/codex/mod.rs index 144fcd3c3..153b70294 100644 --- a/crates/cli/src/agents/codex/mod.rs +++ b/crates/cli/src/agents/codex/mod.rs @@ -8,6 +8,7 @@ use super::AgentDescriptor; pub(super) mod app_server; pub(super) mod assets; pub(super) mod host; +pub(crate) mod launch; pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { argument: "codex", diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index 7472fa5d0..68ce7f7ff 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -17,11 +17,10 @@ use tokio::sync::oneshot; use tokio::task::JoinHandle; use crate::configuration::{ - AgentConfigs, CodingAgent, GatewayConfig, RELAY_PLUGIN_ID, RELAY_SOURCE_PLUGIN_ID, - ResolvedConfig, any_config_file_exists, resolve_run_config, + AgentConfigs, CodingAgent, GatewayConfig, ResolvedConfig, any_config_file_exists, + resolve_run_config, }; use crate::error::CliError; -use crate::hooks::{generated_hooks, transparent_hook_forward_command}; use crate::plugins::lifecycle::ActiveDynamicPluginComponent; use crate::server; use crate::server::GatewayOverrides; @@ -466,7 +465,7 @@ impl PreparedAgentLaunch { CodingAgent::ClaudeCode => { crate::agents::claude::launch::prepare(&mut run, gateway_url, dry_run)? } - CodingAgent::Codex => run.prepare_codex(gateway_url)?, + CodingAgent::Codex => crate::agents::codex::launch::prepare(&mut run, gateway_url)?, CodingAgent::Hermes => { if dry_run { run.prepare_hermes_dry(resolved.agents.hermes.hooks_path.as_deref())?; @@ -482,58 +481,6 @@ impl PreparedAgentLaunch { // reserves built-in provider IDs, so run mode installs a temporary provider alias instead of // overriding `model_providers.openai`. Uses `features.hooks=true` introduced in codex-cli // current supported Codex releases. The centralized host policy validates the version first. - fn prepare_codex(&mut self, gateway_url: &str) -> Result<(), CliError> { - // Codex resolves auth via `CodexAuth::from_auth_dot_json` (`codex-rs/login/src/auth/ - // manager.rs`): `auth_mode=ApiKey` uses `OPENAI_API_KEY`, `auth_mode=Chatgpt` uses the - // OAuth token from `~/.codex/auth.json`. With `requires_openai_auth=true` the provider - // config tells Codex to attach whichever credential it has. The gateway then either - // substitutes `OPENAI_API_KEY` (routing to `api.openai.com`) or forwards the JWT as-is - // (routing to `chatgpt.com/backend-api/codex`). Warn when neither source is present. - let has_openai_key = std::env::var("OPENAI_API_KEY") - .ok() - .is_some_and(|v| !v.is_empty()); - // Codex persists OAuth tokens to `~/.codex/auth.json` via `AuthDotJson` in - // `codex-rs/login/src/auth/storage.rs`. Check for the file rather than parsing it — - // Codex handles token refresh itself at runtime. - let has_codex_auth = std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(|h| { - std::path::PathBuf::from(h) - .join(".codex/auth.json") - .exists() - }) - .unwrap_or(false); - if !has_openai_key && !has_codex_auth { - eprintln!( - "warning: No OpenAI credentials found. Either export OPENAI_API_KEY \ - (e.g. `export OPENAI_API_KEY=sk-...`), log in to codex (`codex --login`), \ - or pass `--openai-base-url` to an upstream that needs no key." - ); - } - let hook_command = transparent_hook_forward_command( - &transparent_hook_executable(), - CodingAgent::Codex, - gateway_url, - ) - .map_err(CliError::Launch)?; - let hook_groups = generated_hooks(CodingAgent::Codex, &hook_command); - let mut args = vec![ - "--config".to_string(), - "features.hooks=true".to_string(), - "--config".to_string(), - "model_provider=\"nemo-relay-openai\"".to_string(), - "--config".to_string(), - codex_gateway_provider_config(gateway_url), - ]; - for (event, groups) in hook_groups["hooks"].as_object().into_iter().flatten() { - args.push("--config".to_string()); - args.push(format!("hooks.{event}={}", hook_groups_toml(groups))); - } - args.push("--config".to_string()); - args.push(codex_session_hook_state_override(&hook_groups)?); - insert_after_host(&mut self.argv, self.host_index, args); - Ok(()) - } // Hermes discovers hooks from `.hermes/config.yaml` instead of command-line flags. A // process-private HERMES_HOME exposes dynamic hooks without rewriting user configuration. @@ -688,149 +635,6 @@ impl PreparedAgentLaunch { // Session hook definitions and their exact trust state share Codex's process-local CLI layer. This // authorizes only the generated Relay command without rewriting the active user profile or using // the process-wide hook-trust bypass. -fn codex_session_hook_state_override(generated: &Value) -> Result { - let events = generated - .get("hooks") - .and_then(Value::as_object) - .ok_or_else(|| CliError::Launch("generated Codex hooks were malformed".into()))?; - let mut states = Vec::new(); - for (event, groups) in events { - let groups = groups.as_array().ok_or_else(|| { - CliError::Launch(format!( - "generated Codex {event} hook groups were malformed" - )) - })?; - let event_key = codex_hook_event_key(event); - for (group_index, group) in groups.iter().enumerate() { - let group = group.as_object().ok_or_else(|| { - CliError::Launch(format!("generated Codex {event} hook group was malformed")) - })?; - let handlers = group - .get("hooks") - .and_then(Value::as_array) - .ok_or_else(|| { - CliError::Launch(format!( - "generated Codex {event} hook handlers were malformed" - )) - })?; - for (handler_index, handler) in handlers.iter().enumerate() { - let hash = codex_command_hook_hash(&event_key, group, handler)?; - let key = format!( - "//config.toml:{event_key}:{group_index}:{handler_index}" - ); - states.push(format!( - "{}={{trusted_hash={},enabled=true}}", - toml_string(&key), - toml_string(&hash) - )); - for plugin_id in [RELAY_PLUGIN_ID, RELAY_SOURCE_PLUGIN_ID] { - let key = format!( - "{plugin_id}:hooks/hooks.json:{event_key}:{group_index}:{handler_index}" - ); - states.push(format!("{}={{enabled=false}}", toml_string(&key))); - } - } - } - } - Ok(format!("hooks.state={{{}}}", states.join(","))) -} - -fn codex_hook_event_key(event: &str) -> String { - let mut normalized = String::with_capacity(event.len() + 2); - for (index, character) in event.chars().enumerate() { - if character.is_ascii_uppercase() { - if index > 0 { - normalized.push('_'); - } - normalized.push(character.to_ascii_lowercase()); - } else { - normalized.push(character); - } - } - normalized -} - -fn codex_command_hook_hash( - event_key: &str, - group: &serde_json::Map, - handler: &Value, -) -> Result { - use sha2::{Digest, Sha256}; - - let handler = handler.as_object().ok_or_else(|| { - CliError::Launch(format!( - "generated Codex {event_key} command hook was malformed" - )) - })?; - if handler.get("type").and_then(Value::as_str) != Some("command") { - return Err(CliError::Launch(format!( - "generated Codex {event_key} hook was not a command" - ))); - } - let command = handler - .get(if cfg!(windows) { - "commandWindows" - } else { - "command" - }) - .or_else(|| handler.get("command")) - .and_then(Value::as_str) - .ok_or_else(|| { - CliError::Launch(format!( - "generated Codex {event_key} hook command was missing" - )) - })?; - let timeout = handler - .get("timeout") - .and_then(Value::as_u64) - .unwrap_or(600) - .max(1); - let mut normalized_handler = serde_json::Map::new(); - normalized_handler.insert("type".into(), Value::String("command".into())); - normalized_handler.insert("command".into(), Value::String(command.into())); - normalized_handler.insert("timeout".into(), Value::Number(timeout.into())); - normalized_handler.insert("async".into(), Value::Bool(false)); - if let Some(status) = handler.get("statusMessage").and_then(Value::as_str) { - normalized_handler.insert("statusMessage".into(), Value::String(status.into())); - } - let mut identity = serde_json::Map::new(); - identity.insert("event_name".into(), Value::String(event_key.into())); - if let Some(matcher) = group.get("matcher").and_then(Value::as_str) { - identity.insert("matcher".into(), Value::String(matcher.into())); - } - identity.insert( - "hooks".into(), - Value::Array(vec![Value::Object(normalized_handler)]), - ); - let canonical = canonical_json(Value::Object(identity)); - let bytes = serde_json::to_vec(&canonical) - .map_err(|error| CliError::Launch(format!("failed to hash Codex hook: {error}")))?; - let digest = Sha256::digest(bytes); - Ok(format!( - "sha256:{}", - digest - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::() - )) -} - -fn canonical_json(value: Value) -> Value { - match value { - Value::Object(object) => { - let mut entries = object.into_iter().collect::>(); - entries.sort_by(|left, right| left.0.cmp(&right.0)); - Value::Object( - entries - .into_iter() - .map(|(key, value)| (key, canonical_json(value))) - .collect(), - ) - } - Value::Array(values) => Value::Array(values.into_iter().map(canonical_json).collect()), - other => other, - } -} /// Renders a bordered status frame for daemon and transparent-run startup output. pub(crate) fn render_status_frame(lines: &[String], color: bool) -> String { @@ -998,26 +802,6 @@ async fn wait_for_health(gateway_url: &str, bootstrap_fingerprint: &str) -> Resu ))) } -fn codex_gateway_provider_config(gateway_url: &str) -> String { - // `wire_api="responses"` is the only value codex 0.130+ accepts; the `chat` value was - // removed (codex#7782). Codex transparent run therefore only works against upstreams that - // implement `/v1/responses` (api.openai.com or a Responses-compatible proxy). For other - // upstreams the user falls back to daemon mode and points codex directly at its configured - // upstream — we observe hooks but not LLM calls. - // - // `requires_openai_auth=true` so Codex's `resolve_provider_auth` (`codex-rs/model-provider/ - // src/auth.rs`) attaches credentials via `BearerAuthProvider`. When the auth mode is - // `Chatgpt` the token is an OAuth JWT or Codex access token; when `ApiKey` it is the - // `OPENAI_API_KEY` value. - // The gateway inspects the inbound `Authorization` header: if `OPENAI_API_KEY` is set in the - // environment the ChatGPT token is replaced (see `alignment::gateway_forward_headers` and - // `gateway.rs::inject_provider_auth`); otherwise it is forwarded to the ChatGPT backend. - format!( - "model_providers.nemo-relay-openai={{name=\"NeMo Relay OpenAI\",base_url={},wire_api=\"responses\",requires_openai_auth=true,supports_websockets=false}}", - toml_string(gateway_url) - ) -} - // Appends one horizontal border line in NVIDIA green when color is enabled, otherwise plain // ASCII-compatible box-drawing. fn push_status_border( @@ -1041,12 +825,6 @@ fn push_status_border( // `PATH`, which would cause hooks to exit with status 127 (command not found). Falls back // to the bare name when `current_exe` is unavailable so behavior degrades to the previous // install-style assumption rather than failing to launch. -fn transparent_hook_executable() -> PathBuf { - std::env::current_exe() - .map(|path| path.canonicalize().unwrap_or(path)) - .map(crate::agents::portable_executable_path) - .unwrap_or_else(|_| PathBuf::from("nemo-relay")) -} // Appends the running gateway binary's directory to the child agent PATH. Transparent hooks use // the absolute executable path when possible, but adding the directory also covers hook loaders or @@ -1073,14 +851,6 @@ fn path_with_transparent_hook_dir() -> Option { // The invocation resolver determines this index before pass-through arguments are appended. Using // it here prevents a prompt token named `codex` or `claude` from becoming an accidental insertion // target while preserving configured wrapper prefixes. -fn insert_after_host( - argv: &mut Vec, - host_index: usize, - args: impl IntoIterator, -) { - debug_assert!(host_index < argv.len()); - argv.splice(host_index + 1..host_index + 1, args); -} // Creates a per-process Hermes home whose user state points at the original profile while the // config and hook approval files remain private to this transparent run. Hermes has no standalone @@ -1226,28 +996,8 @@ fn hermes_hooks_path(configured: Option<&Path>) -> Result { // Converts JSON hook groups into inline TOML arrays for Codex `--config` flags. The function // preserves matchers when present and assumes generated hook groups contain one command hook. -fn hook_groups_toml(value: &Value) -> String { - let mut groups = Vec::new(); - for group in value.as_array().into_iter().flatten() { - let matcher = group - .get("matcher") - .and_then(Value::as_str) - .map(|matcher| format!("matcher={},", toml_string(matcher))) - .unwrap_or_default(); - let command = group["hooks"][0]["command"].as_str().unwrap_or_default(); - groups.push(format!( - "{{{matcher}hooks=[{{type=\"command\",command={},timeout=30}}]}}", - toml_string(command) - )); - } - format!("[{}]", groups.join(",")) -} // Escapes a Rust string as a TOML basic string for inline Codex configuration values. -fn toml_string(value: &str) -> String { - let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); - format!("\"{escaped}\"") -} // Creates a uniquely named directory under the OS temp directory. UUIDv7 avoids collisions // between concurrent transparent runs without keeping persistent coordination state. diff --git a/crates/cli/src/process/mod.rs b/crates/cli/src/process/mod.rs index c56cdbd56..eedcb2447 100644 --- a/crates/cli/src/process/mod.rs +++ b/crates/cli/src/process/mod.rs @@ -9,6 +9,7 @@ mod prepared; mod types; pub(crate) use prepared::PreparedAgentLaunch; +pub(crate) use prepared::insert_after_host; pub(crate) use types::RunOverrides; use std::ffi::{OsStr, OsString}; diff --git a/crates/cli/src/process/prepared.rs b/crates/cli/src/process/prepared.rs index e6e4f5237..b365ae667 100644 --- a/crates/cli/src/process/prepared.rs +++ b/crates/cli/src/process/prepared.rs @@ -11,3 +11,12 @@ pub(crate) struct PreparedAgentLaunch { pub(crate) temp_dirs: Vec, pub(crate) notes: Vec, } + +pub(crate) fn insert_after_host( + argv: &mut Vec, + host_index: usize, + values: impl IntoIterator, +) { + debug_assert!(host_index < argv.len()); + argv.splice(host_index + 1..host_index + 1, values); +} diff --git a/crates/cli/tests/coverage/agents/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs index 182508725..be0b3cb47 100644 --- a/crates/cli/tests/coverage/agents/launcher_tests.rs +++ b/crates/cli/tests/coverage/agents/launcher_tests.rs @@ -3,6 +3,7 @@ use super::*; use crate::configuration::{AgentCommandConfig, GatewayConfig}; +use crate::hooks::generated_hooks; use std::ffi::OsString; use std::sync::Mutex; @@ -373,11 +374,12 @@ fn codex_session_hook_trust_matches_codex_discovery_identity() { .unwrap(); let handler = &group["hooks"].as_array().unwrap()[0]; assert_eq!( - codex_command_hook_hash("user_prompt_submit", group, handler).unwrap(), + crate::agents::codex::launch::command_hook_hash("user_prompt_submit", group, handler) + .unwrap(), "sha256:83a9834ee494ffbd4acc85377c579d2c954f9797a9b8832924a326a6a44b0660" ); - let state = codex_session_hook_state_override(&generated).unwrap(); + let state = crate::agents::codex::launch::session_hook_state_override(&generated).unwrap(); assert_eq!( state.matches("trusted_hash=\"sha256:").count(), generated["hooks"].as_object().unwrap().len() @@ -609,7 +611,7 @@ fn insert_after_host_uses_the_authoritative_executable_index() { "--".to_string(), "codex".to_string(), ]; - insert_after_host(&mut argv, 1, ["--config".to_string()]); + crate::process::insert_after_host(&mut argv, 1, ["--config".to_string()]); assert_eq!( argv, vec!["wrapper", "codex", "--config", "exec", "--", "codex"] @@ -1326,7 +1328,7 @@ fn codex_session_hook_state_rejects_every_malformed_generated_shape() { ), ]; for (generated, expected) in malformed { - let error = codex_session_hook_state_override(&generated) + let error = crate::agents::codex::launch::session_hook_state_override(&generated) .unwrap_err() .to_string(); assert!(error.contains(expected), "{error}"); @@ -1347,7 +1349,8 @@ fn codex_session_hook_state_rejects_every_malformed_generated_shape() { }); let group = generated["hooks"]["PreToolUse"][0].as_object().unwrap(); let handler = &group["hooks"].as_array().unwrap()[0]; - let hash = codex_command_hook_hash("pre_tool_use", group, handler).unwrap(); + let hash = + crate::agents::codex::launch::command_hook_hash("pre_tool_use", group, handler).unwrap(); let normalized_handler = json!({ "type": "command", @@ -1357,14 +1360,16 @@ fn codex_session_hook_state_rejects_every_malformed_generated_shape() { }); assert_eq!( hash, - codex_command_hook_hash("pre_tool_use", group, &normalized_handler).unwrap() + crate::agents::codex::launch::command_hook_hash("pre_tool_use", group, &normalized_handler) + .unwrap() ); let mut without_matcher = group.clone(); without_matcher.remove("matcher"); assert_ne!( hash, - codex_command_hook_hash("pre_tool_use", &without_matcher, handler).unwrap() + crate::agents::codex::launch::command_hook_hash("pre_tool_use", &without_matcher, handler) + .unwrap() ); let mut without_status = normalized_handler; @@ -1374,10 +1379,11 @@ fn codex_session_hook_state_rejects_every_malformed_generated_shape() { .remove("statusMessage"); assert_ne!( hash, - codex_command_hook_hash("pre_tool_use", group, &without_status).unwrap() + crate::agents::codex::launch::command_hook_hash("pre_tool_use", group, &without_status) + .unwrap() ); - let state = codex_session_hook_state_override(&generated).unwrap(); + let state = crate::agents::codex::launch::session_hook_state_override(&generated).unwrap(); assert!(state.contains("pre_tool_use")); assert!(state.contains("trusted_hash")); assert!(state.contains("enabled=false")); @@ -1415,14 +1421,14 @@ fn hook_write_helpers_cover_toml_escaping() { .contains("hooks") ); - let groups = hook_groups_toml(&json!([{ + let groups = crate::agents::codex::launch::hook_groups_toml(&json!([{ "matcher": "Shell\"Run", "hooks": [{"command": "nemo-relay \"quoted\""}] }])); assert!(groups.contains("matcher=\"Shell\\\"Run\"")); assert!(groups.contains("command=\"nemo-relay \\\"quoted\\\"\"")); - let escaped = toml_string(r#"C:\tmp\"quoted""#); + let escaped = crate::agents::codex::launch::toml_string(r#"C:\tmp\"quoted""#); assert!(escaped.starts_with('"')); assert!(escaped.ends_with('"')); assert!(escaped.contains(r#"C:\\tmp\\"#)); From a94380e7b7f0f2437847271c8a6455b19168f9eb Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 22:44:34 -0400 Subject: [PATCH 045/102] refactor(cli): move Hermes launch preparation into agent slice Signed-off-by: Will Killian --- crates/cli/src/agents/hermes/launch.rs | 174 ++++++++++++++++++ crates/cli/src/agents/hermes/mod.rs | 1 + crates/cli/src/agents/mod.rs | 18 ++ crates/cli/src/filesystem/mod.rs | 1 + crates/cli/src/filesystem/temp.rs | 25 +++ crates/cli/src/process/launcher.rs | 172 +---------------- .../tests/coverage/agents/launcher_tests.rs | 17 +- 7 files changed, 231 insertions(+), 177 deletions(-) create mode 100644 crates/cli/src/agents/hermes/launch.rs create mode 100644 crates/cli/src/filesystem/temp.rs diff --git a/crates/cli/src/agents/hermes/launch.rs b/crates/cli/src/agents/hermes/launch.rs new file mode 100644 index 000000000..69bfd3a90 --- /dev/null +++ b/crates/cli/src/agents/hermes/launch.rs @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::{Path, PathBuf}; + +use crate::error::CliError; +use crate::process::PreparedAgentLaunch; + +pub(crate) fn prepare( + launch: &mut PreparedAgentLaunch, + hooks_path: Option<&Path>, + dry_run: bool, +) -> Result<(), CliError> { + let source_config = hooks_path_for_launch(hooks_path)?; + launch.env.push(("HERMES_ACCEPT_HOOKS".into(), "1".into())); + if dry_run { + launch.notes.push(format!( + "would create an isolated Hermes config overlay for {}", + source_config.display() + )); + return Ok(()); + } + let source_home = source_config.parent().ok_or_else(|| { + CliError::Launch(format!( + "Hermes config path {} has no parent directory", + source_config.display() + )) + })?; + let gateway_url = launch + .env + .iter() + .find_map(|(name, value)| { + (name == crate::configuration::GATEWAY_URL_ENV).then_some(value.as_str()) + }) + .expect("transparent runs always define their gateway URL"); + let overlay_home = create_overlay(source_home, &source_config, gateway_url)?; + launch + .env + .push(("HERMES_HOME".into(), overlay_home.display().to_string())); + launch.notes.push(format!( + "using an isolated Hermes config overlay for {}", + source_config.display() + )); + launch.temp_dirs.push(overlay_home); + Ok(()) +} + +fn create_overlay( + source_home: &Path, + source_config: &Path, + gateway_url: &str, +) -> Result { + let overlay = source_home + .parent() + .filter(|parent| parent.is_dir()) + .and_then(|parent| { + crate::filesystem::temp::private_temp_dir(parent, ".nemo-relay-hermes-home").ok() + }) + .map(Ok) + .unwrap_or_else(|| { + crate::filesystem::temp::private_system_temp_dir("nemo-relay-hermes-home") + })?; + if let Err(error) = populate_overlay(&overlay, source_home, source_config, gateway_url) { + let _ = std::fs::remove_dir_all(&overlay); + return Err(error); + } + Ok(overlay) +} + +pub(crate) fn populate_overlay( + overlay: &Path, + source_home: &Path, + source_config: &Path, + gateway_url: &str, +) -> Result<(), CliError> { + let absolute_overlay = overlay + .canonicalize() + .unwrap_or_else(|_| overlay.to_path_buf()); + match std::fs::read_dir(source_home) { + Ok(entries) => { + for entry in entries { + let entry = entry?; + let name = entry.file_name(); + if name == "config.yaml" || name == "shell-hooks-allowlist.json" { + continue; + } + let source = entry.path(); + let absolute_source = source.canonicalize().unwrap_or_else(|_| source.clone()); + if absolute_overlay.starts_with(absolute_source) { + continue; + } + link_state(&source, &overlay.join(name), entry.file_type()?.is_dir())?; + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(CliError::Io(error)), + } + let existing = match std::fs::read_to_string(source_config) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(CliError::Io(error)), + }; + let relay = std::env::current_exe() + .map(|path| path.canonicalize().unwrap_or(path)) + .map(crate::agents::portable_executable_path) + .unwrap_or_else(|_| PathBuf::from("nemo-relay")); + let contents = crate::agents::hermes::transparent_config(&existing, &relay, gateway_url)?; + std::fs::write(overlay.join("config.yaml"), contents)?; + Ok(()) +} + +fn link_state(source: &Path, destination: &Path, directory: bool) -> Result<(), CliError> { + #[cfg(unix)] + { + let _ = directory; + std::os::unix::fs::symlink(source, destination)?; + Ok(()) + } + #[cfg(windows)] + { + if directory { + create_windows_junction(source, destination)?; + } else if std::fs::hard_link(source, destination).is_err() { + std::fs::copy(source, destination)?; + } + Ok(()) + } + #[cfg(not(any(unix, windows)))] + { + let _ = directory; + std::fs::copy(source, destination)?; + Ok(()) + } +} + +#[cfg(windows)] +fn create_windows_junction(source: &Path, destination: &Path) -> Result<(), CliError> { + use std::os::windows::process::CommandExt; + + let mut command = std::process::Command::new( + std::env::var_os("COMSPEC").unwrap_or_else(|| std::ffi::OsString::from("cmd.exe")), + ); + command.args(["/d", "/e:on", "/v:off", "/s", "/c"]); + command + .raw_arg(r#""mklink /J "%NEMO_RELAY_JUNCTION_DEST%" "%NEMO_RELAY_JUNCTION_SOURCE%" >nul""#); + let status = command + .env("NEMO_RELAY_JUNCTION_SOURCE", source) + .env("NEMO_RELAY_JUNCTION_DEST", destination) + .status()?; + if status.success() { + Ok(()) + } else { + Err(CliError::Launch(format!( + "failed to create Hermes state junction {} -> {}: {status}", + destination.display(), + source.display() + ))) + } +} + +pub(crate) fn hooks_path_for_launch(configured: Option<&Path>) -> Result { + if let Some(path) = configured { + return Ok(path.to_path_buf()); + } + if let Some(home) = std::env::var_os("HERMES_HOME").filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(home).join("config.yaml")); + } + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .ok_or_else(|| { + CliError::Launch("could not resolve home directory for Hermes hooks".into()) + })?; + Ok(PathBuf::from(home).join(".hermes").join("config.yaml")) +} diff --git a/crates/cli/src/agents/hermes/mod.rs b/crates/cli/src/agents/hermes/mod.rs index 0aec233fa..aecb6dc0a 100644 --- a/crates/cli/src/agents/hermes/mod.rs +++ b/crates/cli/src/agents/hermes/mod.rs @@ -43,6 +43,7 @@ pub(super) fn parse_version(raw: &str) -> Option { mod config; mod files; mod integration; +pub(crate) mod launch; mod trust; pub(crate) use integration::*; diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index d8646e3b3..477e5f7ac 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -185,6 +185,24 @@ pub(crate) fn codex_mcp_env_vars_from( codex::assets::mcp_env_vars_from(environment, config) } +pub(crate) fn prepare_launch( + agent: CodingAgent, + launch: &mut crate::process::PreparedAgentLaunch, + gateway_url: &str, + resolved: &crate::configuration::ResolvedConfig, + dry_run: bool, +) -> Result<(), crate::error::CliError> { + match agent { + CodingAgent::Codex => codex::launch::prepare(launch, gateway_url), + CodingAgent::ClaudeCode => claude::launch::prepare(launch, gateway_url, dry_run), + CodingAgent::Hermes => hermes::launch::prepare( + launch, + resolved.agents.hermes.hooks_path.as_deref(), + dry_run, + ), + } +} + pub(crate) use claude::host::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; pub(crate) use codex::host::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; pub(crate) use shared::host::portable_executable_path; diff --git a/crates/cli/src/filesystem/mod.rs b/crates/cli/src/filesystem/mod.rs index c5c3b76d6..1bd31f590 100644 --- a/crates/cli/src/filesystem/mod.rs +++ b/crates/cli/src/filesystem/mod.rs @@ -4,6 +4,7 @@ //! Small, platform-aware filesystem primitives shared by CLI subsystems. mod locks; +pub(crate) mod temp; pub(crate) use locks::{LockAttempt, try_lock_exclusive, try_lock_shared, unlock_file}; diff --git a/crates/cli/src/filesystem/temp.rs b/crates/cli/src/filesystem/temp.rs new file mode 100644 index 000000000..e3c9bf23e --- /dev/null +++ b/crates/cli/src/filesystem/temp.rs @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::{Path, PathBuf}; + +use crate::error::CliError; + +pub(crate) fn private_temp_dir(parent: &Path, prefix: &str) -> Result { + let path = parent.join(format!("{prefix}-{}", uuid::Uuid::now_v7())); + #[cfg(unix)] + let builder = { + use std::os::unix::fs::DirBuilderExt; + let mut builder = std::fs::DirBuilder::new(); + builder.mode(0o700); + builder + }; + #[cfg(not(unix))] + let builder = std::fs::DirBuilder::new(); + builder.create(&path)?; + Ok(path) +} + +pub(crate) fn private_system_temp_dir(prefix: &str) -> Result { + private_temp_dir(&std::env::temp_dir(), prefix) +} diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index 68ce7f7ff..045ddb5f7 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -461,19 +461,7 @@ impl PreparedAgentLaunch { if let Some(path) = path_with_transparent_hook_dir() { run.env.push(("PATH".into(), path)); } - match agent { - CodingAgent::ClaudeCode => { - crate::agents::claude::launch::prepare(&mut run, gateway_url, dry_run)? - } - CodingAgent::Codex => crate::agents::codex::launch::prepare(&mut run, gateway_url)?, - CodingAgent::Hermes => { - if dry_run { - run.prepare_hermes_dry(resolved.agents.hermes.hooks_path.as_deref())?; - } else { - run.prepare_hermes(resolved.agents.hermes.hooks_path.as_deref())?; - } - } - } + crate::agents::prepare_launch(agent, &mut run, gateway_url, resolved, dry_run)?; Ok(run) } @@ -484,44 +472,9 @@ impl PreparedAgentLaunch { // Hermes discovers hooks from `.hermes/config.yaml` instead of command-line flags. A // process-private HERMES_HOME exposes dynamic hooks without rewriting user configuration. - fn prepare_hermes(&mut self, hooks_path: Option<&std::path::Path>) -> Result<(), CliError> { - let source_config = hermes_hooks_path(hooks_path)?; - let source_home = source_config.parent().ok_or_else(|| { - CliError::Launch(format!( - "Hermes config path {} has no parent directory", - source_config.display() - )) - })?; - let gateway_url = self - .env - .iter() - .find_map(|(name, value)| { - (name == crate::configuration::GATEWAY_URL_ENV).then_some(value.as_str()) - }) - .expect("transparent runs always define their gateway URL"); - let overlay_home = create_hermes_overlay(source_home, &source_config, gateway_url)?; - self.env.push(("HERMES_ACCEPT_HOOKS".into(), "1".into())); - self.env - .push(("HERMES_HOME".into(), overlay_home.display().to_string())); - self.notes.push(format!( - "using an isolated Hermes config overlay for {}", - source_config.display() - )); - self.temp_dirs.push(overlay_home); - Ok(()) - } // Records the Hermes hook file that would be patched during a real run without touching the // filesystem, preserving dry-run as an inspection-only operation. - fn prepare_hermes_dry(&mut self, hooks_path: Option<&std::path::Path>) -> Result<(), CliError> { - let path = hermes_hooks_path(hooks_path)?; - self.env.push(("HERMES_ACCEPT_HOOKS".into(), "1".into())); - self.notes.push(format!( - "would create an isolated Hermes config overlay for {}", - path.display() - )); - Ok(()) - } // Spawns the prepared child process with injected environment. // Stdio is inherited by default so agent interaction remains unchanged in transparent mode. @@ -855,97 +808,6 @@ fn path_with_transparent_hook_dir() -> Option { // Creates a per-process Hermes home whose user state points at the original profile while the // config and hook approval files remain private to this transparent run. Hermes has no standalone // config-file override, so `HERMES_HOME` is its supported process-scoped configuration boundary. -fn create_hermes_overlay( - source_home: &Path, - source_config: &Path, - gateway_url: &str, -) -> Result { - // Prefer a sibling of HERMES_HOME so Windows file hard links remain on one volume. Fall back - // to the OS temp directory when the profile parent is not writable; regular files then use a - // copy fallback, while profile directories remain live through junctions. - let overlay = source_home - .parent() - .filter(|parent| parent.is_dir()) - .and_then(|parent| private_temp_dir(parent, ".nemo-relay-hermes-home").ok()) - .map(Ok) - .unwrap_or_else(|| temp_dir("nemo-relay-hermes-home"))?; - if let Err(error) = populate_hermes_overlay(&overlay, source_home, source_config, gateway_url) { - let _ = std::fs::remove_dir_all(&overlay); - return Err(error); - } - Ok(overlay) -} - -fn populate_hermes_overlay( - overlay: &Path, - source_home: &Path, - source_config: &Path, - gateway_url: &str, -) -> Result<(), CliError> { - let absolute_overlay = overlay - .canonicalize() - .unwrap_or_else(|_| overlay.to_path_buf()); - match std::fs::read_dir(source_home) { - Ok(entries) => { - for entry in entries { - let entry = entry?; - let name = entry.file_name(); - if name == "config.yaml" || name == "shell-hooks-allowlist.json" { - continue; - } - let source = entry.path(); - let absolute_source = source.canonicalize().unwrap_or_else(|_| source.clone()); - if absolute_overlay.starts_with(absolute_source) { - continue; - } - link_hermes_state(&source, &overlay.join(name), entry.file_type()?.is_dir())?; - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(CliError::Io(error)), - } - let existing = match std::fs::read_to_string(source_config) { - Ok(raw) => raw, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(error) => return Err(CliError::Io(error)), - }; - let relay = std::env::current_exe() - .map(|path| path.canonicalize().unwrap_or(path)) - .map(crate::agents::portable_executable_path) - .unwrap_or_else(|_| PathBuf::from("nemo-relay")); - let contents = crate::agents::hermes::transparent_config(&existing, &relay, gateway_url)?; - std::fs::write(overlay.join("config.yaml"), contents)?; - Ok(()) -} - -fn link_hermes_state(source: &Path, destination: &Path, directory: bool) -> Result<(), CliError> { - #[cfg(unix)] - { - let _ = directory; - std::os::unix::fs::symlink(source, destination)?; - Ok(()) - } - #[cfg(windows)] - { - if directory { - create_windows_junction(source, destination)?; - } else { - match std::fs::hard_link(source, destination) { - Ok(()) => {} - Err(_) => { - std::fs::copy(source, destination)?; - } - } - } - Ok(()) - } - #[cfg(not(any(unix, windows)))] - { - let _ = directory; - std::fs::copy(source, destination)?; - Ok(()) - } -} #[cfg(windows)] fn create_windows_junction(source: &Path, destination: &Path) -> Result<(), CliError> { @@ -979,20 +841,6 @@ fn create_windows_junction(source: &Path, destination: &Path) -> Result<(), CliE // Chooses the Hermes config used as the source for a transparent-run overlay. If setup recorded a // specific path, reuse it; otherwise fall back to the active Hermes home. -fn hermes_hooks_path(configured: Option<&Path>) -> Result { - if let Some(path) = configured { - return Ok(path.to_path_buf()); - } - if let Some(home) = std::env::var_os("HERMES_HOME").filter(|value| !value.is_empty()) { - return Ok(PathBuf::from(home).join("config.yaml")); - } - let home = std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .ok_or_else(|| { - CliError::Launch("could not resolve home directory for Hermes hooks".into()) - })?; - Ok(PathBuf::from(home).join(".hermes").join("config.yaml")) -} // Converts JSON hook groups into inline TOML arrays for Codex `--config` flags. The function // preserves matchers when present and assumes generated hook groups contain one command hook. @@ -1001,24 +849,6 @@ fn hermes_hooks_path(configured: Option<&Path>) -> Result { // Creates a uniquely named directory under the OS temp directory. UUIDv7 avoids collisions // between concurrent transparent runs without keeping persistent coordination state. -fn temp_dir(prefix: &str) -> Result { - private_temp_dir(&std::env::temp_dir(), prefix) -} - -fn private_temp_dir(parent: &Path, prefix: &str) -> Result { - let path = parent.join(format!("{prefix}-{}", uuid::Uuid::now_v7())); - #[cfg(unix)] - let builder = { - use std::os::unix::fs::DirBuilderExt; - let mut builder = std::fs::DirBuilder::new(); - builder.mode(0o700); - builder - }; - #[cfg(not(unix))] - let builder = std::fs::DirBuilder::new(); - builder.create(&path)?; - Ok(path) -} #[cfg(test)] #[path = "../../tests/coverage/agents/launcher_tests.rs"] diff --git a/crates/cli/tests/coverage/agents/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs index be0b3cb47..c3c2eb4cb 100644 --- a/crates/cli/tests/coverage/agents/launcher_tests.rs +++ b/crates/cli/tests/coverage/agents/launcher_tests.rs @@ -839,7 +839,7 @@ fn process_private_directories_are_owner_only() { use std::os::unix::fs::PermissionsExt; let parent = tempfile::tempdir().unwrap(); - let path = private_temp_dir(parent.path(), "relay-private").unwrap(); + let path = crate::filesystem::temp::private_temp_dir(parent.path(), "relay-private").unwrap(); assert_eq!( std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o700 @@ -934,7 +934,7 @@ fn hermes_overlay_does_not_link_an_ancestor_entry_that_contains_it() { let overlay = source_home.path().join("overlay"); std::fs::create_dir(&overlay).unwrap(); - populate_hermes_overlay( + crate::agents::hermes::launch::populate_overlay( &overlay, source_home.path(), &source_config, @@ -986,7 +986,10 @@ fn hermes_hooks_path_prefers_configured_then_env_then_home() { let _guard = current_dir_lock().lock().unwrap(); let temp = tempfile::tempdir().unwrap(); let configured = temp.path().join("configured.yaml"); - assert_eq!(hermes_hooks_path(Some(&configured)).unwrap(), configured); + assert_eq!( + crate::agents::hermes::launch::hooks_path_for_launch(Some(&configured)).unwrap(), + configured + ); let _env = EnvScope::set(&[ ("HERMES_HOME", Some(temp.path().as_os_str())), @@ -994,7 +997,7 @@ fn hermes_hooks_path_prefers_configured_then_env_then_home() { ("USERPROFILE", None), ]); assert_eq!( - hermes_hooks_path(None).unwrap(), + crate::agents::hermes::launch::hooks_path_for_launch(None).unwrap(), temp.path().join("config.yaml") ); @@ -1005,13 +1008,15 @@ fn hermes_hooks_path_prefers_configured_then_env_then_home() { ("USERPROFILE", None), ]); assert_eq!( - hermes_hooks_path(None).unwrap(), + crate::agents::hermes::launch::hooks_path_for_launch(None).unwrap(), temp.path().join(".hermes/config.yaml") ); drop(_env); let _env = EnvScope::set(&[("HERMES_HOME", None), ("HOME", None), ("USERPROFILE", None)]); - let error = hermes_hooks_path(None).unwrap_err().to_string(); + let error = crate::agents::hermes::launch::hooks_path_for_launch(None) + .unwrap_err() + .to_string(); assert!(error.contains("could not resolve home directory")); } From a65d2ad0a7bf826e655461d0d9dad925185c7747 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 22:46:28 -0400 Subject: [PATCH 046/102] refactor(cli): relocate marketplace transactions under installation Signed-off-by: Will Killian --- crates/cli/src/agents/mod.rs | 1 - crates/cli/src/commands/diagnostics.rs | 7 +++++-- crates/cli/src/commands/install.rs | 11 +++++++---- crates/cli/src/diagnostics/mod.rs | 4 ++-- .../marketplace/assets.rs} | 0 .../install => installation/marketplace}/host.rs | 0 .../install => installation/marketplace}/mod.rs | 12 ++++++------ .../install => installation/marketplace}/setup.rs | 0 .../install => installation/marketplace}/state.rs | 0 crates/cli/src/installation/mod.rs | 1 + .../tests/coverage/agents/plugin_install_tests.rs | 2 +- crates/cli/tests/coverage/shared/doctor_tests.rs | 4 ++-- 12 files changed, 24 insertions(+), 18 deletions(-) rename crates/cli/src/{agents/install/marketplace.rs => installation/marketplace/assets.rs} (100%) rename crates/cli/src/{agents/install => installation/marketplace}/host.rs (100%) rename crates/cli/src/{agents/install => installation/marketplace}/mod.rs (99%) rename crates/cli/src/{agents/install => installation/marketplace}/setup.rs (100%) rename crates/cli/src/{agents/install => installation/marketplace}/state.rs (100%) diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 477e5f7ac..2b4ae474e 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -6,7 +6,6 @@ pub(crate) mod claude; pub(crate) mod codex; pub(crate) mod hermes; -pub(crate) mod install; pub(crate) mod shared; use semver::Version; diff --git a/crates/cli/src/commands/diagnostics.rs b/crates/cli/src/commands/diagnostics.rs index 9a77584a4..b862baffa 100644 --- a/crates/cli/src/commands/diagnostics.rs +++ b/crates/cli/src/commands/diagnostics.rs @@ -32,11 +32,14 @@ pub(super) async fn execute(command: DoctorCommand) -> Result Result { let request = command.into_runtime(); let candidates = target.agents(); let agents = if target.is_all() { - crate::agents::install::detected_install_agents(&candidates)? + crate::installation::marketplace::detected_install_agents(&candidates)? } else { candidates }; @@ -97,7 +97,7 @@ pub(super) fn install(command: InstallCommand) -> Result { )); } for agent in agents { - crate::agents::install::install(agent, request.clone())?; + crate::installation::marketplace::install(agent, request.clone())?; } Ok(ExitCode::SUCCESS) } @@ -107,7 +107,10 @@ pub(super) fn uninstall(command: UninstallCommand) -> Result let request = command.into_runtime(); let candidates = target.agents(); let agents = if target.is_all() { - crate::agents::install::installed_agents(&candidates, request.install_dir.as_deref())? + crate::installation::marketplace::installed_agents( + &candidates, + request.install_dir.as_deref(), + )? } else { candidates }; @@ -117,7 +120,7 @@ pub(super) fn uninstall(command: UninstallCommand) -> Result )); } for agent in agents { - crate::agents::install::uninstall(agent, request.clone())?; + crate::installation::marketplace::uninstall(agent, request.clone())?; } Ok(ExitCode::SUCCESS) } diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index 433864f56..d0d38089a 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -70,7 +70,7 @@ pub(crate) struct DoctorReport { pub environment: EnvironmentInfo, pub configuration: ConfigurationInfo, pub agents: Vec, - pub host_plugins: Vec, + pub host_plugins: Vec, pub observability: Vec, pub completions: Vec, } @@ -182,7 +182,7 @@ pub(crate) async fn collect_report( &plugin_diagnostics, ), agents: collect_agents(target_agent, &resolved).await, - host_plugins: crate::agents::install::collect_default_host_plugin_readiness(), + host_plugins: crate::installation::marketplace::collect_default_host_plugin_readiness(), observability: collect_observability(&resolved.gateway).await, completions: collect_completions(home.as_deref()), }) diff --git a/crates/cli/src/agents/install/marketplace.rs b/crates/cli/src/installation/marketplace/assets.rs similarity index 100% rename from crates/cli/src/agents/install/marketplace.rs rename to crates/cli/src/installation/marketplace/assets.rs diff --git a/crates/cli/src/agents/install/host.rs b/crates/cli/src/installation/marketplace/host.rs similarity index 100% rename from crates/cli/src/agents/install/host.rs rename to crates/cli/src/installation/marketplace/host.rs diff --git a/crates/cli/src/agents/install/mod.rs b/crates/cli/src/installation/marketplace/mod.rs similarity index 99% rename from crates/cli/src/agents/install/mod.rs rename to crates/cli/src/installation/marketplace/mod.rs index 9713f6833..a56fe1672 100644 --- a/crates/cli/src/agents/install/mod.rs +++ b/crates/cli/src/installation/marketplace/mod.rs @@ -3,8 +3,8 @@ //! Local marketplace installer for Claude Code and Codex plugins. +mod assets; mod host; -mod marketplace; mod setup; mod state; @@ -25,15 +25,15 @@ use crate::installation::generation::{ use crate::installation::{InstallRequest, UninstallRequest}; use crate::installation::operation_lock::{DEFAULT_OPERATION_LOCK_TIMEOUT, PluginOperationLock}; +use assets::{ + marketplace_manifest, plugin_hooks, plugin_manifest, plugin_mcp_config, + write_plugin_marketplace, write_plugin_marketplace_for_generation, +}; use host::{ CommandRunner, RealCommandRunner, host_registration_report, require_host_cli, require_relay, run_host_marketplace_registration, run_host_marketplace_removal, run_host_plugin_registration, run_host_plugin_removal, validate_relay_hook_forward, validate_relay_mcp, }; -use marketplace::{ - marketplace_manifest, plugin_hooks, plugin_manifest, plugin_mcp_config, - write_plugin_marketplace, write_plugin_marketplace_for_generation, -}; use setup::{ PluginSetupRunner, PluginSetupSnapshot, RealPluginSetupRunner, run_plugin_doctor_json, run_plugin_doctor_with_generation, run_plugin_setup_with_generation, run_plugin_uninstall, @@ -2377,7 +2377,7 @@ fn with_schema(mut value: Value) -> Value { } #[cfg(test)] -use marketplace::*; +use assets::*; #[cfg(test)] use setup::setup_action_description; #[cfg(test)] diff --git a/crates/cli/src/agents/install/setup.rs b/crates/cli/src/installation/marketplace/setup.rs similarity index 100% rename from crates/cli/src/agents/install/setup.rs rename to crates/cli/src/installation/marketplace/setup.rs diff --git a/crates/cli/src/agents/install/state.rs b/crates/cli/src/installation/marketplace/state.rs similarity index 100% rename from crates/cli/src/agents/install/state.rs rename to crates/cli/src/installation/marketplace/state.rs diff --git a/crates/cli/src/installation/mod.rs b/crates/cli/src/installation/mod.rs index f55e2bafc..c156d917b 100644 --- a/crates/cli/src/installation/mod.rs +++ b/crates/cli/src/installation/mod.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; pub(crate) mod generation; +pub(crate) mod marketplace; pub(crate) mod operation_lock; #[derive(Debug, Clone)] diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index b80053b08..f3223b76f 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -1028,7 +1028,7 @@ impl CrossProcessLockHolder { command .args([ "--exact", - "agents::install::tests::cross_process_lock_holder", + "installation::marketplace::tests::cross_process_lock_holder", "--nocapture", ]) .env(env_name, target) diff --git a/crates/cli/tests/coverage/shared/doctor_tests.rs b/crates/cli/tests/coverage/shared/doctor_tests.rs index 2a4dd480a..595d6a87f 100644 --- a/crates/cli/tests/coverage/shared/doctor_tests.rs +++ b/crates/cli/tests/coverage/shared/doctor_tests.rs @@ -201,7 +201,7 @@ fn exit_code_fails_when_an_installed_host_plugin_is_unready() { let mut report = empty_report(); report .host_plugins - .push(crate::agents::install::HostPluginReadiness { + .push(crate::installation::marketplace::HostPluginReadiness { host: "codex".into(), remediation: "nemo-relay install codex --force".into(), state_path: PathBuf::from("/tmp/codex.json"), @@ -209,7 +209,7 @@ fn exit_code_fails_when_an_installed_host_plugin_is_unready() { plugin: Some(PathBuf::from( "/tmp/codex-marketplace/plugins/nemo-relay-plugin", )), - checks: vec![crate::agents::install::HostPluginReadinessCheck { + checks: vec![crate::installation::marketplace::HostPluginReadinessCheck { name: "Host CLI".into(), ok: false, details: "required `codex` CLI was not found on PATH".into(), From e30cf5473855744ca336f9560459ae1ed28fb1cc Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 22:47:46 -0400 Subject: [PATCH 047/102] refactor(cli): route marketplace setup through agent slices Signed-off-by: Will Killian --- crates/cli/src/agents/mod.rs | 101 ++++++++++++++++++ .../cli/src/installation/marketplace/setup.rs | 92 ++-------------- 2 files changed, 110 insertions(+), 83 deletions(-) diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 2b4ae474e..4ff945192 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -202,6 +202,107 @@ pub(crate) fn prepare_launch( } } +pub(crate) enum SetupSnapshot { + Codex(CodexSetupSnapshot), + Claude(ClaudeSetupSnapshot), + #[cfg(test)] + Mock, +} + +pub(crate) fn setup_action_description(agent: CodingAgent, action: &str) -> String { + match (agent, action) { + (CodingAgent::Codex, "configure") => { + "configure Codex provider and trust plugin-owned hooks".into() + } + (CodingAgent::Codex, "restore") => "remove Codex provider and plugin hook trust".into(), + (CodingAgent::Codex, "doctor") => "check Codex provider and plugin-owned hooks".into(), + (CodingAgent::ClaudeCode, "configure") => { + "enable Claude Code provider routing through NeMo Relay".into() + } + (CodingAgent::ClaudeCode, "restore") => { + "restore Claude Code provider routing from NeMo Relay backup".into() + } + (CodingAgent::ClaudeCode, "doctor") => "check Claude Code provider routing".into(), + _ => unreachable!("unsupported setup action"), + } +} + +pub(crate) fn snapshot_setup(agent: CodingAgent) -> Result { + match agent { + CodingAgent::Codex => snapshot_codex_setup().map(SetupSnapshot::Codex), + CodingAgent::ClaudeCode => snapshot_claude_setup().map(SetupSnapshot::Claude), + CodingAgent::Hermes => unreachable!("Hermes does not use marketplace setup"), + } +} + +pub(crate) fn restore_setup_snapshot(snapshot: &SetupSnapshot) -> Result<(), String> { + match snapshot { + SetupSnapshot::Codex(snapshot) => restore_codex_setup(snapshot), + SetupSnapshot::Claude(snapshot) => restore_claude_setup(snapshot), + #[cfg(test)] + SetupSnapshot::Mock => Ok(()), + } +} + +pub(crate) fn setup_marketplace_plugin( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, +) -> Result<(), String> { + match agent { + CodingAgent::Codex => { + install_codex_plugin_with_generation(gateway_url, plugin_root, generation_token) + } + CodingAgent::ClaudeCode => enable_claude_provider(gateway_url), + CodingAgent::Hermes => unreachable!("Hermes does not use marketplace setup"), + } +} + +pub(crate) fn uninstall_marketplace_plugin( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, +) -> Result<(), String> { + match agent { + CodingAgent::Codex => uninstall_codex_plugin(gateway_url, plugin_root), + CodingAgent::ClaudeCode => restore_claude_provider(gateway_url), + CodingAgent::Hermes => unreachable!("Hermes does not use marketplace setup"), + } +} + +pub(crate) fn doctor_marketplace_plugin( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, +) -> Result<(), String> { + match agent { + CodingAgent::Codex => doctor_plugin_with_generation( + CodingAgent::Codex, + gateway_url, + plugin_root, + generation_token, + ), + CodingAgent::ClaudeCode => doctor_plugin(CodingAgent::ClaudeCode, gateway_url, plugin_root), + CodingAgent::Hermes => unreachable!("Hermes does not use marketplace setup"), + } +} + +pub(crate) fn doctor_marketplace_plugin_json( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, +) -> Result { + match agent { + CodingAgent::Codex => doctor_plugin_json(CodingAgent::Codex, gateway_url, plugin_root), + CodingAgent::ClaudeCode => { + doctor_plugin_json(CodingAgent::ClaudeCode, gateway_url, plugin_root) + } + CodingAgent::Hermes => unreachable!("Hermes does not use marketplace setup"), + } +} + pub(crate) use claude::host::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; pub(crate) use codex::host::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; pub(crate) use shared::host::portable_executable_path; diff --git a/crates/cli/src/installation/marketplace/setup.rs b/crates/cli/src/installation/marketplace/setup.rs index eed21ffa2..54572c58c 100644 --- a/crates/cli/src/installation/marketplace/setup.rs +++ b/crates/cli/src/installation/marketplace/setup.rs @@ -3,7 +3,6 @@ //! Host setup, restore, and doctor delegation. -use crate::agents as host; use crate::agents::CodingAgent; use serde_json::Value; use std::path::Path; @@ -87,21 +86,7 @@ pub(super) fn run_plugin_doctor_json( } pub(super) fn setup_action_description(host: CodingAgent, action: &str) -> String { - match (host, action) { - (CodingAgent::Codex, "configure") => { - "configure Codex provider and trust plugin-owned hooks".into() - } - (CodingAgent::Codex, "restore") => "remove Codex provider and plugin hook trust".into(), - (CodingAgent::Codex, "doctor") => "check Codex provider and plugin-owned hooks".into(), - (CodingAgent::ClaudeCode, "configure") => { - "enable Claude Code provider routing through NeMo Relay".into() - } - (CodingAgent::ClaudeCode, "restore") => { - "restore Claude Code provider routing from NeMo Relay backup".into() - } - (CodingAgent::ClaudeCode, "doctor") => "check Claude Code provider routing".into(), - (_, _) => unreachable!("unsupported setup action"), - } + crate::agents::setup_action_description(host, action) } pub(super) trait PluginSetupRunner { @@ -159,39 +144,19 @@ pub(super) trait PluginSetupRunner { pub(super) struct RealPluginSetupRunner; -pub(super) enum PluginSetupSnapshot { - Codex(host::CodexSetupSnapshot), - Claude(host::ClaudeSetupSnapshot), - #[cfg(test)] - Mock, -} +pub(super) use crate::agents::SetupSnapshot as PluginSetupSnapshot; impl PluginSetupRunner for RealPluginSetupRunner { fn snapshot(&self, host: CodingAgent) -> Result, String> { - match host { - CodingAgent::Codex => host::snapshot_codex_setup() - .map(PluginSetupSnapshot::Codex) - .map(Some), - CodingAgent::ClaudeCode => host::snapshot_claude_setup() - .map(PluginSetupSnapshot::Claude) - .map(Some), - CodingAgent::Hermes => { - unreachable!("all is expanded before plugin setup") - } - } + crate::agents::snapshot_setup(host).map(Some) } fn restore_snapshot(&self, snapshot: &PluginSetupSnapshot) -> Result<(), String> { - match snapshot { - PluginSetupSnapshot::Codex(snapshot) => host::restore_codex_setup(snapshot), - PluginSetupSnapshot::Claude(snapshot) => host::restore_claude_setup(snapshot), - #[cfg(test)] - PluginSetupSnapshot::Mock => Ok(()), - } + crate::agents::restore_setup_snapshot(snapshot) } fn refresh_gateway(&self) -> Result<(), String> { - host::stop_plugin_gateway() + crate::agents::stop_plugin_gateway() } fn setup( @@ -210,17 +175,7 @@ impl PluginSetupRunner for RealPluginSetupRunner { plugin_root: &Path, generation_token: Option<&str>, ) -> Result<(), String> { - match host { - CodingAgent::Codex => host::install_codex_plugin_with_generation( - gateway_url, - plugin_root, - generation_token, - ), - CodingAgent::ClaudeCode => host::enable_claude_provider(gateway_url), - CodingAgent::Hermes => { - unreachable!("all is expanded before plugin setup") - } - } + crate::agents::setup_marketplace_plugin(host, gateway_url, plugin_root, generation_token) } fn uninstall( @@ -229,13 +184,7 @@ impl PluginSetupRunner for RealPluginSetupRunner { gateway_url: &str, plugin_root: &Path, ) -> Result<(), String> { - match host { - CodingAgent::Codex => host::uninstall_codex_plugin(gateway_url, plugin_root), - CodingAgent::ClaudeCode => host::restore_claude_provider(gateway_url), - CodingAgent::Hermes => { - unreachable!("all is expanded before plugin uninstall") - } - } + crate::agents::uninstall_marketplace_plugin(host, gateway_url, plugin_root) } fn doctor( @@ -254,20 +203,7 @@ impl PluginSetupRunner for RealPluginSetupRunner { plugin_root: &Path, generation_token: Option<&str>, ) -> Result<(), String> { - match host { - CodingAgent::Codex => host::doctor_plugin_with_generation( - CodingAgent::Codex, - gateway_url, - plugin_root, - generation_token, - ), - CodingAgent::ClaudeCode => { - host::doctor_plugin(CodingAgent::ClaudeCode, gateway_url, plugin_root) - } - CodingAgent::Hermes => { - unreachable!("all is expanded before plugin doctor") - } - } + crate::agents::doctor_marketplace_plugin(host, gateway_url, plugin_root, generation_token) } fn doctor_json( @@ -276,17 +212,7 @@ impl PluginSetupRunner for RealPluginSetupRunner { gateway_url: &str, plugin_root: &Path, ) -> Result { - match host { - CodingAgent::Codex => { - host::doctor_plugin_json(CodingAgent::Codex, gateway_url, plugin_root) - } - CodingAgent::ClaudeCode => { - host::doctor_plugin_json(CodingAgent::ClaudeCode, gateway_url, plugin_root) - } - CodingAgent::Hermes => { - unreachable!("all is expanded before plugin doctor") - } - } + crate::agents::doctor_marketplace_plugin_json(host, gateway_url, plugin_root) } } From af8fbfe5844817b5f167dd15c058946c8735b55e Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 22:51:48 -0400 Subject: [PATCH 048/102] refactor(cli): move Hermes installation into agent slice Signed-off-by: Will Killian --- crates/cli/src/agents/hermes/install.rs | 191 ++++++++++++++++ crates/cli/src/agents/hermes/mod.rs | 1 + crates/cli/src/agents/mod.rs | 24 ++ crates/cli/src/commands/install.rs | 4 +- .../cli/src/installation/marketplace/host.rs | 16 +- .../cli/src/installation/marketplace/mod.rs | 214 ++---------------- .../cli/src/installation/marketplace/state.rs | 12 +- .../coverage/agents/plugin_install_tests.rs | 34 +-- 8 files changed, 271 insertions(+), 225 deletions(-) create mode 100644 crates/cli/src/agents/hermes/install.rs diff --git a/crates/cli/src/agents/hermes/install.rs b/crates/cli/src/agents/hermes/install.rs new file mode 100644 index 000000000..d656dab3f --- /dev/null +++ b/crates/cli/src/agents/hermes/install.rs @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; +use std::process::ExitCode; + +use crate::agents::CodingAgent; +use crate::error::CliError; +use crate::installation::marketplace::HostPluginReadiness; +use crate::installation::marketplace::host::{ + CommandRunner, RealCommandRunner, require_host_cli, require_relay, validate_host_version, + validate_relay_hook_forward, validate_relay_mcp, +}; +use crate::installation::marketplace::state::PluginInstallOptions; +use crate::installation::{InstallRequest, UninstallRequest}; + +pub(crate) fn install(command: InstallRequest) -> Result { + let options = options(command.dry_run, command.skip_doctor, command.force); + let runner = RealCommandRunner; + require_host_cli(CodingAgent::Hermes, &options, &runner).map_err(CliError::Install)?; + validate_host_version(CodingAgent::Hermes, &options, &runner).map_err(CliError::Install)?; + let relay = require_relay(&options, &runner).map_err(CliError::Install)?; + validate_relay_hook_forward(&relay, &options, &runner).map_err(CliError::Install)?; + validate_relay_mcp(&relay, &options, &runner).map_err(CliError::Install)?; + let config = config_path().map_err(CliError::Install)?; + if options.dry_run { + println!("configure Hermes MCP and hooks at {}", config.display()); + return Ok(ExitCode::SUCCESS); + } + super::install_persistent(&config, &relay) + .map_err(|error| CliError::Install(error.to_string()))?; + if !options.skip_doctor { + super::diagnose_persistent(&config).map_err(CliError::Install)?; + } + println!("installed Hermes integration"); + Ok(ExitCode::SUCCESS) +} + +pub(crate) fn uninstall(command: UninstallRequest) -> Result { + let config = config_path().map_err(CliError::Install)?; + if command.dry_run { + println!( + "remove Relay-owned Hermes MCP and hooks from {}", + config.display() + ); + return Ok(ExitCode::SUCCESS); + } + super::uninstall_persistent(&config).map_err(|error| CliError::Install(error.to_string()))?; + println!("uninstalled Hermes integration"); + Ok(ExitCode::SUCCESS) +} + +pub(crate) fn config_path() -> Result { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .map(|home| super::user_config_path(&home)) + .ok_or_else(|| "cannot determine home directory (set HOME or USERPROFILE)".into()) +} + +fn options(dry_run: bool, skip_doctor: bool, force: bool) -> PluginInstallOptions { + PluginInstallOptions { + install_dir: PathBuf::new(), + operation_lock_dir: PathBuf::new(), + force, + dry_run, + skip_doctor, + } +} + +pub(crate) fn doctor( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + let report = doctor_json_value(options, runner)?; + for check in report["readiness_checks"] + .as_array() + .expect("Hermes readiness checks are an array") + { + println!( + "{}: {} ({})", + check["name"].as_str().unwrap_or_default(), + if check["ok"] == serde_json::json!(true) { + "ok" + } else { + "failed" + }, + check["details"].as_str().unwrap_or_default() + ); + } + (report["ok"] == serde_json::json!(true)) + .then_some(()) + .ok_or_else(|| { + format!( + "Hermes integration doctor checks failed; remediation: {}", + report["remediation"].as_str().unwrap_or_default() + ) + }) +} + +pub(crate) fn doctor_json_value( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + let config = config_path()?; + let readiness = collect_readiness(&config, options, runner); + Ok(serde_json::json!({ + "ok": readiness.ok(), + "host": readiness.host, + "remediation": readiness.remediation, + "config": config, + "readiness_checks": readiness.checks + })) +} + +pub(crate) fn collect_readiness( + config: &std::path::Path, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> HostPluginReadiness { + let mut readiness = HostPluginReadiness { + host: CodingAgent::Hermes.install_arg().into(), + remediation: format!( + "nemo-relay install {} --force", + CodingAgent::Hermes.install_arg() + ), + state_path: config.to_path_buf(), + marketplace: None, + plugin: None, + checks: Vec::new(), + relay: None, + host_plugin_registered: None, + host_marketplace_registered: None, + plugin_setup: None, + }; + + let host_cli = require_host_cli(CodingAgent::Hermes, options, runner); + readiness.push( + "Host CLI", + host_cli + .as_ref() + .map(|_| "hermes is available".into()) + .map_err(Clone::clone), + ); + let version = validate_host_version(CodingAgent::Hermes, options, runner); + if version.is_err() { + readiness.remediation = format!( + "upgrade to {}, then run `nemo-relay install {} --force`", + CodingAgent::Hermes.version_requirement(), + CodingAgent::Hermes.install_arg() + ); + } + readiness.push( + "Hermes Agent version", + version.map(|_| format!("{} is installed", CodingAgent::Hermes.version_requirement())), + ); + + let relay = super::configured_relay_executable(config); + readiness.push( + "Configured Relay binary", + relay + .as_ref() + .map(|path| format!("found at {}", path.display())) + .map_err(Clone::clone), + ); + match relay { + Ok(relay) => { + readiness.relay = Some(relay.clone()); + readiness.push( + "Relay hook support", + validate_relay_hook_forward(&relay, options, runner) + .map(|_| "hook-forward is supported".into()), + ); + readiness.push( + "Relay MCP support", + validate_relay_mcp(&relay, options, runner) + .map(|_| "native mcp subcommand is supported".into()), + ); + } + Err(error) => { + let unavailable = || format!("cannot verify configured Relay capabilities: {error}"); + readiness.push("Relay hook support", Err(unavailable())); + readiness.push("Relay MCP support", Err(unavailable())); + } + } + readiness.push( + "Hermes MCP, hooks, and trust", + super::diagnose_persistent(config), + ); + readiness +} diff --git a/crates/cli/src/agents/hermes/mod.rs b/crates/cli/src/agents/hermes/mod.rs index aecb6dc0a..8aeaf87f2 100644 --- a/crates/cli/src/agents/hermes/mod.rs +++ b/crates/cli/src/agents/hermes/mod.rs @@ -42,6 +42,7 @@ pub(super) fn parse_version(raw: &str) -> Option { mod config; mod files; +pub(crate) mod install; mod integration; pub(crate) mod launch; mod trust; diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 4ff945192..a341f91a9 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -303,6 +303,30 @@ pub(crate) fn doctor_marketplace_plugin_json( } } +pub(crate) fn install_integration( + agent: CodingAgent, + command: crate::installation::InstallRequest, +) -> Result { + match agent { + CodingAgent::Hermes => hermes::install::install(command), + CodingAgent::Codex | CodingAgent::ClaudeCode => { + crate::installation::marketplace::install(agent, command) + } + } +} + +pub(crate) fn uninstall_integration( + agent: CodingAgent, + command: crate::installation::UninstallRequest, +) -> Result { + match agent { + CodingAgent::Hermes => hermes::install::uninstall(command), + CodingAgent::Codex | CodingAgent::ClaudeCode => { + crate::installation::marketplace::uninstall(agent, command) + } + } +} + pub(crate) use claude::host::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; pub(crate) use codex::host::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; pub(crate) use shared::host::portable_executable_path; diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index 39a169b04..f44cd3844 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -97,7 +97,7 @@ pub(super) fn install(command: InstallCommand) -> Result { )); } for agent in agents { - crate::installation::marketplace::install(agent, request.clone())?; + crate::agents::install_integration(agent, request.clone())?; } Ok(ExitCode::SUCCESS) } @@ -120,7 +120,7 @@ pub(super) fn uninstall(command: UninstallCommand) -> Result )); } for agent in agents { - crate::installation::marketplace::uninstall(agent, request.clone())?; + crate::agents::uninstall_integration(agent, request.clone())?; } Ok(ExitCode::SUCCESS) } diff --git a/crates/cli/src/installation/marketplace/host.rs b/crates/cli/src/installation/marketplace/host.rs index 8c34182d1..3013007d5 100644 --- a/crates/cli/src/installation/marketplace/host.rs +++ b/crates/cli/src/installation/marketplace/host.rs @@ -288,7 +288,7 @@ fn parse_json_command_output(command: &str, output: CommandOutput) -> Result Result { @@ -301,7 +301,7 @@ pub(super) fn require_relay( .map(crate::agents::portable_executable_path) } -pub(super) fn validate_relay_hook_forward( +pub(crate) fn validate_relay_hook_forward( relay: &Path, options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -321,7 +321,7 @@ pub(super) fn validate_relay_hook_forward( } } -pub(super) fn validate_relay_mcp( +pub(crate) fn validate_relay_mcp( relay: &Path, options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -341,7 +341,7 @@ pub(super) fn validate_relay_mcp( } } -pub(super) fn require_host_cli( +pub(crate) fn require_host_cli( host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -356,7 +356,7 @@ pub(super) fn require_host_cli( .ok_or_else(|| format!("required `{cli}` CLI was not found on PATH")) } -pub(super) fn validate_host_version( +pub(crate) fn validate_host_version( host: CodingAgent, options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -452,7 +452,7 @@ pub(super) fn format_command(program: &str, args: &[String]) -> String { } #[derive(Debug, Clone)] -pub(super) struct CommandOutput { +pub(crate) struct CommandOutput { pub(super) status: i32, pub(super) stdout: String, pub(super) stderr: String, @@ -468,7 +468,7 @@ impl CommandOutput { } } -pub(super) trait CommandRunner { +pub(crate) trait CommandRunner { fn current_executable(&self) -> Result; fn resolve_executable(&self, command: &str) -> Result, String>; fn run(&self, program: &Path, args: &[String]) -> Result; @@ -476,7 +476,7 @@ pub(super) trait CommandRunner { fn run_capture(&self, program: &Path, args: &[String]) -> Result; } -pub(super) struct RealCommandRunner; +pub(crate) struct RealCommandRunner; impl CommandRunner for RealCommandRunner { fn current_executable(&self) -> Result { diff --git a/crates/cli/src/installation/marketplace/mod.rs b/crates/cli/src/installation/marketplace/mod.rs index a56fe1672..20ced3e54 100644 --- a/crates/cli/src/installation/marketplace/mod.rs +++ b/crates/cli/src/installation/marketplace/mod.rs @@ -4,9 +4,9 @@ //! Local marketplace installer for Claude Code and Codex plugins. mod assets; -mod host; +pub(crate) mod host; mod setup; -mod state; +pub(crate) mod state; use std::fs; use std::path::{Path, PathBuf}; @@ -100,7 +100,7 @@ impl HostPluginReadiness { self.checks.iter().all(|check| check.ok) } - fn push(&mut self, name: impl Into, result: Result) { + pub(crate) fn push(&mut self, name: impl Into, result: Result) { match result { Ok(details) => self.checks.push(HostPluginReadinessCheck { name: name.into(), @@ -133,7 +133,8 @@ pub(crate) fn collect_default_host_plugin_readiness() -> Vec>(); - if hermes_config_path().is_ok_and(|path| crate::agents::hermes::persistent_state_exists(&path)) + if crate::agents::hermes::install::config_path() + .is_ok_and(|path| crate::agents::hermes::persistent_state_exists(&path)) { hosts.push(CodingAgent::Hermes); } @@ -158,9 +159,8 @@ fn spawn_default_host_plugin_readiness( install_dir: PathBuf, ) -> PendingHostPluginReadiness { let state_path = match host { - CodingAgent::Hermes => { - hermes_config_path().unwrap_or_else(|_| state_path(CodingAgent::Hermes, &install_dir)) - } + CodingAgent::Hermes => crate::agents::hermes::install::config_path() + .unwrap_or_else(|_| state_path(CodingAgent::Hermes, &install_dir)), _ => state_path(host, &install_dir), }; let worker_state_path = state_path.clone(); @@ -176,9 +176,11 @@ fn spawn_default_host_plugin_readiness( let runner = RealCommandRunner; let setup_runner = RealPluginSetupRunner; let readiness = match host { - CodingAgent::Hermes => { - collect_hermes_host_readiness(&worker_state_path, &options, &runner) - } + CodingAgent::Hermes => crate::agents::hermes::install::collect_readiness( + &worker_state_path, + &options, + &runner, + ), _ => collect_host_plugin_readiness(host, &options, &runner, &setup_runner), }; let _ = sender.send(readiness); @@ -254,14 +256,7 @@ pub(crate) fn install(host: CodingAgent, command: InstallRequest) -> Result install_hermes_host(options, runner), - _ => install_host(host, options, runner, setup_runner), - }, - ) + run_for_host(host, &options, install_host) } pub(crate) fn uninstall( @@ -283,14 +278,7 @@ pub(crate) fn uninstall( dry_run: command.dry_run, skip_doctor: true, }; - run_for_host( - host, - &options, - |host, options, runner, setup_runner| match host { - CodingAgent::Hermes => uninstall_hermes_host(options), - _ => uninstall_host(host, options, runner, setup_runner), - }, - ) + run_for_host(host, &options, uninstall_host) } pub(crate) fn doctor( @@ -320,7 +308,7 @@ pub(crate) fn doctor( host, &options, |host, options, runner, setup_runner| match host { - CodingAgent::Hermes => doctor_hermes_host(options, runner), + CodingAgent::Hermes => crate::agents::hermes::install::doctor(options, runner), _ => doctor_host(host, options, runner, setup_runner), }, )?; @@ -357,7 +345,9 @@ fn doctor_json( .iter() .copied() .map(|host| match host { - CodingAgent::Hermes => doctor_hermes_json_value(options, &runner), + CodingAgent::Hermes => { + crate::agents::hermes::install::doctor_json_value(options, &runner) + } _ => doctor_host_json_value(host, options, &runner, &setup_runner), }) .collect::, _>>() @@ -397,7 +387,7 @@ fn select_available_agents( .map_err(CliError::Install)? .is_some(), HostSelectionMode::InstalledState => match candidate { - CodingAgent::Hermes => hermes_config_path() + CodingAgent::Hermes => crate::agents::hermes::install::config_path() .is_ok_and(|path| crate::agents::hermes::persistent_state_exists(&path)), _ => state_path(candidate, install_dir).exists(), }, @@ -436,172 +426,6 @@ pub(crate) fn installed_agents( ) } -fn hermes_config_path() -> Result { - std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(PathBuf::from) - .map(|home| crate::agents::hermes::user_config_path(&home)) - .ok_or_else(|| "cannot determine home directory (set HOME or USERPROFILE)".into()) -} - -fn install_hermes_host( - options: &PluginInstallOptions, - runner: &dyn CommandRunner, -) -> Result<(), String> { - require_host_cli(CodingAgent::Hermes, options, runner)?; - host::validate_host_version(CodingAgent::Hermes, options, runner)?; - let relay = require_relay(options, runner)?; - validate_relay_hook_forward(&relay, options, runner)?; - validate_relay_mcp(&relay, options, runner)?; - let config = hermes_config_path()?; - if options.dry_run { - println!("configure Hermes MCP and hooks at {}", config.display()); - return Ok(()); - } - crate::agents::hermes::install_persistent(&config, &relay) - .map_err(|error| error.to_string())?; - if !options.skip_doctor { - crate::agents::hermes::diagnose_persistent(&config)?; - } - println!("installed Hermes integration"); - Ok(()) -} - -fn uninstall_hermes_host(options: &PluginInstallOptions) -> Result<(), String> { - let config = hermes_config_path()?; - if options.dry_run { - println!( - "remove Relay-owned Hermes MCP and hooks from {}", - config.display() - ); - return Ok(()); - } - crate::agents::hermes::uninstall_persistent(&config).map_err(|error| error.to_string())?; - println!("uninstalled Hermes integration"); - Ok(()) -} - -fn doctor_hermes_host( - options: &PluginInstallOptions, - runner: &dyn CommandRunner, -) -> Result<(), String> { - let report = doctor_hermes_json_value(options, runner)?; - for check in report["readiness_checks"] - .as_array() - .expect("Hermes readiness checks are an array") - { - println!( - "{}: {} ({})", - check["name"].as_str().unwrap_or_default(), - if check["ok"] == json!(true) { - "ok" - } else { - "failed" - }, - check["details"].as_str().unwrap_or_default() - ); - } - (report["ok"] == json!(true)).then_some(()).ok_or_else(|| { - format!( - "Hermes integration doctor checks failed; remediation: {}", - report["remediation"].as_str().unwrap_or_default() - ) - }) -} - -fn doctor_hermes_json_value( - options: &PluginInstallOptions, - runner: &dyn CommandRunner, -) -> Result { - let config = hermes_config_path()?; - let readiness = collect_hermes_host_readiness(&config, options, runner); - Ok(json!({ - "ok": readiness.ok(), - "host": readiness.host, - "remediation": readiness.remediation, - "config": config, - "readiness_checks": readiness.checks - })) -} - -fn collect_hermes_host_readiness( - config: &Path, - options: &PluginInstallOptions, - runner: &dyn CommandRunner, -) -> HostPluginReadiness { - let mut readiness = HostPluginReadiness { - host: CodingAgent::Hermes.install_arg().into(), - remediation: format!( - "nemo-relay install {} --force", - CodingAgent::Hermes.install_arg() - ), - state_path: config.to_path_buf(), - marketplace: None, - plugin: None, - checks: Vec::new(), - relay: None, - host_plugin_registered: None, - host_marketplace_registered: None, - plugin_setup: None, - }; - - let host_cli = require_host_cli(CodingAgent::Hermes, options, runner); - readiness.push( - "Host CLI", - host_cli - .as_ref() - .map(|_| "hermes is available".into()) - .map_err(Clone::clone), - ); - let version = host::validate_host_version(CodingAgent::Hermes, options, runner); - if version.is_err() { - readiness.remediation = format!( - "upgrade to {}, then run `nemo-relay install {} --force`", - CodingAgent::Hermes.version_requirement(), - CodingAgent::Hermes.install_arg() - ); - } - readiness.push( - "Hermes Agent version", - version.map(|_| format!("{} is installed", CodingAgent::Hermes.version_requirement())), - ); - - let relay = crate::agents::hermes::configured_relay_executable(config); - readiness.push( - "Configured Relay binary", - relay - .as_ref() - .map(|path| format!("found at {}", path.display())) - .map_err(Clone::clone), - ); - match relay { - Ok(relay) => { - readiness.relay = Some(relay.clone()); - readiness.push( - "Relay hook support", - validate_relay_hook_forward(&relay, options, runner) - .map(|_| "hook-forward is supported".into()), - ); - readiness.push( - "Relay MCP support", - validate_relay_mcp(&relay, options, runner) - .map(|_| "native mcp subcommand is supported".into()), - ); - } - Err(error) => { - let unavailable = || format!("cannot verify configured Relay capabilities: {error}"); - readiness.push("Relay hook support", Err(unavailable())); - readiness.push("Relay MCP support", Err(unavailable())); - } - } - - readiness.push( - "Hermes MCP, hooks, and trust", - crate::agents::hermes::diagnose_persistent(config), - ); - readiness -} - fn install_host( host: CodingAgent, options: &PluginInstallOptions, diff --git a/crates/cli/src/installation/marketplace/state.rs b/crates/cli/src/installation/marketplace/state.rs index 845f47b6d..720c1bb1e 100644 --- a/crates/cli/src/installation/marketplace/state.rs +++ b/crates/cli/src/installation/marketplace/state.rs @@ -16,12 +16,12 @@ use crate::installation::generation::GENERATION_FILE_NAME; use super::PLUGIN_NAME; #[derive(Debug, Clone)] -pub(super) struct PluginInstallOptions { - pub(super) install_dir: PathBuf, - pub(super) operation_lock_dir: PathBuf, - pub(super) force: bool, - pub(super) dry_run: bool, - pub(super) skip_doctor: bool, +pub(crate) struct PluginInstallOptions { + pub(crate) install_dir: PathBuf, + pub(crate) operation_lock_dir: PathBuf, + pub(crate) force: bool, + pub(crate) dry_run: bool, + pub(crate) skip_doctor: bool, } #[derive(Debug, Clone, Copy)] diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index f3223b76f..4070a2039 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -2088,7 +2088,7 @@ fn hermes_doctor_probes_the_configured_relay_and_top_level_doctor_discovers_it() let home = dir.path().join("home"); std::fs::create_dir_all(&home).unwrap(); let _home = HomeScope::enter(&home); - let config = hermes_config_path().unwrap(); + let config = crate::agents::hermes::install::config_path().unwrap(); let relay = home .join("bin") .join(format!("nemo-relay{}", std::env::consts::EXE_SUFFIX)); @@ -2100,7 +2100,8 @@ fn hermes_doctor_probes_the_configured_relay_and_top_level_doctor_discovers_it() .with_executable("hermes", "/bin/hermes") .with_capture_output("/bin/hermes --version", "Hermes Agent v0.18.2 (test)\n"); - let report = doctor_hermes_json_value(&options(dir.path()), &runner).unwrap(); + let report = + crate::agents::hermes::install::doctor_json_value(&options(dir.path()), &runner).unwrap(); assert_eq!(report["ok"], json!(true)); assert_eq!( @@ -2136,12 +2137,13 @@ fn hermes_doctor_probes_the_configured_relay_and_top_level_doctor_discovers_it() assert!(hermes.marketplace.is_none()); assert!(hermes.plugin.is_none()); - doctor_hermes_host(&options(dir.path()), &runner).unwrap(); + crate::agents::hermes::install::doctor(&options(dir.path()), &runner).unwrap(); std::fs::remove_file(&configured_relay).unwrap(); - let error = doctor_hermes_host(&options(dir.path()), &runner).unwrap_err(); + let error = crate::agents::hermes::install::doctor(&options(dir.path()), &runner).unwrap_err(); assert!(error.contains("doctor checks failed"), "{error}"); - let report = doctor_hermes_json_value(&options(dir.path()), &runner).unwrap(); + let report = + crate::agents::hermes::install::doctor_json_value(&options(dir.path()), &runner).unwrap(); let failed = report["readiness_checks"].as_array().unwrap(); for expected in [ "Configured Relay binary", @@ -2163,13 +2165,14 @@ fn hermes_install_and_uninstall_dry_runs_preserve_persistent_state() { let home = dir.path().join("home"); std::fs::create_dir_all(&home).unwrap(); let _home = HomeScope::enter(&home); - let mut dry_run = options(dir.path()); - dry_run.dry_run = true; - dry_run.skip_doctor = false; - let runner = MockRunner::default(); - - install_hermes_host(&dry_run, &runner).unwrap(); - let config = hermes_config_path().unwrap(); + crate::agents::hermes::install::install(crate::installation::InstallRequest { + install_dir: Some(dir.path().to_path_buf()), + force: false, + dry_run: true, + skip_doctor: false, + }) + .unwrap(); + let config = crate::agents::hermes::install::config_path().unwrap(); assert!(!config.exists()); let hermes_home = config.parent().unwrap(); @@ -2185,12 +2188,15 @@ fn hermes_install_and_uninstall_dry_runs_preserve_persistent_state() { std::fs::write(path, contents).unwrap(); } - uninstall_hermes_host(&dry_run).unwrap(); + crate::agents::hermes::install::uninstall(crate::installation::UninstallRequest { + install_dir: Some(dir.path().to_path_buf()), + dry_run: true, + }) + .unwrap(); for (path, contents) in sentinels { assert_eq!(std::fs::read(path).unwrap(), contents); } - assert!(runner.quiet_commands().is_empty()); } #[test] From 54729e99a10b8ff4289296d97f5db48f1a6265d6 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 22:52:41 -0400 Subject: [PATCH 049/102] refactor(cli): add agent-owned marketplace entrypoints Signed-off-by: Will Killian --- crates/cli/src/agents/claude/install.rs | 16 ++++++++++++++++ crates/cli/src/agents/claude/mod.rs | 1 + crates/cli/src/agents/codex/install.rs | 16 ++++++++++++++++ crates/cli/src/agents/codex/mod.rs | 1 + crates/cli/src/agents/mod.rs | 10 ++++------ 5 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 crates/cli/src/agents/claude/install.rs create mode 100644 crates/cli/src/agents/codex/install.rs diff --git a/crates/cli/src/agents/claude/install.rs b/crates/cli/src/agents/claude/install.rs new file mode 100644 index 000000000..338646a2c --- /dev/null +++ b/crates/cli/src/agents/claude/install.rs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +use crate::agents::CodingAgent; +use crate::error::CliError; +use crate::installation::{InstallRequest, UninstallRequest}; + +pub(crate) fn install(command: InstallRequest) -> Result { + crate::installation::marketplace::install(CodingAgent::ClaudeCode, command) +} + +pub(crate) fn uninstall(command: UninstallRequest) -> Result { + crate::installation::marketplace::uninstall(CodingAgent::ClaudeCode, command) +} diff --git a/crates/cli/src/agents/claude/mod.rs b/crates/cli/src/agents/claude/mod.rs index 2ee283965..2c98250a1 100644 --- a/crates/cli/src/agents/claude/mod.rs +++ b/crates/cli/src/agents/claude/mod.rs @@ -7,6 +7,7 @@ use super::AgentDescriptor; pub(super) mod assets; pub(super) mod host; +pub(crate) mod install; pub(crate) mod launch; pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { diff --git a/crates/cli/src/agents/codex/install.rs b/crates/cli/src/agents/codex/install.rs new file mode 100644 index 000000000..c9ff485f7 --- /dev/null +++ b/crates/cli/src/agents/codex/install.rs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +use crate::agents::CodingAgent; +use crate::error::CliError; +use crate::installation::{InstallRequest, UninstallRequest}; + +pub(crate) fn install(command: InstallRequest) -> Result { + crate::installation::marketplace::install(CodingAgent::Codex, command) +} + +pub(crate) fn uninstall(command: UninstallRequest) -> Result { + crate::installation::marketplace::uninstall(CodingAgent::Codex, command) +} diff --git a/crates/cli/src/agents/codex/mod.rs b/crates/cli/src/agents/codex/mod.rs index 153b70294..903434d41 100644 --- a/crates/cli/src/agents/codex/mod.rs +++ b/crates/cli/src/agents/codex/mod.rs @@ -8,6 +8,7 @@ use super::AgentDescriptor; pub(super) mod app_server; pub(super) mod assets; pub(super) mod host; +pub(crate) mod install; pub(crate) mod launch; pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index a341f91a9..aa30cf503 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -309,9 +309,8 @@ pub(crate) fn install_integration( ) -> Result { match agent { CodingAgent::Hermes => hermes::install::install(command), - CodingAgent::Codex | CodingAgent::ClaudeCode => { - crate::installation::marketplace::install(agent, command) - } + CodingAgent::Codex => codex::install::install(command), + CodingAgent::ClaudeCode => claude::install::install(command), } } @@ -321,9 +320,8 @@ pub(crate) fn uninstall_integration( ) -> Result { match agent { CodingAgent::Hermes => hermes::install::uninstall(command), - CodingAgent::Codex | CodingAgent::ClaudeCode => { - crate::installation::marketplace::uninstall(agent, command) - } + CodingAgent::Codex => codex::install::uninstall(command), + CodingAgent::ClaudeCode => claude::install::uninstall(command), } } From 3c19b0cbf085c52123b854d9c3843cb4f3f731d4 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 22:56:00 -0400 Subject: [PATCH 050/102] refactor(cli): move bounded reads into filesystem Signed-off-by: Will Killian --- crates/cli/src/configuration/mod.rs | 101 ++---------------- crates/cli/src/filesystem/bounded.rs | 93 ++++++++++++++++ crates/cli/src/filesystem/mod.rs | 1 + crates/cli/src/plugins/lifecycle.rs | 7 +- .../cli/src/plugins/lifecycle/environment.rs | 6 +- crates/cli/src/plugins/lifecycle/trust.rs | 35 +++--- .../cli/tests/coverage/shared/config_tests.rs | 3 + .../shared/plugins_lifecycle_tests.rs | 6 +- 8 files changed, 138 insertions(+), 114 deletions(-) create mode 100644 crates/cli/src/filesystem/bounded.rs diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs index d87394277..ae6c12aa6 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -40,9 +40,6 @@ pub(crate) const BOOTSTRAP_FINGERPRINT_ENV: &str = "NEMO_RELAY_BOOTSTRAP_FINGERP pub(crate) const PLUGIN_IDLE_TIMEOUT_ENV: &str = "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS"; pub(crate) const RELAY_PLUGIN_ID: &str = "nemo-relay-plugin@nemo-relay-local"; pub(crate) const RELAY_SOURCE_PLUGIN_ID: &str = "nemo-relay-plugin@nemo-relay"; -/// Maximum regular-file size hashed into persistent gateway identity (512 MiB). -pub(crate) const MAX_BOOTSTRAP_IDENTITY_FILE_BYTES: u64 = 512 * 1024 * 1024; - pub(crate) const DEFAULT_MAX_HOOK_PAYLOAD_BYTES: usize = 20 * 1024 * 1024; pub(crate) const DEFAULT_MAX_PASSTHROUGH_BODY_BYTES: usize = 100 * 1024 * 1024; pub(crate) const GATEWAY_URL_ENV: &str = "NEMO_RELAY_GATEWAY_URL"; @@ -488,8 +485,10 @@ fn resolve_dynamic_plugin_relative_path(manifest_path: &Path, reference: &str) - fn bootstrap_file_digest(path: &Path, description: &str) -> Result { let mut context = digest::Context::new(&digest::SHA256); - stream_bounded_regular_file(path, description, |bytes| context.update(bytes)) - .map_err(CliError::Config)?; + crate::filesystem::bounded::stream_bounded_regular_file(path, description, |bytes| { + context.update(bytes) + }) + .map_err(CliError::Config)?; Ok(context .finish() .as_ref() @@ -520,8 +519,11 @@ pub(crate) fn load_bounded_dynamic_plugin_manifest_bytes( manifest_path.display() )) })?; - let bytes = read_bounded_regular_file(&normalized, "dynamic plugin manifest") - .map_err(CliError::Config)?; + let bytes = crate::filesystem::bounded::read_bounded_regular_file( + &normalized, + "dynamic plugin manifest", + ) + .map_err(CliError::Config)?; let contents = std::str::from_utf8(&bytes).map_err(|error| { CliError::Config(format!( "dynamic plugin manifest {} is not UTF-8: {error}", @@ -533,91 +535,6 @@ pub(crate) fn load_bounded_dynamic_plugin_manifest_bytes( Ok((manifest, normalized.to_string_lossy().into_owned(), bytes)) } -pub(crate) fn read_bounded_regular_file(path: &Path, description: &str) -> Result, String> { - let mut bytes = Vec::new(); - stream_bounded_regular_file(path, description, |chunk| bytes.extend_from_slice(chunk))?; - Ok(bytes) -} - -pub(crate) fn stream_bounded_regular_file( - path: &Path, - description: &str, - mut consume: impl FnMut(&[u8]), -) -> Result<(), String> { - const BUFFER_BYTES: usize = 64 * 1024; - let metadata = fs::symlink_metadata(path).map_err(|error| { - format!( - "failed to inspect {description} {} for persistent gateway identity: {error}", - path.display() - ) - })?; - if !metadata.file_type().is_file() { - return Err(format!( - "{description} {} must be a regular file for persistent gateway identity", - path.display() - )); - } - if metadata.len() > MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { - return Err(format!( - "{description} {} exceeds the {MAX_BOOTSTRAP_IDENTITY_FILE_BYTES}-byte persistent gateway identity budget", - path.display() - )); - } - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); - } - let mut file = options.open(path).map_err(|error| { - format!( - "failed to read {description} {} for persistent gateway identity: {error}", - path.display() - ) - })?; - let opened_metadata = file.metadata().map_err(|error| { - format!( - "failed to inspect {description} {} for persistent gateway identity: {error}", - path.display() - ) - })?; - if !opened_metadata.file_type().is_file() { - return Err(format!( - "{description} {} must be a regular file for persistent gateway identity", - path.display() - )); - } - if opened_metadata.len() > MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { - return Err(format!( - "{description} {} exceeds the {MAX_BOOTSTRAP_IDENTITY_FILE_BYTES}-byte persistent gateway identity budget", - path.display() - )); - } - let mut buffer = [0_u8; BUFFER_BYTES]; - let mut total = 0_u64; - loop { - let read = file.read(&mut buffer).map_err(|error| { - format!( - "failed to read {description} {} for persistent gateway identity: {error}", - path.display() - ) - })?; - if read == 0 { - break; - } - total = total.saturating_add(read as u64); - if total > MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { - return Err(format!( - "{description} {} exceeds the {MAX_BOOTSTRAP_IDENTITY_FILE_BYTES}-byte persistent gateway identity budget", - path.display() - )); - } - consume(&buffer[..read]); - } - Ok(()) -} - const BOOTSTRAP_HMAC_KEY_BYTES: usize = 32; const BOOTSTRAP_HMAC_LOCK_TIMEOUT: Duration = Duration::from_secs(5); const BOOTSTRAP_CHALLENGE_DOMAIN: &[u8] = b"nemo-relay/bootstrap-health/v1\0"; diff --git a/crates/cli/src/filesystem/bounded.rs b/crates/cli/src/filesystem/bounded.rs new file mode 100644 index 000000000..6e653680c --- /dev/null +++ b/crates/cli/src/filesystem/bounded.rs @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fs::OpenOptions; +use std::io::Read; +use std::path::Path; + +pub(crate) const MAX_BOUNDED_FILE_BYTES: u64 = 512 * 1024 * 1024; + +pub(crate) fn read_bounded_regular_file(path: &Path, description: &str) -> Result, String> { + let mut bytes = Vec::new(); + stream_bounded_regular_file(path, description, |chunk| bytes.extend_from_slice(chunk))?; + Ok(bytes) +} + +pub(crate) fn stream_bounded_regular_file( + path: &Path, + description: &str, + mut consume: impl FnMut(&[u8]), +) -> Result<(), String> { + const BUFFER_BYTES: usize = 64 * 1024; + let metadata = std::fs::symlink_metadata(path).map_err(|error| { + format!( + "failed to inspect {description} {} for persistent gateway identity: {error}", + path.display() + ) + })?; + if !metadata.file_type().is_file() { + return Err(format!( + "{description} {} must be a regular file for persistent gateway identity", + path.display() + )); + } + if metadata.len() > MAX_BOUNDED_FILE_BYTES { + return Err(format!( + "{description} {} exceeds the {MAX_BOUNDED_FILE_BYTES}-byte persistent gateway identity budget", + path.display() + )); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + let mut file = options.open(path).map_err(|error| { + format!( + "failed to read {description} {} for persistent gateway identity: {error}", + path.display() + ) + })?; + let opened_metadata = file.metadata().map_err(|error| { + format!( + "failed to inspect {description} {} for persistent gateway identity: {error}", + path.display() + ) + })?; + if !opened_metadata.file_type().is_file() { + return Err(format!( + "{description} {} must be a regular file for persistent gateway identity", + path.display() + )); + } + if opened_metadata.len() > MAX_BOUNDED_FILE_BYTES { + return Err(format!( + "{description} {} exceeds the {MAX_BOUNDED_FILE_BYTES}-byte persistent gateway identity budget", + path.display() + )); + } + let mut buffer = [0_u8; BUFFER_BYTES]; + let mut total = 0_u64; + loop { + let read = file.read(&mut buffer).map_err(|error| { + format!( + "failed to read {description} {} for persistent gateway identity: {error}", + path.display() + ) + })?; + if read == 0 { + break; + } + total = total.saturating_add(read as u64); + if total > MAX_BOUNDED_FILE_BYTES { + return Err(format!( + "{description} {} exceeds the {MAX_BOUNDED_FILE_BYTES}-byte persistent gateway identity budget", + path.display() + )); + } + consume(&buffer[..read]); + } + Ok(()) +} diff --git a/crates/cli/src/filesystem/mod.rs b/crates/cli/src/filesystem/mod.rs index 1bd31f590..3c22ab2df 100644 --- a/crates/cli/src/filesystem/mod.rs +++ b/crates/cli/src/filesystem/mod.rs @@ -3,6 +3,7 @@ //! Small, platform-aware filesystem primitives shared by CLI subsystems. +pub(crate) mod bounded; mod locks; pub(crate) mod temp; diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index 154787652..259aca492 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -18,10 +18,13 @@ use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use crate::configuration::{ - MAX_BOOTSTRAP_IDENTITY_FILE_BYTES, ResolvedConfig, ResolvedDynamicPluginConfig, - load_bounded_dynamic_plugin_manifest_bytes, read_bounded_regular_file, resolve_plugins_config, + ResolvedConfig, ResolvedDynamicPluginConfig, load_bounded_dynamic_plugin_manifest_bytes, + resolve_plugins_config, }; use crate::error::{CliError, PluginLifecycleFailureKind}; +use crate::filesystem::bounded::{ + MAX_BOUNDED_FILE_BYTES as MAX_BOOTSTRAP_IDENTITY_FILE_BYTES, read_bounded_regular_file, +}; use crate::plugins::policy::{ EvaluatedDynamicPluginHostPolicy, evaluate_dynamic_plugin_host_policy, }; diff --git a/crates/cli/src/plugins/lifecycle/environment.rs b/crates/cli/src/plugins/lifecycle/environment.rs index 783591788..c7bdcf73e 100644 --- a/crates/cli/src/plugins/lifecycle/environment.rs +++ b/crates/cli/src/plugins/lifecycle/environment.rs @@ -464,15 +464,15 @@ fn digest_environment_directory( path.display() )); } - let bytes = crate::configuration::read_bounded_regular_file( + let bytes = crate::filesystem::bounded::read_bounded_regular_file( &source, "managed Python environment file", )?; *total = total.saturating_add(bytes.len() as u64); - if *total > crate::configuration::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { + if *total > crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES { return Err(format!( "managed Python environment exceeds the {}-byte attestation budget", - crate::configuration::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES )); } digest.update(relative.to_string_lossy().as_bytes()); diff --git a/crates/cli/src/plugins/lifecycle/trust.rs b/crates/cli/src/plugins/lifecycle/trust.rs index 5136ec0c7..97a366671 100644 --- a/crates/cli/src/plugins/lifecycle/trust.rs +++ b/crates/cli/src/plugins/lifecycle/trust.rs @@ -317,12 +317,14 @@ fn verify_signature( let signature_path = resolve_artifact_path(manifest_ref, signature_ref); let signature_bytes = read_signature_bytes(&signature_path)?; - let artifact_bytes = - crate::configuration::read_bounded_regular_file(artifact_path, "dynamic plugin artifact") - .map_err(|error| DynamicPluginTrustFailure::ArtifactRead { - path: artifact_path.to_path_buf(), - error, - })?; + let artifact_bytes = crate::filesystem::bounded::read_bounded_regular_file( + artifact_path, + "dynamic plugin artifact", + ) + .map_err(|error| DynamicPluginTrustFailure::ArtifactRead { + path: artifact_path.to_path_buf(), + error, + })?; let mut parse_errors = Vec::new(); for trusted_public_key in trusted_public_keys { @@ -348,11 +350,12 @@ fn verify_signature( } fn read_signature_bytes(path: &Path) -> TrustResult> { - let raw = crate::configuration::read_bounded_regular_file(path, "dynamic plugin signature") - .map_err(|error| DynamicPluginTrustFailure::SignatureRead { - path: path.to_path_buf(), - error, - })?; + let raw = + crate::filesystem::bounded::read_bounded_regular_file(path, "dynamic plugin signature") + .map_err(|error| DynamicPluginTrustFailure::SignatureRead { + path: path.to_path_buf(), + error, + })?; let trimmed = String::from_utf8_lossy(&raw).trim().to_owned(); if trimmed.is_empty() { return Err(DynamicPluginTrustFailure::SignatureRead { @@ -402,9 +405,13 @@ fn resolve_artifact_path(manifest_ref: &str, artifact_ref: &str) -> PathBuf { fn file_sha256(path: &Path) -> Result { let mut digest = Sha256::new(); - crate::configuration::stream_bounded_regular_file(path, "dynamic plugin artifact", |bytes| { - digest.update(bytes); - }) + crate::filesystem::bounded::stream_bounded_regular_file( + path, + "dynamic plugin artifact", + |bytes| { + digest.update(bytes); + }, + ) .map_err(std::io::Error::other)?; Ok(format!( "sha256:{}", diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index 1336633ec..51735db81 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; +use crate::filesystem::bounded::{ + MAX_BOUNDED_FILE_BYTES as MAX_BOOTSTRAP_IDENTITY_FILE_BYTES, read_bounded_regular_file, +}; use crate::hooks::GatewayMode; use axum::http::HeaderValue; use base64::Engine; diff --git a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs index a61217b9d..78b6eef2c 100644 --- a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs @@ -763,7 +763,7 @@ entrypoint = "../worker-runtime/worker.sh" .write(true) .open(&artifact_path) .unwrap() - .set_len(crate::configuration::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1) + .set_len(crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES + 1) .unwrap(); std::fs::set_permissions(&artifact_path, std::fs::Permissions::from_mode(0o755)).unwrap(); std::fs::write(&manifest_path, b"not valid TOML").unwrap(); @@ -771,7 +771,7 @@ entrypoint = "../worker-runtime/worker.sh" .write(true) .open(&manifest_path) .unwrap() - .set_len(crate::configuration::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1) + .set_len(crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES + 1) .unwrap(); let error = match load_worker_plugins(vec![WorkerPluginLoadSpec { @@ -995,7 +995,7 @@ fn activation_snapshot_budgets_reject_entry_and_byte_overflow() { let byte_error = byte_budget .record_bytes( path, - usize::try_from(crate::configuration::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES).unwrap() + 1, + usize::try_from(crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES).unwrap() + 1, ) .unwrap_err() .to_string(); From 935198c9b5a366b546643ea8254aaf963dc3e359 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 22:57:51 -0400 Subject: [PATCH 051/102] refactor(cli): split filesystem atomic operations Signed-off-by: Will Killian --- crates/cli/src/filesystem/atomic.rs | 540 +++++++++++++++++++++++++++ crates/cli/src/filesystem/mod.rs | 545 +--------------------------- 2 files changed, 549 insertions(+), 536 deletions(-) create mode 100644 crates/cli/src/filesystem/atomic.rs diff --git a/crates/cli/src/filesystem/atomic.rs b/crates/cli/src/filesystem/atomic.rs new file mode 100644 index 000000000..440fe7173 --- /dev/null +++ b/crates/cli/src/filesystem/atomic.rs @@ -0,0 +1,540 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Atomic replacement and private-file permission handling. + +use std::fs::{self, File, OpenOptions, Permissions}; +use std::io::{self, Write}; +use std::path::Path; +#[cfg(test)] +use std::path::PathBuf; + +/// Atomically replace `path` with `bytes`, creating its parent directory when needed. +pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { + let permissions = fs::metadata(path) + .ok() + .filter(|metadata| metadata.is_file()) + .map(|metadata| metadata.permissions()); + atomic_write_with_permissions(path, bytes, permissions.as_ref()) +} + +/// Atomically replace a secret-bearing file with owner-only access. +/// +/// The restriction is applied to the temporary file at creation, before its name is visible to +/// another process. This avoids both a permissive umask on Unix and inherited broad directory +/// access-control entries on Windows. +pub(crate) fn atomic_write_private(path: &Path, bytes: &[u8]) -> Result<(), String> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + atomic_write_impl( + path, + bytes, + Some(&Permissions::from_mode(0o600)), + AtomicWritePrivacy::Private, + None, + ) + } + #[cfg(windows)] + { + atomic_write_impl(path, bytes, None, AtomicWritePrivacy::Private, None) + } + #[cfg(not(any(unix, windows)))] + { + atomic_write_impl(path, bytes, None, AtomicWritePrivacy::Standard, None) + } +} + +/// Atomically replace `path` while applying `permissions` before the new bytes become visible. +pub(crate) fn atomic_write_with_permissions( + path: &Path, + bytes: &[u8], + permissions: Option<&Permissions>, +) -> Result<(), String> { + atomic_write_impl(path, bytes, permissions, AtomicWritePrivacy::Standard, None) +} + +/// Atomically restore bytes with an exact Windows discretionary access-control descriptor. +#[cfg(windows)] +pub(crate) fn atomic_write_with_windows_dacl( + path: &Path, + bytes: &[u8], + dacl: &[u8], +) -> Result<(), String> { + atomic_write_impl(path, bytes, None, AtomicWritePrivacy::Standard, Some(dacl)) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum AtomicWritePrivacy { + Standard, + Private, +} + +fn atomic_write_impl( + path: &Path, + bytes: &[u8], + permissions: Option<&Permissions>, + privacy: AtomicWritePrivacy, + windows_dacl: Option<&[u8]>, +) -> Result<(), String> { + #[cfg(test)] + if take_injected_atomic_write_failure(path) { + return Err(format!( + "failed to write {}: injected test failure", + path.display() + )); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("nemo-relay"); + let tmp = path.with_file_name(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7())); + let result = (|| { + let mut file = open_atomic_temp(&tmp, path, permissions, privacy, windows_dacl) + .map_err(|error| format!("failed to create {}: {error}", tmp.display()))?; + file.write_all(bytes) + .map_err(|error| format!("failed to write {}: {error}", tmp.display()))?; + file.sync_all() + .map_err(|error| format!("failed to sync {}: {error}", tmp.display()))?; + if let Some(permissions) = permissions { + fs::set_permissions(&tmp, permissions.clone()).map_err(|error| { + format!("failed to set permissions on {}: {error}", tmp.display()) + })?; + } + drop(file); + replace_file(&tmp, path) + })(); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result +} + +#[cfg(unix)] +fn open_atomic_temp( + tmp: &Path, + _target: &Path, + permissions: Option<&Permissions>, + _privacy: AtomicWritePrivacy, + _windows_dacl: Option<&[u8]>, +) -> io::Result { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + if let Some(permissions) = permissions { + options.mode(permissions.mode() & 0o7777); + } + options.open(tmp) +} + +#[cfg(windows)] +fn open_atomic_temp( + tmp: &Path, + target: &Path, + _permissions: Option<&Permissions>, + privacy: AtomicWritePrivacy, + windows_dacl: Option<&[u8]>, +) -> io::Result { + if let Some(descriptor) = windows_dacl { + return create_windows_file(tmp, descriptor.as_ptr().cast_mut().cast()); + } + if privacy == AtomicWritePrivacy::Private { + return create_private_windows_file(tmp); + } + if target.exists() { + let mut descriptor = read_windows_dacl(target)?; + return create_windows_file(tmp, descriptor.as_mut_ptr().cast()); + } + OpenOptions::new().create_new(true).write(true).open(tmp) +} + +#[cfg(not(any(unix, windows)))] +fn open_atomic_temp( + tmp: &Path, + _target: &Path, + _permissions: Option<&Permissions>, + _privacy: AtomicWritePrivacy, + _windows_dacl: Option<&[u8]>, +) -> io::Result { + OpenOptions::new().create_new(true).write(true).open(tmp) +} + +#[cfg(windows)] +fn create_private_windows_file(path: &Path) -> io::Result { + with_private_windows_descriptor(|descriptor| create_windows_file(path, descriptor)) +} + +/// Opens or creates a secret-bearing file without inheriting a broad Windows DACL. +/// +/// The protected owner/System descriptor is applied by `CreateFileW` when the file is created and +/// repaired before an existing file is returned to the caller. The containing directory must be +/// protected separately before this function is called. +#[cfg(windows)] +pub(crate) fn open_private_windows_file(path: &Path) -> io::Result { + use windows_sys::Win32::Foundation::{GENERIC_READ, GENERIC_WRITE}; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_ALWAYS, + }; + + let file = with_private_windows_descriptor(|descriptor| { + open_windows_file( + path, + descriptor, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + OPEN_ALWAYS, + ) + })?; + protect_private_windows_path(path)?; + Ok(file) +} + +/// Applies and verifies the protected owner/System DACL used for secret-bearing Windows paths. +#[cfg(windows)] +pub(crate) fn protect_private_windows_path(path: &Path) -> io::Result<()> { + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, SetFileSecurityW, + }; + + if !windows_path_owned_by_current_user(path)? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{} is not owned by the current user", path.display()), + )); + } + let path_wide = windows_wide(path.as_os_str()); + with_private_windows_descriptor(|descriptor| { + // SAFETY: The path and descriptor remain valid for the duration of the call. + if unsafe { + SetFileSecurityW( + path_wide.as_ptr(), + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + descriptor, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(()) + })?; + if !windows_path_is_private(path)? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "failed to verify protected owner/System access on {}", + path.display() + ), + )); + } + Ok(()) +} + +#[cfg(windows)] +fn with_private_windows_descriptor( + operation: impl FnOnce(windows_sys::Win32::Security::PSECURITY_DESCRIPTOR) -> io::Result, +) -> io::Result { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::PSECURITY_DESCRIPTOR; + + let descriptor_sddl = windows_wide("D:P(A;;FA;;;OW)(A;;FA;;;SY)"); + let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: The SDDL string is NUL-terminated and `descriptor` points to writable storage. The + // returned allocation is released with LocalFree below. + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + descriptor_sddl.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let result = operation(descriptor); + // SAFETY: `descriptor` was allocated by ConvertStringSecurityDescriptor... and has not been + // freed or transferred. + unsafe { LocalFree(descriptor.cast()) }; + result +} + +#[cfg(windows)] +fn windows_path_owned_by_current_user(path: &Path) -> io::Result { + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::Security::{ + EqualSid, GetSecurityDescriptorOwner, GetTokenInformation, OWNER_SECURITY_INFORMATION, + PSID, TOKEN_QUERY, TOKEN_USER, TokenUser, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut descriptor = read_windows_security_descriptor(path, OWNER_SECURITY_INFORMATION)?; + let mut owner: PSID = std::ptr::null_mut(); + let mut defaulted = 0; + // SAFETY: The self-relative descriptor buffer is valid and both outputs point to writable + // storage for the duration of the call. + if unsafe { + GetSecurityDescriptorOwner(descriptor.as_mut_ptr().cast(), &mut owner, &mut defaulted) + } == 0 + || owner.is_null() + { + return Err(io::Error::last_os_error()); + } + + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: GetCurrentProcess returns a valid pseudo-handle and `token` is writable. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let result = (|| { + let mut required = 0; + // SAFETY: This sizing call intentionally supplies a null output buffer. + unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut required) }; + if required == 0 { + return Err(io::Error::last_os_error()); + } + let word = std::mem::size_of::(); + let mut buffer = vec![0_usize; (required as usize).div_ceil(word)]; + // SAFETY: The aligned buffer has at least `required` writable bytes. + if unsafe { + GetTokenInformation( + token, + TokenUser, + buffer.as_mut_ptr().cast(), + required, + &mut required, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: GetTokenInformation initialized a TOKEN_USER at the aligned buffer address. + let user = unsafe { &*buffer.as_ptr().cast::() }; + // SAFETY: Both SID pointers remain valid while their backing buffers are alive. + Ok(unsafe { EqualSid(owner, user.User.Sid) != 0 }) + })(); + // SAFETY: `token` is an owned handle returned by OpenProcessToken. + unsafe { CloseHandle(token) }; + result +} + +#[cfg(windows)] +pub(crate) fn windows_path_is_private(path: &Path) -> io::Result { + use windows_sys::Win32::Security::{DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION}; + + if !windows_path_owned_by_current_user(path)? { + return Ok(false); + } + let mut actual = read_windows_security_descriptor( + path, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + )?; + let actual = windows_dacl_sddl(actual.as_mut_ptr().cast())?; + with_private_windows_descriptor(|expected| Ok(actual == windows_dacl_sddl(expected)?)) +} + +#[cfg(windows)] +fn windows_dacl_sddl( + descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, +) -> io::Result { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; + + let mut rendered = std::ptr::null_mut(); + let mut rendered_len = 0; + // SAFETY: The descriptor is valid and both output pointers reference writable storage. + if unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor, + SDDL_REVISION_1, + DACL_SECURITY_INFORMATION, + &mut rendered, + &mut rendered_len, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: The API returned `rendered_len` initialized UTF-16 code units. + let value = String::from_utf16_lossy(unsafe { + std::slice::from_raw_parts(rendered, rendered_len as usize) + }) + .trim_end_matches('\0') + .to_string(); + // SAFETY: `rendered` was allocated by ConvertSecurityDescriptor... above. + unsafe { LocalFree(rendered.cast()) }; + Ok(value) +} + +#[cfg(windows)] +pub(crate) fn read_windows_dacl(path: &Path) -> io::Result> { + use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; + + read_windows_security_descriptor(path, DACL_SECURITY_INFORMATION) +} + +#[cfg(windows)] +fn read_windows_security_descriptor( + path: &Path, + information: windows_sys::Win32::Security::OBJECT_SECURITY_INFORMATION, +) -> io::Result> { + use windows_sys::Win32::Security::GetFileSecurityW; + + let path = windows_wide(path.as_os_str()); + let mut required = 0; + // SAFETY: This sizing call intentionally supplies a null output buffer and valid length + // pointer, as required by GetFileSecurityW. + unsafe { + GetFileSecurityW( + path.as_ptr(), + information, + std::ptr::null_mut(), + 0, + &mut required, + ) + }; + if required == 0 { + return Err(io::Error::last_os_error()); + } + let mut descriptor = vec![0_u8; required as usize]; + // SAFETY: The path is NUL-terminated and the allocated output buffer is `required` bytes. + if unsafe { + GetFileSecurityW( + path.as_ptr(), + information, + descriptor.as_mut_ptr().cast(), + required, + &mut required, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(descriptor) +} + +#[cfg(windows)] +fn create_windows_file( + path: &Path, + descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, +) -> io::Result { + use windows_sys::Win32::Foundation::GENERIC_WRITE; + use windows_sys::Win32::Storage::FileSystem::CREATE_NEW; + + open_windows_file(path, descriptor, GENERIC_WRITE, 0, CREATE_NEW) +} + +#[cfg(windows)] +fn open_windows_file( + path: &Path, + descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, + desired_access: u32, + share_mode: u32, + creation_disposition: u32, +) -> io::Result { + use std::os::windows::io::FromRawHandle; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; + use windows_sys::Win32::Storage::FileSystem::{CreateFileW, FILE_ATTRIBUTE_NORMAL}; + + let path = windows_wide(path.as_os_str()); + let attributes = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: descriptor, + bInheritHandle: 0, + }; + // SAFETY: The path and security descriptor remain valid for the call, and a successful owned + // handle is transferred to File. + let handle = unsafe { + CreateFileW( + path.as_ptr(), + desired_access, + share_mode, + &attributes, + creation_disposition, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: `handle` is a newly created, valid, owned file handle. + Ok(unsafe { File::from_raw_handle(handle) }) +} + +#[cfg(windows)] +fn windows_wide(value: impl AsRef) -> Vec { + use std::os::windows::ffi::OsStrExt; + + value.as_ref().encode_wide().chain(Some(0)).collect() +} + +#[cfg(test)] +pub(crate) fn fail_next_atomic_write(path: &Path) { + injected_atomic_write_failures() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(path.to_path_buf()); +} + +#[cfg(test)] +fn take_injected_atomic_write_failure(path: &Path) -> bool { + injected_atomic_write_failures() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .remove(path) +} + +#[cfg(test)] +fn injected_atomic_write_failures() -> &'static std::sync::Mutex> +{ + use std::collections::HashSet; + use std::sync::{Mutex, OnceLock}; + + static FAILURES: OnceLock>> = OnceLock::new(); + FAILURES.get_or_init(Default::default) +} + +#[cfg(not(windows))] +fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { + fs::rename(tmp, path).map_err(|error| format!("failed to replace {}: {error}", path.display())) +} + +#[cfg(windows)] +fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + + let tmp = windows_wide(tmp.as_os_str()); + let path_wide = windows_wide(path.as_os_str()); + // SAFETY: Both paths are NUL-terminated and remain valid for the call. The files share a + // directory, so Windows performs one replace-existing rename without a missing-target window. + if unsafe { + MoveFileExW( + tmp.as_ptr(), + path_wide.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } == 0 + { + return Err(format!( + "failed to replace {}: {}", + path.display(), + io::Error::last_os_error() + )); + } + Ok(()) +} diff --git a/crates/cli/src/filesystem/mod.rs b/crates/cli/src/filesystem/mod.rs index 3c22ab2df..7fd6da7b0 100644 --- a/crates/cli/src/filesystem/mod.rs +++ b/crates/cli/src/filesystem/mod.rs @@ -1,549 +1,22 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Small, platform-aware filesystem primitives shared by CLI subsystems. +//! Platform-aware filesystem primitives shared by CLI subsystems. +mod atomic; pub(crate) mod bounded; mod locks; pub(crate) mod temp; -pub(crate) use locks::{LockAttempt, try_lock_exclusive, try_lock_shared, unlock_file}; - -use std::fs::{self, File, OpenOptions, Permissions}; -use std::io::{self, Write}; -use std::path::Path; -#[cfg(test)] -use std::path::PathBuf; - -/// Atomically replace `path` with `bytes`, creating its parent directory when needed. -pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { - let permissions = fs::metadata(path) - .ok() - .filter(|metadata| metadata.is_file()) - .map(|metadata| metadata.permissions()); - atomic_write_with_permissions(path, bytes, permissions.as_ref()) -} - -/// Atomically replace a secret-bearing file with owner-only access. -/// -/// The restriction is applied to the temporary file at creation, before its name is visible to -/// another process. This avoids both a permissive umask on Unix and inherited broad directory -/// access-control entries on Windows. -pub(crate) fn atomic_write_private(path: &Path, bytes: &[u8]) -> Result<(), String> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - atomic_write_impl( - path, - bytes, - Some(&Permissions::from_mode(0o600)), - AtomicWritePrivacy::Private, - None, - ) - } - #[cfg(windows)] - { - atomic_write_impl(path, bytes, None, AtomicWritePrivacy::Private, None) - } - #[cfg(not(any(unix, windows)))] - { - atomic_write_impl(path, bytes, None, AtomicWritePrivacy::Standard, None) - } -} - -/// Atomically replace `path` while applying `permissions` before the new bytes become visible. -pub(crate) fn atomic_write_with_permissions( - path: &Path, - bytes: &[u8], - permissions: Option<&Permissions>, -) -> Result<(), String> { - atomic_write_impl(path, bytes, permissions, AtomicWritePrivacy::Standard, None) -} - -/// Atomically restore bytes with an exact Windows discretionary access-control descriptor. -#[cfg(windows)] -pub(crate) fn atomic_write_with_windows_dacl( - path: &Path, - bytes: &[u8], - dacl: &[u8], -) -> Result<(), String> { - atomic_write_impl(path, bytes, None, AtomicWritePrivacy::Standard, Some(dacl)) -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum AtomicWritePrivacy { - Standard, - Private, -} - -fn atomic_write_impl( - path: &Path, - bytes: &[u8], - permissions: Option<&Permissions>, - privacy: AtomicWritePrivacy, - windows_dacl: Option<&[u8]>, -) -> Result<(), String> { - #[cfg(test)] - if take_injected_atomic_write_failure(path) { - return Err(format!( - "failed to write {}: injected test failure", - path.display() - )); - } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; - } - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("nemo-relay"); - let tmp = path.with_file_name(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7())); - let result = (|| { - let mut file = open_atomic_temp(&tmp, path, permissions, privacy, windows_dacl) - .map_err(|error| format!("failed to create {}: {error}", tmp.display()))?; - file.write_all(bytes) - .map_err(|error| format!("failed to write {}: {error}", tmp.display()))?; - file.sync_all() - .map_err(|error| format!("failed to sync {}: {error}", tmp.display()))?; - if let Some(permissions) = permissions { - fs::set_permissions(&tmp, permissions.clone()).map_err(|error| { - format!("failed to set permissions on {}: {error}", tmp.display()) - })?; - } - drop(file); - replace_file(&tmp, path) - })(); - if result.is_err() { - let _ = fs::remove_file(&tmp); - } - result -} - -#[cfg(unix)] -fn open_atomic_temp( - tmp: &Path, - _target: &Path, - permissions: Option<&Permissions>, - _privacy: AtomicWritePrivacy, - _windows_dacl: Option<&[u8]>, -) -> io::Result { - use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; - - let mut options = OpenOptions::new(); - options.create_new(true).write(true); - if let Some(permissions) = permissions { - options.mode(permissions.mode() & 0o7777); - } - options.open(tmp) -} - -#[cfg(windows)] -fn open_atomic_temp( - tmp: &Path, - target: &Path, - _permissions: Option<&Permissions>, - privacy: AtomicWritePrivacy, - windows_dacl: Option<&[u8]>, -) -> io::Result { - if let Some(descriptor) = windows_dacl { - return create_windows_file(tmp, descriptor.as_ptr().cast_mut().cast()); - } - if privacy == AtomicWritePrivacy::Private { - return create_private_windows_file(tmp); - } - if target.exists() { - let mut descriptor = read_windows_dacl(target)?; - return create_windows_file(tmp, descriptor.as_mut_ptr().cast()); - } - OpenOptions::new().create_new(true).write(true).open(tmp) -} - -#[cfg(not(any(unix, windows)))] -fn open_atomic_temp( - tmp: &Path, - _target: &Path, - _permissions: Option<&Permissions>, - _privacy: AtomicWritePrivacy, - _windows_dacl: Option<&[u8]>, -) -> io::Result { - OpenOptions::new().create_new(true).write(true).open(tmp) -} - -#[cfg(windows)] -fn create_private_windows_file(path: &Path) -> io::Result { - with_private_windows_descriptor(|descriptor| create_windows_file(path, descriptor)) -} - -/// Opens or creates a secret-bearing file without inheriting a broad Windows DACL. -/// -/// The protected owner/System descriptor is applied by `CreateFileW` when the file is created and -/// repaired before an existing file is returned to the caller. The containing directory must be -/// protected separately before this function is called. -#[cfg(windows)] -pub(crate) fn open_private_windows_file(path: &Path) -> io::Result { - use windows_sys::Win32::Foundation::{GENERIC_READ, GENERIC_WRITE}; - use windows_sys::Win32::Storage::FileSystem::{ - FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_ALWAYS, - }; - - let file = with_private_windows_descriptor(|descriptor| { - open_windows_file( - path, - descriptor, - GENERIC_READ | GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - OPEN_ALWAYS, - ) - })?; - protect_private_windows_path(path)?; - Ok(file) -} - -/// Applies and verifies the protected owner/System DACL used for secret-bearing Windows paths. -#[cfg(windows)] -pub(crate) fn protect_private_windows_path(path: &Path) -> io::Result<()> { - use windows_sys::Win32::Security::{ - DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, SetFileSecurityW, - }; - - if !windows_path_owned_by_current_user(path)? { - return Err(io::Error::new( - io::ErrorKind::PermissionDenied, - format!("{} is not owned by the current user", path.display()), - )); - } - let path_wide = windows_wide(path.as_os_str()); - with_private_windows_descriptor(|descriptor| { - // SAFETY: The path and descriptor remain valid for the duration of the call. - if unsafe { - SetFileSecurityW( - path_wide.as_ptr(), - DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, - descriptor, - ) - } == 0 - { - return Err(io::Error::last_os_error()); - } - Ok(()) - })?; - if !windows_path_is_private(path)? { - return Err(io::Error::new( - io::ErrorKind::PermissionDenied, - format!( - "failed to verify protected owner/System access on {}", - path.display() - ), - )); - } - Ok(()) -} - -#[cfg(windows)] -fn with_private_windows_descriptor( - operation: impl FnOnce(windows_sys::Win32::Security::PSECURITY_DESCRIPTOR) -> io::Result, -) -> io::Result { - use windows_sys::Win32::Foundation::LocalFree; - use windows_sys::Win32::Security::Authorization::{ - ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, - }; - use windows_sys::Win32::Security::PSECURITY_DESCRIPTOR; - - let descriptor_sddl = windows_wide("D:P(A;;FA;;;OW)(A;;FA;;;SY)"); - let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); - // SAFETY: The SDDL string is NUL-terminated and `descriptor` points to writable storage. The - // returned allocation is released with LocalFree below. - if unsafe { - ConvertStringSecurityDescriptorToSecurityDescriptorW( - descriptor_sddl.as_ptr(), - SDDL_REVISION_1, - &mut descriptor, - std::ptr::null_mut(), - ) - } == 0 - { - return Err(io::Error::last_os_error()); - } - let result = operation(descriptor); - // SAFETY: `descriptor` was allocated by ConvertStringSecurityDescriptor... and has not been - // freed or transferred. - unsafe { LocalFree(descriptor.cast()) }; - result -} - -#[cfg(windows)] -fn windows_path_owned_by_current_user(path: &Path) -> io::Result { - use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; - use windows_sys::Win32::Security::{ - EqualSid, GetSecurityDescriptorOwner, GetTokenInformation, OWNER_SECURITY_INFORMATION, - PSID, TOKEN_QUERY, TOKEN_USER, TokenUser, - }; - use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; - - let mut descriptor = read_windows_security_descriptor(path, OWNER_SECURITY_INFORMATION)?; - let mut owner: PSID = std::ptr::null_mut(); - let mut defaulted = 0; - // SAFETY: The self-relative descriptor buffer is valid and both outputs point to writable - // storage for the duration of the call. - if unsafe { - GetSecurityDescriptorOwner(descriptor.as_mut_ptr().cast(), &mut owner, &mut defaulted) - } == 0 - || owner.is_null() - { - return Err(io::Error::last_os_error()); - } - - let mut token: HANDLE = std::ptr::null_mut(); - // SAFETY: GetCurrentProcess returns a valid pseudo-handle and `token` is writable. - if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { - return Err(io::Error::last_os_error()); - } - let result = (|| { - let mut required = 0; - // SAFETY: This sizing call intentionally supplies a null output buffer. - unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut required) }; - if required == 0 { - return Err(io::Error::last_os_error()); - } - let word = std::mem::size_of::(); - let mut buffer = vec![0_usize; (required as usize).div_ceil(word)]; - // SAFETY: The aligned buffer has at least `required` writable bytes. - if unsafe { - GetTokenInformation( - token, - TokenUser, - buffer.as_mut_ptr().cast(), - required, - &mut required, - ) - } == 0 - { - return Err(io::Error::last_os_error()); - } - // SAFETY: GetTokenInformation initialized a TOKEN_USER at the aligned buffer address. - let user = unsafe { &*buffer.as_ptr().cast::() }; - // SAFETY: Both SID pointers remain valid while their backing buffers are alive. - Ok(unsafe { EqualSid(owner, user.User.Sid) != 0 }) - })(); - // SAFETY: `token` is an owned handle returned by OpenProcessToken. - unsafe { CloseHandle(token) }; - result -} - -#[cfg(windows)] -pub(crate) fn windows_path_is_private(path: &Path) -> io::Result { - use windows_sys::Win32::Security::{DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION}; - - if !windows_path_owned_by_current_user(path)? { - return Ok(false); - } - let mut actual = read_windows_security_descriptor( - path, - OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, - )?; - let actual = windows_dacl_sddl(actual.as_mut_ptr().cast())?; - with_private_windows_descriptor(|expected| Ok(actual == windows_dacl_sddl(expected)?)) -} - -#[cfg(windows)] -fn windows_dacl_sddl( - descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, -) -> io::Result { - use windows_sys::Win32::Foundation::LocalFree; - use windows_sys::Win32::Security::Authorization::{ - ConvertSecurityDescriptorToStringSecurityDescriptorW, SDDL_REVISION_1, - }; - use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; - - let mut rendered = std::ptr::null_mut(); - let mut rendered_len = 0; - // SAFETY: The descriptor is valid and both output pointers reference writable storage. - if unsafe { - ConvertSecurityDescriptorToStringSecurityDescriptorW( - descriptor, - SDDL_REVISION_1, - DACL_SECURITY_INFORMATION, - &mut rendered, - &mut rendered_len, - ) - } == 0 - { - return Err(io::Error::last_os_error()); - } - // SAFETY: The API returned `rendered_len` initialized UTF-16 code units. - let value = String::from_utf16_lossy(unsafe { - std::slice::from_raw_parts(rendered, rendered_len as usize) - }) - .trim_end_matches('\0') - .to_string(); - // SAFETY: `rendered` was allocated by ConvertSecurityDescriptor... above. - unsafe { LocalFree(rendered.cast()) }; - Ok(value) -} - -#[cfg(windows)] -pub(crate) fn read_windows_dacl(path: &Path) -> io::Result> { - use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; - - read_windows_security_descriptor(path, DACL_SECURITY_INFORMATION) -} - -#[cfg(windows)] -fn read_windows_security_descriptor( - path: &Path, - information: windows_sys::Win32::Security::OBJECT_SECURITY_INFORMATION, -) -> io::Result> { - use windows_sys::Win32::Security::GetFileSecurityW; - - let path = windows_wide(path.as_os_str()); - let mut required = 0; - // SAFETY: This sizing call intentionally supplies a null output buffer and valid length - // pointer, as required by GetFileSecurityW. - unsafe { - GetFileSecurityW( - path.as_ptr(), - information, - std::ptr::null_mut(), - 0, - &mut required, - ) - }; - if required == 0 { - return Err(io::Error::last_os_error()); - } - let mut descriptor = vec![0_u8; required as usize]; - // SAFETY: The path is NUL-terminated and the allocated output buffer is `required` bytes. - if unsafe { - GetFileSecurityW( - path.as_ptr(), - information, - descriptor.as_mut_ptr().cast(), - required, - &mut required, - ) - } == 0 - { - return Err(io::Error::last_os_error()); - } - Ok(descriptor) -} - -#[cfg(windows)] -fn create_windows_file( - path: &Path, - descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, -) -> io::Result { - use windows_sys::Win32::Foundation::GENERIC_WRITE; - use windows_sys::Win32::Storage::FileSystem::CREATE_NEW; - - open_windows_file(path, descriptor, GENERIC_WRITE, 0, CREATE_NEW) -} - -#[cfg(windows)] -fn open_windows_file( - path: &Path, - descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, - desired_access: u32, - share_mode: u32, - creation_disposition: u32, -) -> io::Result { - use std::os::windows::io::FromRawHandle; - use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; - use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; - use windows_sys::Win32::Storage::FileSystem::{CreateFileW, FILE_ATTRIBUTE_NORMAL}; - - let path = windows_wide(path.as_os_str()); - let attributes = SECURITY_ATTRIBUTES { - nLength: std::mem::size_of::() as u32, - lpSecurityDescriptor: descriptor, - bInheritHandle: 0, - }; - // SAFETY: The path and security descriptor remain valid for the call, and a successful owned - // handle is transferred to File. - let handle = unsafe { - CreateFileW( - path.as_ptr(), - desired_access, - share_mode, - &attributes, - creation_disposition, - FILE_ATTRIBUTE_NORMAL, - std::ptr::null_mut(), - ) - }; - if handle == INVALID_HANDLE_VALUE { - return Err(io::Error::last_os_error()); - } - // SAFETY: `handle` is a newly created, valid, owned file handle. - Ok(unsafe { File::from_raw_handle(handle) }) -} - -#[cfg(windows)] -fn windows_wide(value: impl AsRef) -> Vec { - use std::os::windows::ffi::OsStrExt; - - value.as_ref().encode_wide().chain(Some(0)).collect() -} - -#[cfg(test)] -pub(crate) fn fail_next_atomic_write(path: &Path) { - injected_atomic_write_failures() - .lock() - .unwrap_or_else(|error| error.into_inner()) - .insert(path.to_path_buf()); -} - #[cfg(test)] -fn take_injected_atomic_write_failure(path: &Path) -> bool { - injected_atomic_write_failures() - .lock() - .unwrap_or_else(|error| error.into_inner()) - .remove(path) -} - -#[cfg(test)] -fn injected_atomic_write_failures() -> &'static std::sync::Mutex> -{ - use std::collections::HashSet; - use std::sync::{Mutex, OnceLock}; - - static FAILURES: OnceLock>> = OnceLock::new(); - FAILURES.get_or_init(Default::default) -} - -#[cfg(not(windows))] -fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { - fs::rename(tmp, path).map_err(|error| format!("failed to replace {}: {error}", path.display())) -} - +pub(crate) use atomic::fail_next_atomic_write; +pub(crate) use atomic::{atomic_write, atomic_write_private, atomic_write_with_permissions}; #[cfg(windows)] -fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { - use windows_sys::Win32::Storage::FileSystem::{ - MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, - }; - - let tmp = windows_wide(tmp.as_os_str()); - let path_wide = windows_wide(path.as_os_str()); - // SAFETY: Both paths are NUL-terminated and remain valid for the call. The files share a - // directory, so Windows performs one replace-existing rename without a missing-target window. - if unsafe { - MoveFileExW( - tmp.as_ptr(), - path_wide.as_ptr(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) - } == 0 - { - return Err(format!( - "failed to replace {}: {}", - path.display(), - io::Error::last_os_error() - )); - } - Ok(()) -} +pub(crate) use atomic::{ + atomic_write_with_windows_dacl, open_private_windows_file, protect_private_windows_path, + read_windows_dacl, windows_path_is_private, +}; +pub(crate) use locks::{LockAttempt, try_lock_exclusive, try_lock_shared, unlock_file}; #[cfg(test)] #[path = "../../tests/coverage/shared/file_io_tests.rs"] From 139cfc63bfa9af61d185486560eb92aad7f4327d Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:02:25 -0400 Subject: [PATCH 052/102] refactor(cli): split hook delivery and destination Signed-off-by: Will Killian --- crates/cli/src/hooks/delivery.rs | 262 ++++++++++++ crates/cli/src/hooks/destination.rs | 119 ++++++ crates/cli/src/hooks/encoding.rs | 9 +- crates/cli/src/hooks/merging.rs | 4 +- crates/cli/src/hooks/mod.rs | 383 +----------------- crates/cli/src/hooks/response.rs | 11 +- .../tests/coverage/shared/installer_tests.rs | 8 + 7 files changed, 423 insertions(+), 373 deletions(-) create mode 100644 crates/cli/src/hooks/delivery.rs create mode 100644 crates/cli/src/hooks/destination.rs diff --git a/crates/cli/src/hooks/delivery.rs b/crates/cli/src/hooks/delivery.rs new file mode 100644 index 000000000..1207601ee --- /dev/null +++ b/crates/cli/src/hooks/delivery.rs @@ -0,0 +1,262 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::io::Read; +use std::time::Duration; + +use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; +use serde_json::Value; + +use crate::error::CliError; +use crate::installation::generation::InstallGeneration; + +use super::destination::{ + HookGatewayLifecycle, hook_destination, recovery_plan, transparent_gateway_spec, + transparent_run_active, wait_for_existing_gateway, +}; +use super::response::MAX_HOOK_RESPONSE_BYTES; +use super::response::{handle_hook_forward_response, handle_verified_hook_forward_response}; +use super::{GatewayMode, HookForwardRequest}; + +const HOOK_FORWARD_TIMEOUT: Duration = Duration::from_secs(2); + +pub(crate) async fn hook_forward(command: HookForwardRequest) -> Result<(), CliError> { + // A transparent wrapper can coexist with any installed Relay plugin. Its process marker makes + // persistent plugin hooks inert, while only the wrapper-owned command carries + // `--transparent-run` and forwards to the process-private gateway. This avoids rewriting host + // plugin settings and works for both installer and source-marketplace plugin identities. + if transparent_run_active() && !command.transparent_run { + return Ok(()); + } + validate_optional_json("session metadata", command.session_metadata.as_deref())?; + let fail_closed = + command.fail_closed || std::env::var("NEMO_RELAY_FAIL_CLOSED").ok().as_deref() == Some("1"); + let destination = hook_destination(&command); + let persistent = match (destination.lifecycle != HookGatewayLifecycle::Transparent) + .then(|| recovery_plan(&destination.gateway_url)) + .transpose() + { + Ok(persistent) => persistent, + Err(error) => return handle_hook_error(error, fail_closed), + }; + let transparent_gateway = match command + .transparent_run + .then(|| transparent_gateway_spec(&destination.gateway_url)) + .transpose() + { + Ok(gateway) => gateway, + Err(error) => return handle_hook_error(error, fail_closed), + }; + let _generation_guard = if destination.lifecycle == HookGatewayLifecycle::Existing + && !command.forward_only + { + let install_host = command.agent.install_arg(); + let Some(generation_file) = command.generation_file.clone() else { + return handle_hook_error( + CliError::Launch(format!( + "persistent {} hook is missing its install-generation fence; run `nemo-relay install {install_host} --force`", + command.agent.label() + )), + fail_closed, + ); + }; + let Some(generation_token) = command.generation_token.as_deref() else { + return handle_hook_error( + CliError::Launch(format!( + "persistent {} hook is missing its expected install-generation identity; run `nemo-relay install {install_host} --force`", + command.agent.label() + )), + fail_closed, + ); + }; + match InstallGeneration::capture_guarded_expected(generation_file, generation_token) { + Ok((_generation, guard)) => Some(guard), + Err(error) => return handle_hook_error(CliError::Launch(error), fail_closed), + } + } else { + None + }; + let input = match read_hook_payload(persistent.as_ref().map_or( + crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + |launch| launch.max_hook_payload_bytes, + )) { + Ok(input) => input, + Err(error) => return handle_hook_error(error, fail_closed), + }; + if destination.lifecycle == HookGatewayLifecycle::Existing { + let gateway = persistent + .as_ref() + .expect("existing persistent destinations resolve a gateway") + .gateway + .clone(); + if let Err(error) = + wait_for_existing_gateway(gateway, destination.gateway_url.clone()).await + { + return handle_hook_error(error, fail_closed); + } + } + let verified_gateway = persistent + .as_ref() + .map(|launch| &launch.gateway) + .or(transparent_gateway.as_ref()); + if let Some(gateway) = verified_gateway { + let response = + send_verified_hook_forward_request(&command, gateway, &destination.gateway_url, input) + .await?; + return handle_verified_hook_forward_response(response, fail_closed); + } + + let url = format!( + "{}{}", + destination.gateway_url.trim_end_matches('/'), + command.agent.hook_path() + ); + let response = send_hook_forward_request(&command, &url, input).await?; + handle_hook_forward_response(response, fail_closed).await +} + +fn handle_hook_error(error: CliError, fail_closed: bool) -> Result<(), CliError> { + eprintln!("nemo-relay hook forward failed: {error}"); + if fail_closed { Err(error) } else { Ok(()) } +} + +// Reads the native hook payload from stdin and normalizes empty payloads to JSON object syntax. +// This keeps hook commands observable even for agents or events that invoke hooks without input. +fn read_hook_payload(limit: usize) -> Result { + read_hook_payload_from(std::io::stdin(), limit) +} + +pub(crate) fn read_hook_payload_from(reader: impl Read, limit: usize) -> Result { + let mut bytes = Vec::new(); + reader + .take(limit.saturating_add(1) as u64) + .read_to_end(&mut bytes)?; + if bytes.len() > limit { + return Err(CliError::Install(format!( + "hook payload exceeds the {limit}-byte limit" + ))); + } + let input = String::from_utf8(bytes) + .map_err(|error| CliError::Install(format!("hook payload is not valid UTF-8: {error}")))?; + if input.trim().is_empty() { + Ok("{}".to_string()) + } else { + Ok(input) + } +} + +pub(crate) async fn send_verified_hook_forward_request( + command: &HookForwardRequest, + gateway: &crate::bootstrap::GatewaySpec, + gateway_url: &str, + input: String, +) -> Result< + Result, + CliError, +> { + let headers = gateway_headers( + command.profile.as_deref(), + command.session_metadata.as_deref(), + command.gateway_mode, + )? + .iter() + .map(|(name, value)| { + value + .to_str() + .map(|value| (name.as_str().to_string(), value.to_string())) + .map_err(|error| { + CliError::Install(format!( + "hook header {name} is not valid HTTP text: {error}" + )) + }) + }) + .collect::, _>>()?; + let gateway = gateway.clone(); + let gateway_url = gateway_url.to_string(); + let path = command.agent.hook_path().to_string(); + tokio::task::spawn_blocking(move || { + gateway.post_verified( + &gateway_url, + &path, + &headers, + input.as_bytes(), + HOOK_FORWARD_TIMEOUT, + MAX_HOOK_RESPONSE_BYTES, + ) + }) + .await + .map_err(|error| CliError::Launch(format!("verified hook request task failed: {error}"))) +} + +// Sends the hook payload with gateway-specific headers translated from CLI flags. The reqwest +// transport result is returned separately so response handling can preserve fail-open semantics. +async fn send_hook_forward_request( + command: &HookForwardRequest, + url: &str, + input: String, +) -> Result, CliError> { + Ok(reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(HOOK_FORWARD_TIMEOUT) + .build()? + .post(url) + .headers(gateway_headers( + command.profile.as_deref(), + command.session_metadata.as_deref(), + command.gateway_mode, + )?) + .header(CONTENT_TYPE, "application/json") + .body(input) + .send() + .await) +} + +// Handles hook delivery results without changing agent control flow unless `--fail-closed` was +// requested. Successful non-empty endpoint bodies are printed verbatim for the invoking hook API. +fn validate_optional_json(name: &str, value: Option<&str>) -> Result<(), CliError> { + if let Some(value) = value { + serde_json::from_str::(value) + .map_err(|error| CliError::Install(format!("invalid {name}: {error}")))?; + } + Ok(()) +} + +// Converts optional session/export/gateway settings into gateway headers for hook-forward. Each +// absent value is omitted so the server can fall back to file, environment, or default config. +pub(crate) fn gateway_headers( + profile: Option<&str>, + session_metadata: Option<&str>, + gateway_mode: Option, +) -> Result { + let mut headers = HeaderMap::new(); + insert_header(&mut headers, "x-nemo-relay-config-profile", profile)?; + insert_header( + &mut headers, + "x-nemo-relay-session-metadata", + session_metadata, + )?; + insert_header( + &mut headers, + "x-nemo-relay-gateway-mode", + gateway_mode.map(GatewayMode::as_arg), + )?; + Ok(headers) +} + +// Inserts one optional header after validating it is legal HTTP header text. Invalid values are +// reported as installer errors because they came from generated or user-provided hook options. +pub(crate) fn insert_header( + headers: &mut HeaderMap, + name: &'static str, + value: Option<&str>, +) -> Result<(), CliError> { + if let Some(value) = value { + headers.insert( + HeaderName::from_static(name), + HeaderValue::from_str(value) + .map_err(|error| CliError::Install(format!("invalid header {name}: {error}")))?, + ); + } + Ok(()) +} diff --git a/crates/cli/src/hooks/destination.rs b/crates/cli/src/hooks/destination.rs new file mode 100644 index 000000000..4b27272b9 --- /dev/null +++ b/crates/cli/src/hooks/destination.rs @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::time::{Duration, Instant}; + +use crate::configuration::{GATEWAY_URL_ENV, TRANSPARENT_RUN_ENV}; +use crate::error::CliError; + +use super::HookForwardRequest; + +const HOOK_GATEWAY_RETRY_TIMEOUT: Duration = Duration::from_secs(20); + +pub(crate) struct HookDestination { + pub(crate) gateway_url: String, + pub(crate) lifecycle: HookGatewayLifecycle, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum HookGatewayLifecycle { + /// A transparent run owns the dynamic gateway and passes its URL through the environment. + Transparent, + /// Persistent hooks use the authenticated gateway started and maintained by MCP. + Existing, +} + +// Installed hooks use the shared fixed gateway that MCP owns. Transparent runs set the dynamic +// environment URL and already own that gateway's lifecycle. +pub(super) fn hook_destination(command: &HookForwardRequest) -> HookDestination { + resolve_hook_destination( + command.gateway_url.clone(), + std::env::var(GATEWAY_URL_ENV).ok(), + command.forward_only, + command.transparent_run, + ) +} + +pub(super) fn transparent_run_active() -> bool { + std::env::var(TRANSPARENT_RUN_ENV).ok().as_deref() == Some("1") +} + +pub(crate) fn resolve_hook_destination( + command_url: Option, + environment_url: Option, + forward_only: bool, + transparent_run: bool, +) -> HookDestination { + if transparent_run { + return HookDestination { + gateway_url: command_url + .or(environment_url) + .unwrap_or_else(|| crate::bootstrap::DEFAULT_URL.into()), + lifecycle: HookGatewayLifecycle::Transparent, + }; + } + if forward_only { + return HookDestination { + gateway_url: command_url.unwrap_or_else(|| crate::bootstrap::DEFAULT_URL.into()), + lifecycle: HookGatewayLifecycle::Existing, + }; + } + if let Some(gateway_url) = command_url { + return HookDestination { + gateway_url, + lifecycle: HookGatewayLifecycle::Existing, + }; + } + if let Some(gateway_url) = environment_url { + return HookDestination { + gateway_url, + lifecycle: HookGatewayLifecycle::Transparent, + }; + } + HookDestination { + gateway_url: crate::bootstrap::DEFAULT_URL.into(), + lifecycle: HookGatewayLifecycle::Existing, + } +} + +pub(super) async fn wait_for_existing_gateway( + gateway: crate::bootstrap::GatewaySpec, + gateway_url: String, +) -> Result<(), CliError> { + tokio::task::spawn_blocking(move || { + let deadline = Instant::now() + HOOK_GATEWAY_RETRY_TIMEOUT; + loop { + match gateway.existing_healthy_instance(&gateway_url) { + Ok(Some(_instance_id)) => return Ok(()), + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(50)); + } + Ok(None) => { + return Err(format!( + "no compatible Relay gateway became ready at {gateway_url}; ensure the host started `nemo-relay mcp`" + )); + } + Err(error) => return Err(error), + } + } + }) + .await + .map_err(|error| CliError::Launch(format!("hook gateway verification task failed: {error}")))? + .map_err(CliError::Launch) +} + +pub(super) fn recovery_plan( + gateway_url: &str, +) -> Result { + let bind = crate::gateway::client::loopback_bind(gateway_url).map_err(CliError::Install)?; + crate::bootstrap::resolve_plugin_gateway(&Default::default(), bind) +} + +pub(crate) fn transparent_gateway_spec( + gateway_url: &str, +) -> Result { + let bind = crate::gateway::client::loopback_bind(gateway_url).map_err(CliError::Install)?; + Ok(crate::bootstrap::GatewaySpec::new(bind).with_fingerprint( + crate::configuration::transparent_gateway_fingerprint(gateway_url), + )) +} diff --git a/crates/cli/src/hooks/encoding.rs b/crates/cli/src/hooks/encoding.rs index 6f544b0d5..a62e299b3 100644 --- a/crates/cli/src/hooks/encoding.rs +++ b/crates/cli/src/hooks/encoding.rs @@ -3,7 +3,14 @@ //! Hook definition and portable command encoding. -use super::*; +use std::path::Path; + +use serde_json::{Value, json}; + +use crate::agents::CodingAgent; + +#[cfg(any(windows, test))] +use base64::Engine; pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value { if agent.uses_direct_hook_entries() { diff --git a/crates/cli/src/hooks/merging.rs b/crates/cli/src/hooks/merging.rs index bb66f17ff..c6ddef596 100644 --- a/crates/cli/src/hooks/merging.rs +++ b/crates/cli/src/hooks/merging.rs @@ -3,7 +3,9 @@ //! Non-destructive lifecycle-hook configuration merging. -use super::*; +use serde_json::{Value, json}; + +use crate::error::CliError; pub(crate) fn merge_hooks(existing: Value, generated: Value) -> Result { let mut root = hook_config_root(existing)?; diff --git a/crates/cli/src/hooks/mod.rs b/crates/cli/src/hooks/mod.rs index 23499d9d2..ec6625602 100644 --- a/crates/cli/src/hooks/mod.rs +++ b/crates/cli/src/hooks/mod.rs @@ -3,11 +3,22 @@ //! Hook delivery, command encoding, generated definitions, and configuration merging. +mod delivery; +mod destination; mod encoding; mod merging; mod response; mod types; +pub(crate) use delivery::hook_forward; +#[cfg(test)] +pub(crate) use delivery::send_verified_hook_forward_request; +#[cfg(test)] +pub(crate) use delivery::{gateway_headers, insert_header, read_hook_payload_from}; +#[cfg(test)] +pub(crate) use destination::{ + HookGatewayLifecycle, resolve_hook_destination, transparent_gateway_spec, +}; #[cfg(test)] pub(crate) use encoding::{ decode_windows_hook_command, encoded_windows_hook_command, event_matches_tools, @@ -17,376 +28,12 @@ pub(crate) use encoding::{ generated_hooks, persistent_hook_forward_command, transparent_hook_forward_command, }; pub(crate) use merging::merge_hooks; -use response::*; +#[cfg(test)] +pub(crate) use response::{handle_hook_forward_status, handle_verified_hook_forward_response}; pub(crate) use types::{GatewayMode, HookForwardRequest}; -use std::io::Read; -use std::path::Path; -use std::time::{Duration, Instant}; - -#[cfg(any(windows, test))] -use base64::Engine; -use futures_util::StreamExt; -use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; -use serde_json::{Value, json}; - -use crate::configuration::{CodingAgent, GATEWAY_URL_ENV, TRANSPARENT_RUN_ENV}; -use crate::error::CliError; -use crate::installation::generation::InstallGeneration; - -const HOOK_FORWARD_TIMEOUT: Duration = Duration::from_secs(2); -const HOOK_GATEWAY_RETRY_TIMEOUT: Duration = Duration::from_secs(20); -const MAX_HOOK_RESPONSE_BYTES: usize = 1024 * 1024; - -/// Forwards a hook payload from an installed shell command to a running gateway. -/// -/// Empty stdin is normalized to `{}` so hooks that provide no payload still generate observable -/// marks. Delivery failures are fail-open by default to avoid blocking coding agents, but -/// `--fail-closed` converts missing URLs, HTTP failures, and upstream errors into process errors. -pub(crate) async fn hook_forward(command: HookForwardRequest) -> Result<(), CliError> { - // A transparent wrapper can coexist with any installed Relay plugin. Its process marker makes - // persistent plugin hooks inert, while only the wrapper-owned command carries - // `--transparent-run` and forwards to the process-private gateway. This avoids rewriting host - // plugin settings and works for both installer and source-marketplace plugin identities. - if transparent_run_active() && !command.transparent_run { - return Ok(()); - } - validate_optional_json("session metadata", command.session_metadata.as_deref())?; - let fail_closed = - command.fail_closed || std::env::var("NEMO_RELAY_FAIL_CLOSED").ok().as_deref() == Some("1"); - let destination = hook_destination(&command); - let persistent = match (destination.lifecycle != HookGatewayLifecycle::Transparent) - .then(|| recovery_plan(&destination.gateway_url)) - .transpose() - { - Ok(persistent) => persistent, - Err(error) => return handle_hook_error(error, fail_closed), - }; - let transparent_gateway = match command - .transparent_run - .then(|| transparent_gateway_spec(&destination.gateway_url)) - .transpose() - { - Ok(gateway) => gateway, - Err(error) => return handle_hook_error(error, fail_closed), - }; - let _generation_guard = if destination.lifecycle == HookGatewayLifecycle::Existing - && !command.forward_only - { - let install_host = command.agent.install_arg(); - let Some(generation_file) = command.generation_file.clone() else { - return handle_hook_error( - CliError::Launch(format!( - "persistent {} hook is missing its install-generation fence; run `nemo-relay install {install_host} --force`", - command.agent.label() - )), - fail_closed, - ); - }; - let Some(generation_token) = command.generation_token.as_deref() else { - return handle_hook_error( - CliError::Launch(format!( - "persistent {} hook is missing its expected install-generation identity; run `nemo-relay install {install_host} --force`", - command.agent.label() - )), - fail_closed, - ); - }; - match InstallGeneration::capture_guarded_expected(generation_file, generation_token) { - Ok((_generation, guard)) => Some(guard), - Err(error) => return handle_hook_error(CliError::Launch(error), fail_closed), - } - } else { - None - }; - let input = match read_hook_payload(persistent.as_ref().map_or( - crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - |launch| launch.max_hook_payload_bytes, - )) { - Ok(input) => input, - Err(error) => return handle_hook_error(error, fail_closed), - }; - if destination.lifecycle == HookGatewayLifecycle::Existing { - let gateway = persistent - .as_ref() - .expect("existing persistent destinations resolve a gateway") - .gateway - .clone(); - if let Err(error) = - wait_for_existing_gateway(gateway, destination.gateway_url.clone()).await - { - return handle_hook_error(error, fail_closed); - } - } - let verified_gateway = persistent - .as_ref() - .map(|launch| &launch.gateway) - .or(transparent_gateway.as_ref()); - if let Some(gateway) = verified_gateway { - let response = - send_verified_hook_forward_request(&command, gateway, &destination.gateway_url, input) - .await?; - return handle_verified_hook_forward_response(response, fail_closed); - } - - let url = format!( - "{}{}", - destination.gateway_url.trim_end_matches('/'), - command.agent.hook_path() - ); - let response = send_hook_forward_request(&command, &url, input).await?; - handle_hook_forward_response(response, fail_closed).await -} - -fn handle_hook_error(error: CliError, fail_closed: bool) -> Result<(), CliError> { - eprintln!("nemo-relay hook forward failed: {error}"); - if fail_closed { Err(error) } else { Ok(()) } -} - -// Reads the native hook payload from stdin and normalizes empty payloads to JSON object syntax. -// This keeps hook commands observable even for agents or events that invoke hooks without input. -fn read_hook_payload(limit: usize) -> Result { - read_hook_payload_from(std::io::stdin(), limit) -} - -fn read_hook_payload_from(reader: impl Read, limit: usize) -> Result { - let mut bytes = Vec::new(); - reader - .take(limit.saturating_add(1) as u64) - .read_to_end(&mut bytes)?; - if bytes.len() > limit { - return Err(CliError::Install(format!( - "hook payload exceeds the {limit}-byte limit" - ))); - } - let input = String::from_utf8(bytes) - .map_err(|error| CliError::Install(format!("hook payload is not valid UTF-8: {error}")))?; - if input.trim().is_empty() { - Ok("{}".to_string()) - } else { - Ok(input) - } -} - -struct HookDestination { - gateway_url: String, - lifecycle: HookGatewayLifecycle, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum HookGatewayLifecycle { - /// A transparent run owns the dynamic gateway and passes its URL through the environment. - Transparent, - /// Persistent hooks use the authenticated gateway started and maintained by MCP. - Existing, -} - -// Installed hooks use the shared fixed gateway that MCP owns. Transparent runs set the dynamic -// environment URL and already own that gateway's lifecycle. -fn hook_destination(command: &HookForwardRequest) -> HookDestination { - resolve_hook_destination( - command.gateway_url.clone(), - std::env::var(GATEWAY_URL_ENV).ok(), - command.forward_only, - command.transparent_run, - ) -} - -fn transparent_run_active() -> bool { - std::env::var(TRANSPARENT_RUN_ENV).ok().as_deref() == Some("1") -} - -fn resolve_hook_destination( - command_url: Option, - environment_url: Option, - forward_only: bool, - transparent_run: bool, -) -> HookDestination { - if transparent_run { - return HookDestination { - gateway_url: command_url - .or(environment_url) - .unwrap_or_else(|| crate::bootstrap::DEFAULT_URL.into()), - lifecycle: HookGatewayLifecycle::Transparent, - }; - } - if forward_only { - return HookDestination { - gateway_url: command_url.unwrap_or_else(|| crate::bootstrap::DEFAULT_URL.into()), - lifecycle: HookGatewayLifecycle::Existing, - }; - } - if let Some(gateway_url) = command_url { - return HookDestination { - gateway_url, - lifecycle: HookGatewayLifecycle::Existing, - }; - } - if let Some(gateway_url) = environment_url { - return HookDestination { - gateway_url, - lifecycle: HookGatewayLifecycle::Transparent, - }; - } - HookDestination { - gateway_url: crate::bootstrap::DEFAULT_URL.into(), - lifecycle: HookGatewayLifecycle::Existing, - } -} - -async fn wait_for_existing_gateway( - gateway: crate::bootstrap::GatewaySpec, - gateway_url: String, -) -> Result<(), CliError> { - tokio::task::spawn_blocking(move || { - let deadline = Instant::now() + HOOK_GATEWAY_RETRY_TIMEOUT; - loop { - match gateway.existing_healthy_instance(&gateway_url) { - Ok(Some(_instance_id)) => return Ok(()), - Ok(None) if Instant::now() < deadline => { - std::thread::sleep(Duration::from_millis(50)); - } - Ok(None) => { - return Err(format!( - "no compatible Relay gateway became ready at {gateway_url}; ensure the host started `nemo-relay mcp`" - )); - } - Err(error) => return Err(error), - } - } - }) - .await - .map_err(|error| CliError::Launch(format!("hook gateway verification task failed: {error}")))? - .map_err(CliError::Launch) -} - -fn recovery_plan(gateway_url: &str) -> Result { - let bind = crate::gateway::client::loopback_bind(gateway_url).map_err(CliError::Install)?; - crate::bootstrap::resolve_plugin_gateway(&Default::default(), bind) -} - -fn transparent_gateway_spec(gateway_url: &str) -> Result { - let bind = crate::gateway::client::loopback_bind(gateway_url).map_err(CliError::Install)?; - Ok(crate::bootstrap::GatewaySpec::new(bind).with_fingerprint( - crate::configuration::transparent_gateway_fingerprint(gateway_url), - )) -} - -async fn send_verified_hook_forward_request( - command: &HookForwardRequest, - gateway: &crate::bootstrap::GatewaySpec, - gateway_url: &str, - input: String, -) -> Result< - Result, - CliError, -> { - let headers = gateway_headers( - command.profile.as_deref(), - command.session_metadata.as_deref(), - command.gateway_mode, - )? - .iter() - .map(|(name, value)| { - value - .to_str() - .map(|value| (name.as_str().to_string(), value.to_string())) - .map_err(|error| { - CliError::Install(format!( - "hook header {name} is not valid HTTP text: {error}" - )) - }) - }) - .collect::, _>>()?; - let gateway = gateway.clone(); - let gateway_url = gateway_url.to_string(); - let path = command.agent.hook_path().to_string(); - tokio::task::spawn_blocking(move || { - gateway.post_verified( - &gateway_url, - &path, - &headers, - input.as_bytes(), - HOOK_FORWARD_TIMEOUT, - MAX_HOOK_RESPONSE_BYTES, - ) - }) - .await - .map_err(|error| CliError::Launch(format!("verified hook request task failed: {error}"))) -} - -// Sends the hook payload with gateway-specific headers translated from CLI flags. The reqwest -// transport result is returned separately so response handling can preserve fail-open semantics. -async fn send_hook_forward_request( - command: &HookForwardRequest, - url: &str, - input: String, -) -> Result, CliError> { - Ok(reqwest::Client::builder() - .no_proxy() - .redirect(reqwest::redirect::Policy::none()) - .timeout(HOOK_FORWARD_TIMEOUT) - .build()? - .post(url) - .headers(gateway_headers( - command.profile.as_deref(), - command.session_metadata.as_deref(), - command.gateway_mode, - )?) - .header(CONTENT_TYPE, "application/json") - .body(input) - .send() - .await) -} - -// Handles hook delivery results without changing agent control flow unless `--fail-closed` was -// requested. Successful non-empty endpoint bodies are printed verbatim for the invoking hook API. -fn validate_optional_json(name: &str, value: Option<&str>) -> Result<(), CliError> { - if let Some(value) = value { - serde_json::from_str::(value) - .map_err(|error| CliError::Install(format!("invalid {name}: {error}")))?; - } - Ok(()) -} - -// Converts optional session/export/gateway settings into gateway headers for hook-forward. Each -// absent value is omitted so the server can fall back to file, environment, or default config. -fn gateway_headers( - profile: Option<&str>, - session_metadata: Option<&str>, - gateway_mode: Option, -) -> Result { - let mut headers = HeaderMap::new(); - insert_header(&mut headers, "x-nemo-relay-config-profile", profile)?; - insert_header( - &mut headers, - "x-nemo-relay-session-metadata", - session_metadata, - )?; - insert_header( - &mut headers, - "x-nemo-relay-gateway-mode", - gateway_mode.map(GatewayMode::as_arg), - )?; - Ok(headers) -} - -// Inserts one optional header after validating it is legal HTTP header text. Invalid values are -// reported as installer errors because they came from generated or user-provided hook options. -fn insert_header( - headers: &mut HeaderMap, - name: &'static str, - value: Option<&str>, -) -> Result<(), CliError> { - if let Some(value) = value { - headers.insert( - HeaderName::from_static(name), - HeaderValue::from_str(value) - .map_err(|error| CliError::Install(format!("invalid header {name}: {error}")))?, - ); - } - Ok(()) -} +#[cfg(test)] +use serde_json::json; #[cfg(test)] #[path = "../../tests/coverage/shared/installer_tests.rs"] diff --git a/crates/cli/src/hooks/response.rs b/crates/cli/src/hooks/response.rs index 36058892a..5e43f5000 100644 --- a/crates/cli/src/hooks/response.rs +++ b/crates/cli/src/hooks/response.rs @@ -3,7 +3,12 @@ //! Gateway response handling for forwarded lifecycle hooks. -use super::*; +use futures_util::StreamExt; +use serde_json::Value; + +use crate::error::CliError; + +pub(super) const MAX_HOOK_RESPONSE_BYTES: usize = 1024 * 1024; pub(super) async fn handle_hook_forward_response( response: Result, @@ -33,7 +38,7 @@ pub(super) async fn handle_hook_forward_response( } } -pub(super) fn handle_verified_hook_forward_response( +pub(crate) fn handle_verified_hook_forward_response( response: Result< crate::gateway::client::VerifiedHttpResponse, crate::gateway::client::VerifiedHttpError, @@ -66,7 +71,7 @@ pub(super) fn handle_verified_hook_forward_response( } } -pub(super) fn handle_hook_forward_status( +pub(crate) fn handle_hook_forward_status( status: reqwest::StatusCode, body: String, fail_closed: bool, diff --git a/crates/cli/tests/coverage/shared/installer_tests.rs b/crates/cli/tests/coverage/shared/installer_tests.rs index 4595f235b..bc2eebc98 100644 --- a/crates/cli/tests/coverage/shared/installer_tests.rs +++ b/crates/cli/tests/coverage/shared/installer_tests.rs @@ -2,6 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; +use base64::Engine; +use std::path::Path; +use std::time::Duration; + +use reqwest::header::HeaderMap; +use serde_json::Value; + +use crate::agents::CodingAgent; struct BootstrapConfigHome { _guard: std::sync::MutexGuard<'static, ()>, From 13fb672960f475ee3155086929ec7f9995fa9fa5 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:04:01 -0400 Subject: [PATCH 053/102] refactor(cli): separate diagnostic report model Signed-off-by: Will Killian --- crates/cli/src/diagnostics/mod.rs | 87 +--------------------------- crates/cli/src/diagnostics/model.rs | 89 +++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 85 deletions(-) create mode 100644 crates/cli/src/diagnostics/model.rs diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index d0d38089a..a4b09d12b 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -9,8 +9,10 @@ //! - `DoctorReport` is the resulting pure data shape. //! - `format_human(&report)` / `format_json(&report)` render the report. +mod model; mod render; +pub(crate) use model::*; use render::*; use std::path::{Path, PathBuf}; @@ -23,7 +25,6 @@ use nemo_relay::observability::plugin_component::OBSERVABILITY_PLUGIN_KIND; use nemo_relay::plugin::{DiagnosticLevel, PluginConfig, validate_plugin_config}; use nemo_relay_adaptive::plugin_component::register_adaptive_component; use nemo_relay_pii_redaction::component::register_pii_redaction_component; -use serde::Serialize; use serde_json::{Value, json}; use tokio::time::timeout; use tokio_tungstenite::tungstenite::client::IntoClientRequest; @@ -39,96 +40,12 @@ use crate::server::GatewayOverrides; const NETWORK_TIMEOUT: Duration = Duration::from_secs(2); const PRICING_PLUGIN_KIND: &str = "pricing"; -/// Outcome of one check inside the doctor report. The `details` field carries human-readable -/// supplementary text; the `status` is the bottom-line signal callers (and CI) use to decide -/// pass/fail. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub(crate) struct Check { - pub name: &'static str, - pub status: Status, - pub details: String, -} - -#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub(crate) enum Status { - Pass, - Warn, - Fail, - /// The check ran but no relevant state was detected — purely informational (e.g. an agent - /// not on $PATH). Renders as a dot; not counted toward exit code. - Info, -} - -/// Snapshot of the running system that the doctor renders. Stable schema, versioned via -/// `schema_version`. Adding fields is non-breaking; removing or renaming requires a bump. -#[derive(Debug, Clone, Serialize)] -pub(crate) struct DoctorReport { - pub schema_version: u32, - pub binary_version: &'static str, - pub target_agent: Option, - pub environment: EnvironmentInfo, - pub configuration: ConfigurationInfo, - pub agents: Vec, - pub host_plugins: Vec, - pub observability: Vec, - pub completions: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub(crate) struct EnvironmentInfo { - pub os: String, - pub arch: &'static str, - pub shell: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub(crate) struct ConfigurationInfo { - pub workspace: ConfigLayer, - pub global: ConfigLayer, - pub system: ConfigLayer, - pub plugin_configs: Vec, - pub plugin_resolution: Check, - pub resolution: Check, - pub default_agent: Option, - pub configured_agents: Vec, - pub dynamic_plugins: Vec, -} - struct PluginConfigurationDiagnostics { sources: Vec, error: Option, resolution: Check, } -#[derive(Debug, Clone, Serialize)] -pub(crate) struct DynamicPluginReferenceInfo { - pub plugin_id: String, - pub manifest_ref: String, - pub source: PathBuf, - pub host_config_status: DynamicPluginHostConfigStatus, -} - -#[derive(Debug, Clone, Serialize)] -pub(crate) struct ConfigLayer { - pub path: PathBuf, - pub status: Status, - pub active: bool, - pub details: String, -} - -#[derive(Debug, Clone, Serialize)] -pub(crate) struct AgentInfo { - pub name: &'static str, - pub status: Status, - pub configured: bool, - pub command: String, - pub path: Option, - pub version: Option, - /// Free-form annotation, e.g. "hooks: installed" once we wire up hook detection. - pub annotation: String, -} - /// Drives all checks and produces a single `DoctorReport`. Network probes are bounded by a /// short timeout so the command always returns quickly. Filesystem checks short-circuit on /// the first missing directory. diff --git a/crates/cli/src/diagnostics/model.rs b/crates/cli/src/diagnostics/model.rs new file mode 100644 index 000000000..82250d3d4 --- /dev/null +++ b/crates/cli/src/diagnostics/model.rs @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Stable diagnostic report data model. + +use std::path::PathBuf; + +use serde::Serialize; + +use crate::configuration::DynamicPluginHostConfigStatus; + +/// Outcome of one check inside the doctor report. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct Check { + pub name: &'static str, + pub status: Status, + pub details: String, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Status { + Pass, + Warn, + Fail, + /// The check ran but no relevant state was detected. + Info, +} + +/// Snapshot of the running system rendered by `doctor`. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct DoctorReport { + pub schema_version: u32, + pub binary_version: &'static str, + pub target_agent: Option, + pub environment: EnvironmentInfo, + pub configuration: ConfigurationInfo, + pub agents: Vec, + pub host_plugins: Vec, + pub observability: Vec, + pub completions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct EnvironmentInfo { + pub os: String, + pub arch: &'static str, + pub shell: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ConfigurationInfo { + pub workspace: ConfigLayer, + pub global: ConfigLayer, + pub system: ConfigLayer, + pub plugin_configs: Vec, + pub plugin_resolution: Check, + pub resolution: Check, + pub default_agent: Option, + pub configured_agents: Vec, + pub dynamic_plugins: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct DynamicPluginReferenceInfo { + pub plugin_id: String, + pub manifest_ref: String, + pub source: PathBuf, + pub host_config_status: DynamicPluginHostConfigStatus, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ConfigLayer { + pub path: PathBuf, + pub status: Status, + pub active: bool, + pub details: String, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AgentInfo { + pub name: &'static str, + pub status: Status, + pub configured: bool, + pub command: String, + pub path: Option, + pub version: Option, + pub annotation: String, +} From ed1cc4948137d6c19d554e6eccd85ac45b62abc9 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:04:55 -0400 Subject: [PATCH 054/102] refactor(cli): isolate environment diagnostics Signed-off-by: Will Killian --- crates/cli/src/diagnostics/environment.rs | 28 +++++++++++++++++++++++ crates/cli/src/diagnostics/mod.rs | 25 ++------------------ 2 files changed, 30 insertions(+), 23 deletions(-) create mode 100644 crates/cli/src/diagnostics/environment.rs diff --git a/crates/cli/src/diagnostics/environment.rs b/crates/cli/src/diagnostics/environment.rs new file mode 100644 index 000000000..0699fd091 --- /dev/null +++ b/crates/cli/src/diagnostics/environment.rs @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Process and operating-system diagnostic collection. + +use super::EnvironmentInfo; + +pub(super) fn collect_environment() -> EnvironmentInfo { + EnvironmentInfo { + os: format!("{} {}", std::env::consts::OS, os_version()), + arch: std::env::consts::ARCH, + shell: std::env::var("SHELL").ok().and_then(|path| { + std::path::Path::new(&path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + }), + } +} + +fn os_version() -> String { + if cfg!(windows) { + return String::new(); + } + match std::process::Command::new("uname").arg("-r").output() { + Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(), + _ => String::new(), + } +} diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index a4b09d12b..e3b03cf89 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -9,9 +9,11 @@ //! - `DoctorReport` is the resulting pure data shape. //! - `format_human(&report)` / `format_json(&report)` render the report. +mod environment; mod model; mod render; +use environment::collect_environment; pub(crate) use model::*; use render::*; @@ -105,29 +107,6 @@ pub(crate) async fn collect_report( }) } -fn collect_environment() -> EnvironmentInfo { - EnvironmentInfo { - os: format!("{} {}", std::env::consts::OS, os_version()), - arch: std::env::consts::ARCH, - shell: std::env::var("SHELL").ok().and_then(|path| { - std::path::Path::new(&path) - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - }), - } -} - -fn os_version() -> String { - // `uname -r` works on macOS/Linux; on Windows we just report the OS name with no detail. - if cfg!(windows) { - return String::new(); - } - match std::process::Command::new("uname").arg("-r").output() { - Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(), - _ => String::new(), - } -} - fn collect_configuration( cwd: Option<&Path>, home: Option<&Path>, From 8a5b43ceeb6e90f9a8f7680b76bffa79ef738f76 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:06:57 -0400 Subject: [PATCH 055/102] refactor(cli): keep easy-path setup in command layer Signed-off-by: Will Killian --- crates/cli/src/commands/run.rs | 24 ++++++++++++++- crates/cli/src/process/launcher.rs | 47 +----------------------------- 2 files changed, 24 insertions(+), 47 deletions(-) diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index 59e2134e7..13a9912f4 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -70,5 +70,27 @@ pub(super) async fn easy_path( server: &ServerArgs, ) -> Result { let inherited = server.to_runtime(); - crate::process::launcher::easy_path(agent, command.command, Some(&inherited)).await + // An explicit config path is the user's contract. Without one, setup is required only when + // none of the normal discovery layers exists. Keep this interactive decision in the command + // layer so process supervision receives a complete, agent-neutral run request. + let explicit_config = inherited.config.as_deref(); + let needs_setup = explicit_config.map_or_else( + || !crate::configuration::any_config_file_exists(), + |path| !path.exists(), + ); + if needs_setup { + crate::configuration::wizard::run(Some(agent)).await?; + } + let runtime = crate::process::RunOverrides { + agent: Some(agent), + config: explicit_config.map(PathBuf::from), + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: false, + print: false, + command: command.command, + }; + crate::process::launcher::run(runtime, Some(&inherited)).await } diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index 045ddb5f7..19e1fccca 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -17,8 +17,7 @@ use tokio::sync::oneshot; use tokio::task::JoinHandle; use crate::configuration::{ - AgentConfigs, CodingAgent, GatewayConfig, ResolvedConfig, any_config_file_exists, - resolve_run_config, + AgentConfigs, CodingAgent, GatewayConfig, ResolvedConfig, resolve_run_config, }; use crate::error::CliError; use crate::plugins::lifecycle::ActiveDynamicPluginComponent; @@ -42,50 +41,6 @@ pub(crate) async fn run( run.execute().await } -/// Runs the easy-path bare-agent shortcut (`nemo-relay claude`, `nemo-relay codex`, etc.). -/// -/// If no config file is present at any discovery layer, this fires the interactive setup inline -/// (`crate::configuration::wizard::run`) which writes a `config.toml`, then proceeds to launch the agent. When -/// config IS present, the easy path constructs a synthetic `RunOverrides` and delegates to the -/// same transparent-run pipeline `nemo-relay run` uses — same observability wiring, same agent -/// argv resolution, same lifecycle management. -pub(crate) async fn easy_path( - agent: CodingAgent, - command: Vec, - inherited: Option<&GatewayOverrides>, -) -> Result { - // Explicit `--config ` short-circuits the discovery-based setup trigger: when the - // user has pointed at a specific file, that file is the contract — fire setup only if it - // doesn't exist yet, and never run setup just because no config lives at any default - // discovery location. - let explicit_config = inherited.and_then(|args| args.config.as_deref()); - let needs_setup = match explicit_config { - Some(path) => !path.exists(), - None => !any_config_file_exists(), - }; - if needs_setup { - // No config anywhere — fire setup inline, scoped to the agent the user typed. After - // it returns, config discovery will pick up the freshly-written `config.toml` and - // `run()` below will see a populated environment. If setup errors (non-TTY, user - // cancelled), surface that directly. - crate::configuration::wizard::run(Some(agent)).await?; - } - let synthetic = RunOverrides { - agent: Some(agent), - // Forward the explicit config path so `run` parses the same file the user asked for, - // rather than re-discovering from defaults. - config: explicit_config.map(std::path::Path::to_path_buf), - openai_base_url: None, - anthropic_base_url: None, - session_metadata: None, - plugin_config_path: None, - dry_run: false, - print: false, - command, - }; - run(synthetic, inherited).await -} - struct TransparentRun { agent: CodingAgent, prepared: PreparedAgentLaunch, From 86b5dbedbf478d042b7e7556923fc96082cc9ac9 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:08:23 -0400 Subject: [PATCH 056/102] refactor(cli): normalize configure command module Signed-off-by: Will Killian --- .../cli/src/commands/{configuration.rs => configure/mod.rs} | 2 ++ crates/cli/src/commands/mod.rs | 4 ++-- crates/cli/src/commands/root.rs | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) rename crates/cli/src/commands/{configuration.rs => configure/mod.rs} (82%) diff --git a/crates/cli/src/commands/configuration.rs b/crates/cli/src/commands/configure/mod.rs similarity index 82% rename from crates/cli/src/commands/configuration.rs rename to crates/cli/src/commands/configure/mod.rs index d42731674..4a607f8a2 100644 --- a/crates/cli/src/commands/configuration.rs +++ b/crates/cli/src/commands/configure/mod.rs @@ -12,6 +12,8 @@ use crate::error::CliError; pub(crate) struct ConfigCommand { #[arg(value_enum)] pub(crate) agent: Option, + /// Reset Relay configuration for the selected scope. Persistent Hermes integration state is + /// managed separately with `nemo-relay uninstall hermes`. #[arg(long)] pub(crate) reset: bool, } diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 12d1c365a..aae1c2c6f 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -4,7 +4,7 @@ //! Command parsing, dispatch, rendering, and exit-code ownership. mod completions; -mod configuration; +mod configure; mod diagnostics; mod hook_forward; mod install; @@ -73,7 +73,7 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result run::easy_path(CodingAgent::Codex, command, server).await, Command::Hermes(command) => run::easy_path(CodingAgent::Hermes, command, server).await, Command::Mcp => mcp::execute(server).await, - Command::Config(command) => configuration::execute(command).await, + Command::Config(command) => configure::execute(command).await, Command::Plugins(command) => plugins::execute(command, server), Command::ModelPricing(command) => model_pricing::execute(command), Command::Doctor(command) => diagnostics::execute(command).await, diff --git a/crates/cli/src/commands/root.rs b/crates/cli/src/commands/root.rs index ddafae7fa..1f41dca1a 100644 --- a/crates/cli/src/commands/root.rs +++ b/crates/cli/src/commands/root.rs @@ -4,7 +4,7 @@ use clap::{Parser, Subcommand, ValueEnum}; use super::completions::CompletionsCommand; -use super::configuration::ConfigCommand; +use super::configure::ConfigCommand; use super::diagnostics::{AgentsCommand, DoctorCommand}; use super::hook_forward::HookForwardCommand; use super::install::{InstallCommand, UninstallCommand}; From 43f5fb4ed7878b08b0881369520cee2095bf84d0 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:09:56 -0400 Subject: [PATCH 057/102] refactor(cli): separate configure UI from persistence Signed-off-by: Will Killian --- crates/cli/src/commands/configure/mod.rs | 8 +++-- .../configure}/wizard.rs | 14 +++----- crates/cli/src/commands/mod.rs | 2 +- crates/cli/src/commands/run.rs | 2 +- crates/cli/src/configuration/mod.rs | 2 +- .../{wizard_model.rs => setup.rs} | 32 +++++++++---------- 6 files changed, 30 insertions(+), 30 deletions(-) rename crates/cli/src/{configuration => commands/configure}/wizard.rs (98%) rename crates/cli/src/configuration/{wizard_model.rs => setup.rs} (94%) diff --git a/crates/cli/src/commands/configure/mod.rs b/crates/cli/src/commands/configure/mod.rs index 4a607f8a2..ca1e8c5d1 100644 --- a/crates/cli/src/commands/configure/mod.rs +++ b/crates/cli/src/commands/configure/mod.rs @@ -8,6 +8,10 @@ use clap::Args; use super::root::AgentArg; use crate::error::CliError; +mod wizard; + +pub(super) use wizard::run; + #[derive(Debug, Clone, Args)] pub(crate) struct ConfigCommand { #[arg(value_enum)] @@ -21,9 +25,9 @@ pub(crate) struct ConfigCommand { pub(super) async fn execute(command: ConfigCommand) -> Result { let agent = command.agent.map(Into::into); if command.reset { - crate::configuration::wizard::reset(agent)?; + crate::configuration::setup::reset(agent)?; } else { - crate::configuration::wizard::run(agent).await?; + wizard::run(agent).await?; } Ok(ExitCode::SUCCESS) } diff --git a/crates/cli/src/configuration/wizard.rs b/crates/cli/src/commands/configure/wizard.rs similarity index 98% rename from crates/cli/src/configuration/wizard.rs rename to crates/cli/src/commands/configure/wizard.rs index 1d5efbb6b..c91127072 100644 --- a/crates/cli/src/configuration/wizard.rs +++ b/crates/cli/src/commands/configure/wizard.rs @@ -18,21 +18,17 @@ use dialoguer::theme::ColorfulTheme; use dialoguer::{Confirm, MultiSelect, Select}; use toml_edit::DocumentMut; -use crate::configuration::CodingAgent; -use crate::error::CliError; - -#[path = "wizard_model.rs"] -mod model; - -pub(crate) use self::model::reset; use self::model::{ ConfigScope, SetupAnswers, agent_key_and_command, build_config, detect_installed_agents, home_dir, plugins_edit_command_for_scope, plugins_resume_command, preview_paths, read_existing_defaults, save_config, }; +use crate::agents::CodingAgent; +use crate::configuration::setup as model; +use crate::error::CliError; #[cfg(test)] -use self::model::{Defaults, global_config_dir, read_agents_from_doc, write_or_merge}; +use self::model::{Defaults, global_config_dir, read_agents_from_doc, reset, write_or_merge}; #[cfg(test)] use self::model::detect_installed_agents_in; @@ -327,5 +323,5 @@ fn setup_error(err: dialoguer::Error) -> CliError { } #[cfg(test)] -#[path = "../../tests/coverage/shared/setup_tests.rs"] +#[path = "../../../tests/coverage/shared/setup_tests.rs"] mod tests; diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index aae1c2c6f..a03f1d727 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -122,7 +122,7 @@ async fn run_default(server_args: &ServerArgs) -> Result &'static str { + pub(crate) fn label(self) -> &'static str { match self { Self::Project => "project ./.nemo-relay/config.toml (recommended)", Self::Global => "global ~/.config/nemo-relay/config.toml", @@ -37,7 +37,7 @@ impl ConfigScope { /// `Project` and `Both` configure the project `plugins.toml`; `Global` configures the user /// `plugins.toml`. Returns the existing `PluginsEditRequest` so the in-process editor behaves /// exactly like the equivalent `nemo-relay plugins edit` invocation. -pub(super) fn plugins_edit_command_for_scope(scope: ConfigScope) -> PluginsEditRequest { +pub(crate) fn plugins_edit_command_for_scope(scope: ConfigScope) -> PluginsEditRequest { let scope = match scope { ConfigScope::Project | ConfigScope::Both => ConfigurationScope::Project, ConfigScope::Global => ConfigurationScope::User, @@ -46,7 +46,7 @@ pub(super) fn plugins_edit_command_for_scope(scope: ConfigScope) -> PluginsEditR } /// Returns the exact command a user runs to resume plugin setup after skipping the continuation. -pub(super) fn plugins_resume_command(scope: ConfigScope) -> &'static str { +pub(crate) fn plugins_resume_command(scope: ConfigScope) -> &'static str { match scope { ConfigScope::Project | ConfigScope::Both => "nemo-relay plugins edit --project", ConfigScope::Global => "nemo-relay plugins edit", @@ -97,7 +97,7 @@ pub(crate) fn build_config(answers: &SetupAnswers) -> DocumentMut { doc } -pub(super) fn build_agents_table(answers: &SetupAnswers) -> Option
{ +pub(crate) fn build_agents_table(answers: &SetupAnswers) -> Option
{ if answers.agents.is_empty() { return None; } @@ -150,7 +150,7 @@ pub(crate) fn save_config( // Resolves the global nemo-relay config directory. Prefers `$XDG_CONFIG_HOME/nemo-relay` (matches // `config::user_config_dir`), falling back to `/.config/nemo-relay`. Tests that pass a // tempdir for `home` get hermetic paths unless they set XDG_CONFIG_HOME explicitly. -pub(super) fn global_config_dir(home: &Path) -> PathBuf { +pub(crate) fn global_config_dir(home: &Path) -> PathBuf { if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") { return PathBuf::from(base).join("nemo-relay"); } @@ -160,7 +160,7 @@ pub(super) fn global_config_dir(home: &Path) -> PathBuf { // Writes the wizard-built `doc` to `path`. When `merge_scope` is `Some(agent)` and the file // already exists, preserves any `[agents.]` blocks while replacing the shared sections // and the target agent's block. When `merge_scope` is `None`, just overwrites the file. -pub(super) fn write_or_merge( +pub(crate) fn write_or_merge( path: &Path, doc: &DocumentMut, merge_scope: Option, @@ -188,7 +188,7 @@ pub(super) fn write_or_merge( // Replaces the single `[agents.]` block in `dst` with the one from `src`. If `src` does // not contain that block, the existing entry in `dst` is left as-is. -pub(super) fn merge_agents_entry(dst: &mut DocumentMut, src: &DocumentMut, agent_key: &str) { +pub(crate) fn merge_agents_entry(dst: &mut DocumentMut, src: &DocumentMut, agent_key: &str) { let Some(src_agent) = src .get("agents") .and_then(|item| item.as_table()) @@ -273,13 +273,13 @@ fn reset_project_config(agent_hint: Option) -> Result<(), CliError> /// Pre-filled wizard defaults read from an existing `config.toml`. When the file is missing or /// unparseable the defaults are all-empty and the wizard behaves like a first-run setup. #[derive(Debug, Clone, Default)] -pub(super) struct Defaults { - pub(super) scope: Option, - pub(super) agents: Vec, +pub(crate) struct Defaults { + pub(crate) scope: Option, + pub(crate) agents: Vec, } impl Defaults { - pub(super) fn has_any(&self) -> bool { + pub(crate) fn has_any(&self) -> bool { self.scope.is_some() || !self.agents.is_empty() } } @@ -287,7 +287,7 @@ impl Defaults { /// Reads the highest-precedence existing config file and derives wizard defaults from it. /// Workspace config wins over global; if both exist, scope defaults to `Both`. Missing or /// malformed files yield `None` (the wizard then behaves as if no config existed). -pub(super) fn read_existing_defaults() -> Option { +pub(crate) fn read_existing_defaults() -> Option { let cwd = std::env::current_dir().ok()?; let home = home_dir(); @@ -321,7 +321,7 @@ pub(super) fn read_existing_defaults() -> Option { }) } -pub(super) fn read_agents_from_doc(doc: &DocumentMut) -> Vec { +pub(crate) fn read_agents_from_doc(doc: &DocumentMut) -> Vec { let Some(table) = doc.get("agents").and_then(|i| i.as_table()) else { return Vec::new(); }; @@ -340,11 +340,11 @@ pub(super) fn read_agents_from_doc(doc: &DocumentMut) -> Vec { found } -pub(super) fn agent_key_and_command(agent: CodingAgent) -> (&'static str, &'static str) { +pub(crate) fn agent_key_and_command(agent: CodingAgent) -> (&'static str, &'static str) { (agent.as_arg(), agent.executable()) } -pub(super) fn preview_paths(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec { +pub(crate) fn preview_paths(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec { let mut paths = Vec::new(); if matches!(scope, ConfigScope::Project | ConfigScope::Both) { paths.push(cwd.join(".nemo-relay").join("config.toml")); @@ -355,7 +355,7 @@ pub(super) fn preview_paths(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec< paths } -pub(super) fn home_dir() -> Option { +pub(crate) fn home_dir() -> Option { std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .map(PathBuf::from) From c402a6969a0228f9f0c037049c83017352264c20 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:11:43 -0400 Subject: [PATCH 058/102] refactor(cli): separate resolved configuration model Signed-off-by: Will Killian --- crates/cli/src/configuration/mod.rs | 126 +------------------------ crates/cli/src/configuration/types.rs | 131 ++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 122 deletions(-) create mode 100644 crates/cli/src/configuration/types.rs diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs index f9bfdf8d3..1618fd13b 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -2,12 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 pub(crate) mod setup; +mod types; + +pub(crate) use types::*; use std::collections::HashSet; use std::env; use std::fs::{self, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; -use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::thread; use std::time::{Duration, Instant}; @@ -19,9 +21,8 @@ use nemo_relay::plugin::dynamic::{ use nemo_relay::plugin::{PluginError, merge_plugin_config_documents}; use ring::rand::{SecureRandom, SystemRandom}; use ring::{digest, hmac}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::{Map, Value}; -use strum::{Display, IntoStaticStr}; pub(crate) use crate::agents::CodingAgent; use crate::error::CliError; @@ -45,106 +46,6 @@ pub(crate) const DEFAULT_MAX_PASSTHROUGH_BODY_BYTES: usize = 100 * 1024 * 1024; pub(crate) const GATEWAY_URL_ENV: &str = "NEMO_RELAY_GATEWAY_URL"; pub(crate) const TRANSPARENT_RUN_ENV: &str = "NEMO_RELAY_TRANSPARENT_RUN"; -#[derive(Debug, Clone)] -pub(crate) struct GatewayConfig { - pub(crate) bind: SocketAddr, - pub(crate) openai_base_url: String, - pub(crate) anthropic_base_url: String, - pub(crate) metadata: Option, - pub(crate) plugin_config: Option, - pub(crate) max_hook_payload_bytes: usize, - pub(crate) max_passthrough_body_bytes: usize, -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct SessionConfig { - pub(crate) metadata: Option, - pub(crate) plugin_config: Option, - pub(crate) profile: Option, - pub(crate) gateway_mode: Option, -} - -impl GatewayConfig { - // Resolves per-session settings from hook/gateway headers with process config as fallback. - // Header JSON fields are parsed opportunistically; invalid JSON is treated as absent here - // because install and hook-forward validate generated header values before sending them. - pub(crate) fn session_config_from_headers(&self, headers: &HeaderMap) -> SessionConfig { - let metadata = - header_json(headers, "x-nemo-relay-session-metadata").or_else(|| self.metadata.clone()); - let plugin_config = header_json(headers, "x-nemo-relay-plugin-config") - .or_else(|| self.plugin_config.clone()); - let profile = header_string(headers, "x-nemo-relay-config-profile"); - let gateway_mode = header_string(headers, "x-nemo-relay-gateway-mode"); - SessionConfig { - metadata, - plugin_config, - profile, - gateway_mode, - } - } -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct ResolvedConfig { - pub(crate) gateway: GatewayConfig, - pub(crate) agents: AgentConfigs, - pub(crate) dynamic_plugins: Vec, - pub(crate) dynamic_plugin_policy: DynamicPluginHostPolicy, - pub(crate) bootstrap_fingerprint: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ResolvedDynamicPluginConfig { - pub(crate) plugin_id: String, - pub(crate) manifest_ref: String, - pub(crate) config: Map, - pub(crate) has_explicit_config: bool, - pub(crate) source: PathBuf, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Display, IntoStaticStr)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub(crate) enum DynamicPluginHostConfigStatus { - Absent, - Present, -} - -impl ResolvedDynamicPluginConfig { - pub(crate) fn host_config_status(&self) -> DynamicPluginHostConfigStatus { - if self.has_explicit_config { - DynamicPluginHostConfigStatus::Present - } else { - DynamicPluginHostConfigStatus::Absent - } - } -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct AgentConfigs { - pub(crate) claude: AgentCommandConfig, - pub(crate) codex: AgentCommandConfig, - pub(crate) hermes: AgentCommandConfig, -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct AgentCommandConfig { - pub(crate) command: Option, - /// Legacy Hermes config-path override retained for existing Relay configuration files. - /// New setup flows do not write it; persistent Hermes state belongs to `install hermes`. - pub(crate) hooks_path: Option, -} - -impl AgentConfigs { - pub(crate) const fn get(&self, agent: CodingAgent) -> &AgentCommandConfig { - match agent { - CodingAgent::ClaudeCode => &self.claude, - CodingAgent::Codex => &self.codex, - CodingAgent::Hermes => &self.hermes, - } - } -} - // TOML file shape grouped by user intent. Sections map 1:1 onto fields already present on // `GatewayConfig` / `AgentConfigs`; plugin configuration lives in `plugins.toml`. #[derive(Debug, Clone, Default, Deserialize)] @@ -182,25 +83,6 @@ struct FileAgentCommandConfig { hooks_path: Option, } -impl Default for GatewayConfig { - // Supplies conservative local gateway defaults: bind only to loopback, route OpenAI and - // Anthropic requests to their public bases, and leave plugins disabled until config, - // environment, or headers explicitly opt in. - fn default() -> Self { - Self { - bind: "127.0.0.1:4040" - .parse() - .expect("valid default bind address"), - openai_base_url: "https://api.openai.com/v1".into(), - anthropic_base_url: "https://api.anthropic.com".into(), - metadata: None, - plugin_config: None, - max_hook_payload_bytes: DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, - } - } -} - /// Resolves server-mode configuration from shared config files plus server CLI/environment overrides. /// /// File discovery and merge behavior live in `load_shared_config`; this function only applies the diff --git a/crates/cli/src/configuration/types.rs b/crates/cli/src/configuration/types.rs new file mode 100644 index 000000000..39a23c07b --- /dev/null +++ b/crates/cli/src/configuration/types.rs @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Resolved runtime configuration model. + +use std::net::SocketAddr; +use std::path::PathBuf; + +use axum::http::HeaderMap; +use serde::Serialize; +use serde_json::{Map, Value}; +use strum::{Display, IntoStaticStr}; + +use crate::agents::CodingAgent; +use crate::plugins::policy::DynamicPluginHostPolicy; + +use super::{ + DEFAULT_MAX_HOOK_PAYLOAD_BYTES, DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, header_json, header_string, +}; + +#[derive(Debug, Clone)] +pub(crate) struct GatewayConfig { + pub(crate) bind: SocketAddr, + pub(crate) openai_base_url: String, + pub(crate) anthropic_base_url: String, + pub(crate) metadata: Option, + pub(crate) plugin_config: Option, + pub(crate) max_hook_payload_bytes: usize, + pub(crate) max_passthrough_body_bytes: usize, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct SessionConfig { + pub(crate) metadata: Option, + pub(crate) plugin_config: Option, + pub(crate) profile: Option, + pub(crate) gateway_mode: Option, +} + +impl GatewayConfig { + pub(crate) fn session_config_from_headers(&self, headers: &HeaderMap) -> SessionConfig { + let metadata = + header_json(headers, "x-nemo-relay-session-metadata").or_else(|| self.metadata.clone()); + let plugin_config = header_json(headers, "x-nemo-relay-plugin-config") + .or_else(|| self.plugin_config.clone()); + let profile = header_string(headers, "x-nemo-relay-config-profile"); + let gateway_mode = header_string(headers, "x-nemo-relay-gateway-mode"); + SessionConfig { + metadata, + plugin_config, + profile, + gateway_mode, + } + } +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct ResolvedConfig { + pub(crate) gateway: GatewayConfig, + pub(crate) agents: AgentConfigs, + pub(crate) dynamic_plugins: Vec, + pub(crate) dynamic_plugin_policy: DynamicPluginHostPolicy, + pub(crate) bootstrap_fingerprint: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ResolvedDynamicPluginConfig { + pub(crate) plugin_id: String, + pub(crate) manifest_ref: String, + pub(crate) config: Map, + pub(crate) has_explicit_config: bool, + pub(crate) source: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Display, IntoStaticStr)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub(crate) enum DynamicPluginHostConfigStatus { + Absent, + Present, +} + +impl ResolvedDynamicPluginConfig { + pub(crate) fn host_config_status(&self) -> DynamicPluginHostConfigStatus { + if self.has_explicit_config { + DynamicPluginHostConfigStatus::Present + } else { + DynamicPluginHostConfigStatus::Absent + } + } +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct AgentConfigs { + pub(crate) claude: AgentCommandConfig, + pub(crate) codex: AgentCommandConfig, + pub(crate) hermes: AgentCommandConfig, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct AgentCommandConfig { + pub(crate) command: Option, + /// Legacy Hermes config-path override retained for existing Relay configuration files. + pub(crate) hooks_path: Option, +} + +impl AgentConfigs { + pub(crate) const fn get(&self, agent: CodingAgent) -> &AgentCommandConfig { + match agent { + CodingAgent::ClaudeCode => &self.claude, + CodingAgent::Codex => &self.codex, + CodingAgent::Hermes => &self.hermes, + } + } +} + +impl Default for GatewayConfig { + fn default() -> Self { + Self { + bind: "127.0.0.1:4040" + .parse() + .expect("valid default bind address"), + openai_base_url: "https://api.openai.com/v1".into(), + anthropic_base_url: "https://api.anthropic.com".into(), + metadata: None, + plugin_config: None, + max_hook_payload_bytes: DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + } + } +} From d59ece42b00bcf75e32ff5db9c94458d9b91845a Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:13:20 -0400 Subject: [PATCH 059/102] refactor(cli): source agent identity from agents Signed-off-by: Will Killian --- crates/cli/src/agents/codex/host.rs | 5 ++--- crates/cli/src/agents/hermes/config.rs | 14 +++++++------- crates/cli/src/agents/hermes/integration.rs | 2 +- crates/cli/src/agents/hermes/trust.rs | 2 +- crates/cli/src/configuration/mod.rs | 1 - crates/cli/src/configuration/setup.rs | 2 +- crates/cli/src/diagnostics/mod.rs | 3 ++- crates/cli/src/process/launcher.rs | 5 ++--- crates/cli/src/process/mod.rs | 2 +- crates/cli/tests/coverage/agents/hermes_tests.rs | 2 +- .../tests/coverage/agents/plugin_install_tests.rs | 2 +- crates/cli/tests/coverage/shared/config_tests.rs | 1 + 12 files changed, 20 insertions(+), 21 deletions(-) diff --git a/crates/cli/src/agents/codex/host.rs b/crates/cli/src/agents/codex/host.rs index 570bc777e..540c1e5cd 100644 --- a/crates/cli/src/agents/codex/host.rs +++ b/crates/cli/src/agents/codex/host.rs @@ -12,9 +12,8 @@ use std::process::ExitCode; use serde_json::{Value, json}; use toml_edit::{DocumentMut, InlineTable, Item, Table, Value as TomlValue, value}; -use crate::configuration::{ - BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey, CodingAgent, RELAY_PLUGIN_ID, -}; +use crate::agents::CodingAgent; +use crate::configuration::{BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey, RELAY_PLUGIN_ID}; use crate::hooks::generated_hooks; #[cfg(test)] use crate::hooks::merge_hooks; diff --git a/crates/cli/src/agents/hermes/config.rs b/crates/cli/src/agents/hermes/config.rs index d76a9956a..1108fb3bd 100644 --- a/crates/cli/src/agents/hermes/config.rs +++ b/crates/cli/src/agents/hermes/config.rs @@ -36,13 +36,13 @@ pub(crate) fn transparent_config( remove_owned_mcp(&mut root, owned.is_some())?; let command = crate::hooks::transparent_hook_forward_command( relay, - crate::configuration::CodingAgent::Hermes, + crate::agents::CodingAgent::Hermes, gateway_url, ) .map_err(CliError::Install)?; let root = merge_hooks( root, - generated_hooks(crate::configuration::CodingAgent::Hermes, &command), + generated_hooks(crate::agents::CodingAgent::Hermes, &command), )?; serde_yaml::to_string(&root).map_err(|error| CliError::Install(error.to_string())) } @@ -54,7 +54,7 @@ pub(crate) fn persistent_hook_command( ) -> Result { crate::hooks::persistent_hook_forward_command( relay, - crate::configuration::CodingAgent::Hermes, + crate::agents::CodingAgent::Hermes, generation, generation_token, ) @@ -69,7 +69,7 @@ pub(super) fn persistent_hook_command_for_platform( ) -> String { crate::hooks::persistent_hook_forward_command_for_platform( relay, - crate::configuration::CodingAgent::Hermes, + crate::agents::CodingAgent::Hermes, generation, generation_token, windows, @@ -98,7 +98,7 @@ pub(super) fn persistent_config( strip_owned_hooks(&mut root, owned.as_deref())?; root = merge_hooks( root, - generated_hooks(crate::configuration::CodingAgent::Hermes, command), + generated_hooks(crate::agents::CodingAgent::Hermes, command), )?; let servers = object_field_mut(&mut root, "mcp_servers", "mcp_servers")?; servers.insert( @@ -224,7 +224,7 @@ pub(super) fn owned_install_command( } fn has_complete_hook_set(root: &Value, command: &str) -> bool { - crate::configuration::CodingAgent::Hermes + crate::agents::CodingAgent::Hermes .hook_events() .iter() .all(|event| { @@ -251,7 +251,7 @@ fn legacy_owned_command(root: &Value, relay: &Path) -> Result, Cl return Ok(None); }; let mut common = None; - for event in crate::configuration::CodingAgent::Hermes.hook_events() { + for event in crate::agents::CodingAgent::Hermes.hook_events() { let commands = hooks .get(*event) .and_then(Value::as_array) diff --git a/crates/cli/src/agents/hermes/integration.rs b/crates/cli/src/agents/hermes/integration.rs index 7ddd30707..d35b4c9f7 100644 --- a/crates/cli/src/agents/hermes/integration.rs +++ b/crates/cli/src/agents/hermes/integration.rs @@ -23,8 +23,8 @@ use super::files::{ acquire_install_lock, read_optional_utf8, remove_optional_file, replace_optional_file, }; use super::trust::{json_bytes, parse_json_object, trusted_hooks, verify_trust}; +use crate::agents::CodingAgent; use crate::bootstrap::DEFAULT_BIND; -use crate::configuration::CodingAgent; use crate::error::CliError; use crate::filesystem::atomic_write; #[cfg(test)] diff --git a/crates/cli/src/agents/hermes/trust.rs b/crates/cli/src/agents/hermes/trust.rs index 8e7f7a8fb..494c26c53 100644 --- a/crates/cli/src/agents/hermes/trust.rs +++ b/crates/cli/src/agents/hermes/trust.rs @@ -10,7 +10,7 @@ use std::time::SystemTime; use chrono::{DateTime, SecondsFormat, Utc}; use serde_json::{Value, json}; -use crate::configuration::CodingAgent; +use crate::agents::CodingAgent; use crate::error::CliError; pub(super) fn trusted_hooks( diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs index 1618fd13b..cb561a567 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -24,7 +24,6 @@ use ring::{digest, hmac}; use serde::Deserialize; use serde_json::{Map, Value}; -pub(crate) use crate::agents::CodingAgent; use crate::error::CliError; use crate::filesystem::{LockAttempt, try_lock_exclusive, try_lock_shared}; #[cfg(test)] diff --git a/crates/cli/src/configuration/setup.rs b/crates/cli/src/configuration/setup.rs index d860aa0a8..ef4bce99f 100644 --- a/crates/cli/src/configuration/setup.rs +++ b/crates/cli/src/configuration/setup.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use toml_edit::{DocumentMut, Item, Table, value}; -use crate::configuration::CodingAgent; +use crate::agents::CodingAgent; use crate::error::CliError; use crate::plugins::{ConfigurationScope, PluginsEditRequest}; diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index e3b03cf89..308ee9b6d 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -32,8 +32,9 @@ use tokio::time::timeout; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use uuid::Uuid; +use crate::agents::CodingAgent; use crate::configuration::{ - AgentConfigs, CodingAgent, DynamicPluginHostConfigStatus, GatewayConfig, ResolvedConfig, + AgentConfigs, DynamicPluginHostConfigStatus, GatewayConfig, ResolvedConfig, default_plugin_config_paths, effective_plugin_toml_sources, resolve_server_config, }; use crate::error::CliError; diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index 19e1fccca..9fe7a360d 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -16,9 +16,8 @@ use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio::task::JoinHandle; -use crate::configuration::{ - AgentConfigs, CodingAgent, GatewayConfig, ResolvedConfig, resolve_run_config, -}; +use crate::agents::CodingAgent; +use crate::configuration::{AgentConfigs, GatewayConfig, ResolvedConfig, resolve_run_config}; use crate::error::CliError; use crate::plugins::lifecycle::ActiveDynamicPluginComponent; use crate::server; diff --git a/crates/cli/src/process/mod.rs b/crates/cli/src/process/mod.rs index eedcb2447..228fe5f34 100644 --- a/crates/cli/src/process/mod.rs +++ b/crates/cli/src/process/mod.rs @@ -16,7 +16,7 @@ use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::process::Command; -use crate::configuration::CodingAgent; +use crate::agents::CodingAgent; /// Parses the intentionally simple command strings accepted by `[agents.*].command`. /// diff --git a/crates/cli/tests/coverage/agents/hermes_tests.rs b/crates/cli/tests/coverage/agents/hermes_tests.rs index 9825bd3f1..86568a08f 100644 --- a/crates/cli/tests/coverage/agents/hermes_tests.rs +++ b/crates/cli/tests/coverage/agents/hermes_tests.rs @@ -10,7 +10,7 @@ use std::time::{Duration, UNIX_EPOCH}; use serde_json::{Value, json}; use super::*; -use crate::configuration::CodingAgent; +use crate::agents::CodingAgent; const TEST_GENERATION_TOKEN: &str = "test-generation"; diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index 4070a2039..a15d2b050 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -20,8 +20,8 @@ use super::host::{ validate_relay_mcp, }; use super::*; +use crate::agents::CodingAgent; use crate::agents::strip_windows_verbatim_prefix; -use crate::configuration::CodingAgent; const OPERATION_LOCK_HELPER_DIR_ENV: &str = "NEMO_RELAY_TEST_OPERATION_LOCK_DIR"; const OPERATION_LOCK_HELPER_GLOBAL_DIR_ENV: &str = "NEMO_RELAY_TEST_OPERATION_LOCK_GLOBAL_DIR"; diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index 51735db81..1aa63f574 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; +use crate::agents::CodingAgent; use crate::filesystem::bounded::{ MAX_BOUNDED_FILE_BYTES as MAX_BOOTSTRAP_IDENTITY_FILE_BYTES, read_bounded_regular_file, }; From b60cf2cfdac37b2cc546c0473dad177c96c3e261 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:14:39 -0400 Subject: [PATCH 060/102] refactor(cli): split plugin command syntax from dispatch Signed-off-by: Will Killian --- .../mod.rs} | 0 crates/cli/src/commands/plugins/mod.rs | 69 +++++++++++++++++++ .../{plugins.rs => plugins/subcommands.rs} | 59 +--------------- 3 files changed, 70 insertions(+), 58 deletions(-) rename crates/cli/src/commands/{model_pricing.rs => model_pricing/mod.rs} (100%) create mode 100644 crates/cli/src/commands/plugins/mod.rs rename crates/cli/src/commands/{plugins.rs => plugins/subcommands.rs} (75%) diff --git a/crates/cli/src/commands/model_pricing.rs b/crates/cli/src/commands/model_pricing/mod.rs similarity index 100% rename from crates/cli/src/commands/model_pricing.rs rename to crates/cli/src/commands/model_pricing/mod.rs diff --git a/crates/cli/src/commands/plugins/mod.rs b/crates/cli/src/commands/plugins/mod.rs new file mode 100644 index 000000000..c52d298d9 --- /dev/null +++ b/crates/cli/src/commands/plugins/mod.rs @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Dynamic-plugin command syntax, dispatch, and rendering. + +mod subcommands; + +pub(crate) use subcommands::PluginsCommand; +#[cfg(test)] +pub(crate) use subcommands::*; + +use std::process::ExitCode; + +use super::serve::ServerArgs; +use crate::error::CliError; + +pub(super) fn execute(command: PluginsCommand, server: &ServerArgs) -> Result { + let server = server.to_runtime(); + let json_context = command + .command + .json_context() + .map(|context| (context.command, context.target.map(str::to_owned))); + let json = json_context.is_some(); + let result = match command.command { + subcommands::PluginsSubcommand::Edit(command) => { + crate::plugins::edit(command.into_runtime()) + } + subcommands::PluginsSubcommand::Add(command) => { + crate::plugins::lifecycle::add(command.into_runtime(), &server) + } + subcommands::PluginsSubcommand::Validate(command) => { + crate::plugins::lifecycle::validate(command.into_runtime(), &server) + } + subcommands::PluginsSubcommand::List(command) => { + crate::plugins::lifecycle::list(command.into_runtime(), &server) + } + subcommands::PluginsSubcommand::Inspect(command) => { + crate::plugins::lifecycle::inspect(command.into_runtime(), &server) + } + subcommands::PluginsSubcommand::Enable(command) => { + crate::plugins::lifecycle::enable(command.into_runtime(), &server) + } + subcommands::PluginsSubcommand::Disable(command) => { + crate::plugins::lifecycle::disable(command.into_runtime(), &server) + } + subcommands::PluginsSubcommand::Remove(command) => { + crate::plugins::lifecycle::remove(command.into_runtime(), &server) + } + }; + match result { + Ok(()) => Ok(ExitCode::SUCCESS), + Err(error) => { + if let Some(exit_code) = crate::plugins::lifecycle::render_plugin_error(&error, json)? { + Ok(exit_code) + } else if json { + let (command, target) = json_context + .as_ref() + .expect("json plugin command context should exist when enabled"); + crate::plugins::lifecycle::render_generic_plugin_json_error( + command, + target.as_deref(), + &error.to_string(), + ) + } else { + Err(error) + } + } + } +} diff --git a/crates/cli/src/commands/plugins.rs b/crates/cli/src/commands/plugins/subcommands.rs similarity index 75% rename from crates/cli/src/commands/plugins.rs rename to crates/cli/src/commands/plugins/subcommands.rs index 9aeb4701e..c9cbfbd3a 100644 --- a/crates/cli/src/commands/plugins.rs +++ b/crates/cli/src/commands/plugins/subcommands.rs @@ -1,13 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::path::PathBuf; -use std::process::ExitCode; - use clap::{ArgGroup, Args, Subcommand}; - -use super::serve::ServerArgs; -use crate::error::CliError; +use std::path::PathBuf; /// Args for `nemo-relay plugins`. #[derive(Debug, Clone, Args)] @@ -216,55 +211,3 @@ impl PluginsRemoveCommand { crate::plugins::PluginsRemoveRequest { id: self.id } } } - -pub(super) fn execute(command: PluginsCommand, server: &ServerArgs) -> Result { - let server = server.to_runtime(); - let json_context = command - .command - .json_context() - .map(|context| (context.command, context.target.map(str::to_owned))); - let json = json_context.is_some(); - let result = match command.command { - PluginsSubcommand::Edit(command) => crate::plugins::edit(command.into_runtime()), - PluginsSubcommand::Add(command) => { - crate::plugins::lifecycle::add(command.into_runtime(), &server) - } - PluginsSubcommand::Validate(command) => { - crate::plugins::lifecycle::validate(command.into_runtime(), &server) - } - PluginsSubcommand::List(command) => { - crate::plugins::lifecycle::list(command.into_runtime(), &server) - } - PluginsSubcommand::Inspect(command) => { - crate::plugins::lifecycle::inspect(command.into_runtime(), &server) - } - PluginsSubcommand::Enable(command) => { - crate::plugins::lifecycle::enable(command.into_runtime(), &server) - } - PluginsSubcommand::Disable(command) => { - crate::plugins::lifecycle::disable(command.into_runtime(), &server) - } - PluginsSubcommand::Remove(command) => { - crate::plugins::lifecycle::remove(command.into_runtime(), &server) - } - }; - match result { - Ok(()) => Ok(ExitCode::SUCCESS), - Err(error) => { - if let Some(exit_code) = crate::plugins::lifecycle::render_plugin_error(&error, json)? { - Ok(exit_code) - } else if json { - let (command, target) = json_context - .as_ref() - .expect("json plugin command context should exist when enabled"); - crate::plugins::lifecycle::render_generic_plugin_json_error( - command, - target.as_deref(), - &error.to_string(), - ) - } else { - Err(error) - } - } - } -} From 20c0c16a39c0681e9be3319daeae438eaf34999c Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:17:41 -0400 Subject: [PATCH 061/102] refactor(cli): isolate gateway request preparation Signed-off-by: Will Killian --- crates/cli/src/gateway/mod.rs | 142 +----------------- crates/cli/src/gateway/request.rs | 137 +++++++++++++++++ crates/cli/src/gateway/response.rs | 7 +- .../tests/coverage/shared/gateway_tests.rs | 1 + 4 files changed, 149 insertions(+), 138 deletions(-) create mode 100644 crates/cli/src/gateway/request.rs diff --git a/crates/cli/src/gateway/mod.rs b/crates/cli/src/gateway/mod.rs index 95a33b144..449a7303d 100644 --- a/crates/cli/src/gateway/mod.rs +++ b/crates/cli/src/gateway/mod.rs @@ -2,13 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 pub(crate) mod client; +mod request; mod response; mod routes; +use request::*; use response::*; use routes::*; -use std::error::Error; use std::sync::{Arc, Mutex}; use async_stream::stream; @@ -16,7 +17,6 @@ use axum::body::{Body, Bytes}; use axum::extract::State; use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode}; use futures_util::StreamExt; -use http_body_util::LengthLimitError; use nemo_relay::api::llm::{ LlmCallExecuteParams, LlmRequest, LlmStreamCallExecuteParams, llm_call_execute, llm_stream_call_execute, @@ -31,13 +31,13 @@ use nemo_relay::codec::resolve::{ use nemo_relay::codec::streaming::StreamingCodec; use nemo_relay::codec::traits::LlmResponseCodec; use nemo_relay::error::FlowError; -use serde_json::{Map, Value, json}; +use serde_json::{Value, json}; use crate::agents::shared::alignment::{self, GatewayRouteKind}; -use crate::configuration::{BOOTSTRAP_CLIENT_TOKEN_HEADER, header_string}; +use crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER; use crate::error::CliError; use crate::server::AppState; -use crate::sessions::{GatewayCallPrep, GatewaySessionFinish, LlmGatewayStart, SessionManager}; +use crate::sessions::{GatewayCallPrep, GatewaySessionFinish, SessionManager}; #[cfg(test)] #[path = "../../tests/coverage/shared/gateway_tests.rs"] @@ -71,138 +71,6 @@ pub(crate) async fn passthrough( run_managed_gateway(state, prepared, prep).await } -struct PreparedGatewayRequest { - method: Method, - headers: HeaderMap, - path: String, - provider: ProviderRoute, - upstream_url: String, - body_bytes: Bytes, - request_json: Value, - streaming: bool, - allow_environment_provider_auth: bool, -} - -// Validates the gateway route, buffers the request body exactly once, and derives the metadata used -// for both upstream forwarding and NeMo Relay LLM start events. Provider JSON parse failures are not -// request failures because the gateway still forwards raw bytes unchanged. -async fn prepare_gateway_request( - config: &crate::configuration::GatewayConfig, - request: Request, - allow_environment_provider_auth: bool, -) -> Result { - let (mut parts, body) = request.into_parts(); - // This proof authorizes Relay's local credential injection only. It must not be observed by - // middleware, recorded in ATOF, or forwarded to the model provider. - parts.headers.remove(BOOTSTRAP_CLIENT_TOKEN_HEADER); - let provider = ProviderRoute::from_path(parts.uri.path()).ok_or_else(|| { - CliError::InvalidPayload(format!("unsupported gateway path {}", parts.uri.path())) - })?; - let body_bytes = axum::body::to_bytes(body, config.max_passthrough_body_bytes) - .await - .map_err(passthrough_body_error)?; - let request_json = serde_json::from_slice::(&body_bytes).unwrap_or(Value::Null); - let path_and_query = parts - .uri - .path_and_query() - .map(|p| p.as_str()) - .unwrap_or(parts.uri.path()); - let upstream_url = gateway_upstream_url_override( - provider, - &parts.headers, - path_and_query, - allow_environment_provider_auth, - ) - .unwrap_or_else(|| provider.upstream_url(config, path_and_query)); - let streaming = request_json - .get("stream") - .and_then(Value::as_bool) - .unwrap_or(false); - Ok(PreparedGatewayRequest { - method: parts.method, - headers: parts.headers, - path: parts.uri.path().to_string(), - provider, - upstream_url, - body_bytes, - request_json, - streaming, - allow_environment_provider_auth, - }) -} - -fn passthrough_body_error(error: axum::Error) -> CliError { - if error.source().is_some_and(|source| { - source.is::() - || source - .source() - .is_some_and(|source| source.is::()) - }) { - CliError::PayloadTooLarge(error.to_string()) - } else { - CliError::InvalidPayload(error.to_string()) - } -} - -// Builds the [`LlmGatewayStart`] payload from a prepared request. Identifier resolution is shared -// across streaming and non-streaming paths so correlation behavior is consistent for every route. -// Provider-specific fallbacks are resolved here, before request execution leaves the gateway path, -// because the later runtime-managed LLM call only sees this normalized start payload. -fn build_llm_gateway_start(request: &PreparedGatewayRequest) -> LlmGatewayStart { - LlmGatewayStart { - // Explicit NeMo Relay headers still win, but alignment can recover agent-native session - // signals when available. Applies to Claude Code's session header and Codex's Responses - // prompt-cache thread id today. - session_id: gateway_session_id(&request.headers, &request.request_json, request.provider), - provider: request.provider.name().to_string(), - model_name: request - .request_json - .get("model") - .and_then(Value::as_str) - .map(ToOwned::to_owned), - // Subagent ownership is intentionally header-only at the gateway layer. Body fields can be - // provider payload content rather than scope identity, so the session layer handles other - // ownership hints. - subagent_id: gateway_subagent_id(&request.headers), - conversation_id: gateway_identifier( - &request.headers, - &request.request_json, - "x-nemo-relay-conversation-id", - &[ - &["conversation_id"], - &["conversationId"], - &["conversation", "id"], - ], - ), - generation_id: gateway_identifier( - &request.headers, - &request.request_json, - "x-nemo-relay-generation-id", - &[&["generation_id"], &["generationId"], &["generation", "id"]], - ), - request_id: gateway_identifier( - &request.headers, - &request.request_json, - "x-nemo-relay-request-id", - &[ - &["request_id"], - &["requestId"], - &["request", "id"], - &["metadata", "request_id"], - ], - ) - // Preserve a transport request id as a weak fallback for debugging even when the provider - // body does not expose an LLM request id. - .or_else(|| header_string(&request.headers, "x-request-id")), - request: LlmRequest { - headers: observable_headers(&request.headers), - content: request.request_json.clone(), - }, - streaming: request.streaming, - metadata: json!({ "gateway_path": request.path }), - } -} - // Captures upstream HTTP status and response headers from inside the managed `func`. The runtime's // LLM execution callback returns only a Json (or Json stream), so the outer gateway needs a side // channel to recover the bytes the client expects. diff --git a/crates/cli/src/gateway/request.rs b/crates/cli/src/gateway/request.rs new file mode 100644 index 000000000..614812e08 --- /dev/null +++ b/crates/cli/src/gateway/request.rs @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway request validation, buffering, and normalized LLM start construction. + +use std::error::Error; + +use axum::body::{Body, Bytes}; +use axum::http::{HeaderMap, Method, Request}; +use http_body_util::LengthLimitError; +use nemo_relay::api::llm::LlmRequest; +use serde_json::{Value, json}; + +use crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER; +use crate::error::CliError; +use crate::sessions::LlmGatewayStart; + +use super::response::observable_headers; +use super::routes::{ + ProviderRoute, gateway_identifier, gateway_session_id, gateway_subagent_id, + gateway_upstream_url_override, +}; + +pub(super) struct PreparedGatewayRequest { + pub(super) method: Method, + pub(super) headers: HeaderMap, + pub(super) path: String, + pub(super) provider: ProviderRoute, + pub(super) upstream_url: String, + pub(super) body_bytes: Bytes, + pub(super) request_json: Value, + pub(super) streaming: bool, + pub(super) allow_environment_provider_auth: bool, +} + +pub(super) async fn prepare_gateway_request( + config: &crate::configuration::GatewayConfig, + request: Request, + allow_environment_provider_auth: bool, +) -> Result { + let (mut parts, body) = request.into_parts(); + parts.headers.remove(BOOTSTRAP_CLIENT_TOKEN_HEADER); + let provider = ProviderRoute::from_path(parts.uri.path()).ok_or_else(|| { + CliError::InvalidPayload(format!("unsupported gateway path {}", parts.uri.path())) + })?; + let body_bytes = axum::body::to_bytes(body, config.max_passthrough_body_bytes) + .await + .map_err(passthrough_body_error)?; + let request_json = serde_json::from_slice::(&body_bytes).unwrap_or(Value::Null); + let path_and_query = parts + .uri + .path_and_query() + .map(|path| path.as_str()) + .unwrap_or(parts.uri.path()); + let upstream_url = gateway_upstream_url_override( + provider, + &parts.headers, + path_and_query, + allow_environment_provider_auth, + ) + .unwrap_or_else(|| provider.upstream_url(config, path_and_query)); + let streaming = request_json + .get("stream") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(PreparedGatewayRequest { + method: parts.method, + headers: parts.headers, + path: parts.uri.path().to_string(), + provider, + upstream_url, + body_bytes, + request_json, + streaming, + allow_environment_provider_auth, + }) +} + +fn passthrough_body_error(error: axum::Error) -> CliError { + if error.source().is_some_and(|source| { + source.is::() + || source + .source() + .is_some_and(|source| source.is::()) + }) { + CliError::PayloadTooLarge(error.to_string()) + } else { + CliError::InvalidPayload(error.to_string()) + } +} + +pub(super) fn build_llm_gateway_start(request: &PreparedGatewayRequest) -> LlmGatewayStart { + LlmGatewayStart { + session_id: gateway_session_id(&request.headers, &request.request_json, request.provider), + provider: request.provider.name().to_string(), + model_name: request + .request_json + .get("model") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + subagent_id: gateway_subagent_id(&request.headers), + conversation_id: gateway_identifier( + &request.headers, + &request.request_json, + "x-nemo-relay-conversation-id", + &[ + &["conversation_id"], + &["conversationId"], + &["conversation", "id"], + ], + ), + generation_id: gateway_identifier( + &request.headers, + &request.request_json, + "x-nemo-relay-generation-id", + &[&["generation_id"], &["generationId"], &["generation", "id"]], + ), + request_id: gateway_identifier( + &request.headers, + &request.request_json, + "x-nemo-relay-request-id", + &[ + &["request_id"], + &["requestId"], + &["request", "id"], + &["metadata", "request_id"], + ], + ) + .or_else(|| crate::configuration::header_string(&request.headers, "x-request-id")), + request: LlmRequest { + headers: observable_headers(&request.headers), + content: request.request_json.clone(), + }, + streaming: request.streaming, + metadata: json!({ "gateway_path": request.path }), + } +} diff --git a/crates/cli/src/gateway/response.rs b/crates/cli/src/gateway/response.rs index 3dcbf8a51..1ffe234a9 100644 --- a/crates/cli/src/gateway/response.rs +++ b/crates/cli/src/gateway/response.rs @@ -3,7 +3,12 @@ //! Observable header policy and downstream response construction. -use super::*; +use axum::body::Body; +use axum::http::{HeaderMap, HeaderName, Response, StatusCode}; +use serde_json::{Map, Value, json}; + +use crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER; +use crate::error::CliError; pub(super) fn observable_headers(headers: &HeaderMap) -> Map { let mut output = Map::new(); diff --git a/crates/cli/tests/coverage/shared/gateway_tests.rs b/crates/cli/tests/coverage/shared/gateway_tests.rs index 72d99d96f..c9b8e8383 100644 --- a/crates/cli/tests/coverage/shared/gateway_tests.rs +++ b/crates/cli/tests/coverage/shared/gateway_tests.rs @@ -11,6 +11,7 @@ use axum::extract::State; use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header}; use http_body_util::BodyExt; use reqwest::Client; +use serde_json::Map; fn test_http_client() -> Client { Client::new() From c482557ab31ed0cb6e05af280d77f28586428611 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:19:01 -0400 Subject: [PATCH 062/102] refactor(cli): separate session gateway types Signed-off-by: Will Killian --- crates/cli/src/sessions/mod.rs | 60 ++------------------------------ crates/cli/src/sessions/types.rs | 58 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 58 deletions(-) create mode 100644 crates/cli/src/sessions/types.rs diff --git a/crates/cli/src/sessions/mod.rs b/crates/cli/src/sessions/mod.rs index c2ff1c057..1248ace0b 100644 --- a/crates/cli/src/sessions/mod.rs +++ b/crates/cli/src/sessions/mod.rs @@ -31,8 +31,10 @@ use crate::agents::shared::alignment::{ use crate::configuration::{GatewayConfig, SessionConfig}; use crate::error::CliError; mod correlation; +mod types; use correlation::*; +pub(crate) use types::*; use crate::events::{ AgentKind, LlmEvent, LlmHintEvent, NormalizedEvent, SessionEvent, SubagentEvent, ToolEvent, @@ -54,64 +56,6 @@ pub(crate) struct SessionManager { default_config: GatewayConfig, } -#[derive(Debug, Clone)] -pub(crate) struct LlmGatewayStart { - pub(crate) session_id: Option, - pub(crate) provider: String, - pub(crate) model_name: Option, - pub(crate) subagent_id: Option, - pub(crate) conversation_id: Option, - pub(crate) generation_id: Option, - pub(crate) request_id: Option, - pub(crate) request: LlmRequest, - pub(crate) streaming: bool, - pub(crate) metadata: Value, -} - -/// Legacy active-LLM record kept for tests that exercise the manual `llm_call` / -/// `llm_call_end` correlation path. Production gateway traffic now uses managed execution via -/// [`SessionManager::prepare_gateway_call`]. -#[cfg(test)] -#[derive(Debug, Clone)] -pub(crate) struct ActiveLlm { - stack: ScopeStackHandle, - handle: LlmHandle, - session_id: String, - owner_subagent_id: Option, -} - -/// Inputs prepared by [`SessionManager::prepare_gateway_call`] for invoking the -/// runtime's managed LLM execution pipeline outside the session lock. -/// -/// The session lock is released after the prep is built, so the gateway can run -/// the upstream HTTP work without blocking unrelated session activity. The -/// preserved `scope_stack` is what restores the turn/subagent scope context -/// the call was opened against when the runtime emits start/end events. -pub(crate) struct GatewayCallPrep { - pub(crate) scope_stack: ScopeStackHandle, - pub(crate) session_id: String, - pub(crate) provider_name: String, - pub(crate) request: LlmRequest, - pub(crate) parent: Option, - pub(crate) attributes: LlmAttributes, - pub(crate) metadata: Value, - pub(crate) model_name: Option, - pub(crate) owner_subagent_id: Option, - pub(crate) bypass_managed_pipeline: bool, - pub(crate) session_finish: GatewaySessionFinish, -} - -/// Cleanup policy for the session selected by one gateway request. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum GatewaySessionFinish { - /// Keep an explicit, correlated, or sole active session for later lifecycle events. - Retain, - /// Remove a startup-probe session when it never opened observable scopes. - PruneIfEmpty, - /// Close an isolated synthetic session as soon as its only gateway call completes. - Close, -} - struct Session { agent_kind: AgentKind, session_id: String, diff --git a/crates/cli/src/sessions/types.rs b/crates/cli/src/sessions/types.rs new file mode 100644 index 000000000..0e8cf84c6 --- /dev/null +++ b/crates/cli/src/sessions/types.rs @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Session gateway inputs and prepared-call outputs. + +#[cfg(test)] +use nemo_relay::api::llm::LlmHandle; +use nemo_relay::api::llm::{LlmAttributes, LlmRequest}; +use nemo_relay::api::runtime::ScopeStackHandle; +use nemo_relay::api::scope::ScopeHandle; +use serde_json::Value; + +#[derive(Debug, Clone)] +pub(crate) struct LlmGatewayStart { + pub(crate) session_id: Option, + pub(crate) provider: String, + pub(crate) model_name: Option, + pub(crate) subagent_id: Option, + pub(crate) conversation_id: Option, + pub(crate) generation_id: Option, + pub(crate) request_id: Option, + pub(crate) request: LlmRequest, + pub(crate) streaming: bool, + pub(crate) metadata: Value, +} + +/// Legacy active-LLM record retained for manual-correlation tests. +#[cfg(test)] +#[derive(Debug, Clone)] +pub(crate) struct ActiveLlm { + pub(super) stack: ScopeStackHandle, + pub(super) handle: LlmHandle, + pub(super) session_id: String, + pub(super) owner_subagent_id: Option, +} + +/// Inputs for invoking managed LLM execution after releasing the session lock. +pub(crate) struct GatewayCallPrep { + pub(crate) scope_stack: ScopeStackHandle, + pub(crate) session_id: String, + pub(crate) provider_name: String, + pub(crate) request: LlmRequest, + pub(crate) parent: Option, + pub(crate) attributes: LlmAttributes, + pub(crate) metadata: Value, + pub(crate) model_name: Option, + pub(crate) owner_subagent_id: Option, + pub(crate) bypass_managed_pipeline: bool, + pub(crate) session_finish: GatewaySessionFinish, +} + +/// Cleanup policy for the session selected by one gateway request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GatewaySessionFinish { + Retain, + PruneIfEmpty, + Close, +} From f45bd5c2943ed9560615407eafbbf97634241ce9 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:20:14 -0400 Subject: [PATCH 063/102] refactor(cli): isolate session idle shutdown Signed-off-by: Will Killian --- crates/cli/src/sessions/idle.rs | 97 +++++++++++++++++++++++++++++++++ crates/cli/src/sessions/mod.rs | 84 +--------------------------- 2 files changed, 100 insertions(+), 81 deletions(-) create mode 100644 crates/cli/src/sessions/idle.rs diff --git a/crates/cli/src/sessions/idle.rs b/crates/cli/src/sessions/idle.rs new file mode 100644 index 000000000..e540c79ca --- /dev/null +++ b/crates/cli/src/sessions/idle.rs @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Idle-session sweeping and shutdown closure. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use nemo_relay::api::runtime::TASK_SCOPE_STACK; +use tokio::sync::Mutex; + +use crate::agents::shared::alignment::SessionAlignmentState; +use crate::error::CliError; + +use super::Session; + +pub(super) const AGENT_IDLE_TIMEOUT: Duration = Duration::from_secs(30); +pub(super) const AGENT_IDLE_SWEEP_INTERVAL: Duration = Duration::from_secs(5); + +pub(super) async fn close_sessions_for_shutdown( + sessions: &mut [Session], + reason: &str, +) -> Result<(), CliError> { + let mut first_error = None; + for session in sessions { + if let Err(error) = session.close_for_shutdown(reason).await + && first_error.is_none() + { + first_error = Some(error); + } + } + first_error.map_or(Ok(()), Err) +} + +pub(super) async fn close_idle_sessions_from_parts( + inner: &Arc>>, + alignment: &Arc>, + now: Instant, + timeout: Duration, + reason: &str, +) -> Result { + let mut idle_sessions = Vec::new(); + { + let mut sessions = inner.lock().await; + let ids = sessions + .iter() + .filter_map(|(session_id, session)| { + session + .is_idle_for(now, timeout) + .then_some(session_id.clone()) + }) + .collect::>(); + for session_id in ids { + if let Some(session) = sessions.remove(&session_id) { + idle_sessions.push((session_id, session)); + } + } + } + if idle_sessions.is_empty() { + return Ok(0); + } + let mut closed_turns = 0; + let mut closed_subagents = Vec::new(); + let mut retained_sessions = Vec::new(); + let mut first_error = None; + for (session_id, mut session) in idle_sessions { + let stack = session.scope_stack.clone(); + let result = TASK_SCOPE_STACK + .scope(stack, async { session.close_turn_for_reason(reason).await }) + .await; + match result { + Ok(subagent_ids) => { + closed_turns += 1; + for subagent_id in subagent_ids { + closed_subagents.push((session_id.clone(), subagent_id)); + } + } + Err(error) if first_error.is_none() => first_error = Some(error), + Err(_) => {} + } + if !session.is_empty() { + retained_sessions.push((session_id, session)); + } + } + { + let mut sessions = inner.lock().await; + sessions.extend(retained_sessions); + } + if !closed_subagents.is_empty() { + let mut alignment_state = alignment.lock().await; + for (session_id, subagent_id) in closed_subagents { + alignment_state.clear_for_ended_subagent(&session_id, &subagent_id); + } + } + first_error.map_or(Ok(closed_turns), Err) +} diff --git a/crates/cli/src/sessions/mod.rs b/crates/cli/src/sessions/mod.rs index 1248ace0b..4fcbc17c9 100644 --- a/crates/cli/src/sessions/mod.rs +++ b/crates/cli/src/sessions/mod.rs @@ -31,9 +31,11 @@ use crate::agents::shared::alignment::{ use crate::configuration::{GatewayConfig, SessionConfig}; use crate::error::CliError; mod correlation; +mod idle; mod types; use correlation::*; +use idle::*; pub(crate) use types::*; use crate::events::{ @@ -43,8 +45,6 @@ use crate::events::{ const LLM_HINT_TTL: Duration = Duration::from_secs(300); const TOOL_HINT_TTL: Duration = Duration::from_secs(300); const LAST_OWNER_TTL: Duration = Duration::from_secs(300); -const AGENT_IDLE_TIMEOUT: Duration = Duration::from_secs(30); -const AGENT_IDLE_SWEEP_INTERVAL: Duration = Duration::from_secs(5); #[derive(Clone)] pub(crate) struct SessionManager { @@ -56,7 +56,7 @@ pub(crate) struct SessionManager { default_config: GatewayConfig, } -struct Session { +pub(super) struct Session { agent_kind: AgentKind, session_id: String, scope_stack: ScopeStackHandle, @@ -698,84 +698,6 @@ async fn promote_pending_subagent( Ok(Some(alias)) } -async fn close_sessions_for_shutdown( - sessions: &mut [Session], - reason: &str, -) -> Result<(), CliError> { - let mut first_error = None; - for session in sessions { - if let Err(error) = session.close_for_shutdown(reason).await - && first_error.is_none() - { - first_error = Some(error); - } - } - first_error.map_or(Ok(()), Err) -} - -async fn close_idle_sessions_from_parts( - inner: &Arc>>, - alignment: &Arc>, - now: Instant, - timeout: Duration, - reason: &str, -) -> Result { - let mut idle_sessions = Vec::new(); - { - let mut sessions = inner.lock().await; - let ids = sessions - .iter() - .filter_map(|(session_id, session)| { - session - .is_idle_for(now, timeout) - .then_some(session_id.clone()) - }) - .collect::>(); - for session_id in ids { - if let Some(session) = sessions.remove(&session_id) { - idle_sessions.push((session_id, session)); - } - } - } - if idle_sessions.is_empty() { - return Ok(0); - } - let mut closed_turns = 0; - let mut closed_subagents = Vec::new(); - let mut retained_sessions = Vec::new(); - let mut first_error = None; - for (session_id, mut session) in idle_sessions { - let stack = session.scope_stack.clone(); - let result = TASK_SCOPE_STACK - .scope(stack, async { session.close_turn_for_reason(reason).await }) - .await; - match result { - Ok(subagent_ids) => { - closed_turns += 1; - for subagent_id in subagent_ids { - closed_subagents.push((session_id.clone(), subagent_id)); - } - } - Err(error) if first_error.is_none() => first_error = Some(error), - Err(_) => {} - } - if !session.is_empty() { - retained_sessions.push((session_id, session)); - } - } - { - let mut sessions = inner.lock().await; - sessions.extend(retained_sessions); - } - if !closed_subagents.is_empty() { - let mut alignment_state = alignment.lock().await; - for (session_id, subagent_id) in closed_subagents { - alignment_state.clear_for_ended_subagent(&session_id, &subagent_id); - } - } - first_error.map_or(Ok(closed_turns), Err) -} - fn route_event_for_session( event: NormalizedEvent, sessions: &mut HashMap, From 201d1abbafc277c299da46003d9f92cfceddc33a Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:22:09 -0400 Subject: [PATCH 064/102] refactor(cli): isolate session alias routing Signed-off-by: Will Killian --- crates/cli/src/sessions/mod.rs | 183 +---------------------------- crates/cli/src/sessions/routing.rs | 176 +++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 179 deletions(-) create mode 100644 crates/cli/src/sessions/routing.rs diff --git a/crates/cli/src/sessions/mod.rs b/crates/cli/src/sessions/mod.rs index 4fcbc17c9..aa712281c 100644 --- a/crates/cli/src/sessions/mod.rs +++ b/crates/cli/src/sessions/mod.rs @@ -25,17 +25,19 @@ use tokio::sync::Mutex; use crate::agents::shared::adapters::{SKILL_LOAD_SOURCE_KEY, SKILL_LOAD_SOURCE_PROMPT_EXPANSION}; use crate::agents::shared::alignment::{ - self, GatewayManagementPolicy, PendingSubagentStart, SessionAlias, SessionAlignmentState, - insert_optional, json_string_at, json_value_at, merge_metadata, + self, GatewayManagementPolicy, SessionAlias, SessionAlignmentState, insert_optional, + json_string_at, json_value_at, merge_metadata, }; use crate::configuration::{GatewayConfig, SessionConfig}; use crate::error::CliError; mod correlation; mod idle; +mod routing; mod types; use correlation::*; use idle::*; +use routing::*; pub(crate) use types::*; use crate::events::{ @@ -568,183 +570,6 @@ impl SessionManager { } } -// Mutates a gateway LLM start in place after alias resolution. The parent session id is what the -// runtime session manager should open, while the subagent id and alias metadata preserve the child -// thread as the LLM owner. -fn apply_start_alias(start: &mut LlmGatewayStart, alias: &SessionAlias) { - start.session_id = Some(alias.parent_session_id.clone()); - start.subagent_id = Some(alias.subagent_id.clone()); - start.metadata = merge_metadata(start.metadata.clone(), alias.metadata()); -} - -// Handles child SessionStart events before normal per-session dispatch. Some harnesses advertise a -// parent session on SessionStart; when the child is still empty, queue or promote that start as a -// subagent instead of letting it open a new root trace. Applies to Codex child threads today. -async fn queue_or_promote_child_start( - event: &mut NormalizedEvent, - sessions: &mut HashMap, - alignment_state: &mut SessionAlignmentState, - config: SessionConfig, -) -> Result { - let Some((child_session_id, pending)) = alignment::pending_subagent_start(event).await else { - return Ok(false); - }; - if sessions - .get(&child_session_id) - .is_some_and(|session| !session.can_reparent_as_subagent_alias()) - { - return Ok(false); - } - if sessions.contains_key(pending.parent_session_id()) { - alignment_state.remove_pending(&child_session_id); - promote_pending_subagent(sessions, alignment_state, child_session_id, pending, config) - .await?; - } else { - // Child-first ordering is possible for harness-managed children. Drop any empty child - // placeholder and wait until the parent hook or a gateway LLM forces promotion. Applies to - // Codex transparent runs today. - sessions.remove(&child_session_id); - alignment_state.insert_pending(child_session_id, pending); - } - Ok(true) -} - -async fn apply_event_to_session( - sessions: &mut HashMap, - session_id: &str, - event: NormalizedEvent, - event_kind: AgentKind, - config: SessionConfig, - is_agent_started: bool, -) -> Result { - let session = sessions - .entry(session_id.to_string()) - .or_insert_with(|| Session::new(session_id.to_string(), event_kind, config)); - if is_agent_started - && session.agent_kind == AgentKind::Gateway - && event_kind != AgentKind::Gateway - { - session.agent_kind = event_kind; - } - session.apply(event).await?; - Ok(session.is_empty()) -} - -// Promotes all child SessionStart hooks that were waiting on a newly opened parent. Multiple -// children can wait for the same parent when parallel harness-managed subagents start before the -// root hook is observed. Applies to Codex child threads today. -async fn promote_pending_subagents_for_parent( - sessions: &mut HashMap, - alignment_state: &mut SessionAlignmentState, - parent_session_id: &str, - config: SessionConfig, -) -> Result<(), CliError> { - for (child_session_id, pending) in alignment_state.pending_for_parent(parent_session_id) { - promote_pending_subagent( - sessions, - alignment_state, - child_session_id, - pending, - config.clone(), - ) - .await?; - } - Ok(()) -} - -// Converts one pending child SessionStart into a parent-owned subagent and installs the alias used -// by later child-session events. If the child session gained real activity while pending, promotion -// is skipped rather than moving existing LLM/tool handles across scopes. Applies to Codex child -// threads today. -async fn promote_pending_subagent( - sessions: &mut HashMap, - alignment_state: &mut SessionAlignmentState, - child_session_id: String, - pending: PendingSubagentStart, - config: SessionConfig, -) -> Result, CliError> { - if sessions - .get(&child_session_id) - .is_some_and(|session| !session.can_reparent_as_subagent_alias()) - { - return Ok(None); - } - sessions.remove(&child_session_id); - let parent_session_id = pending.parent_session_id().to_string(); - let parent_session = sessions - .entry(parent_session_id.clone()) - .or_insert_with(|| { - Session::new(parent_session_id.clone(), pending.event.agent_kind, config) - }); - if !parent_session.session_started && parent_session.agent_scope.is_none() { - // Gateway traffic can be the first signal that forces promotion. In that case, synthesize - // the parent session metadata; the later subagent start will create a turn-scoped parent. - parent_session - .apply(NormalizedEvent::AgentStarted(SessionEvent { - session_id: parent_session_id, - agent_kind: pending.event.agent_kind, - event_name: "implicit_parent_for_aligned_subagent".into(), - payload: Value::Null, - metadata: Value::Null, - })) - .await?; - } - let subagent_event = pending.subagent_start_event(); - parent_session - .apply(NormalizedEvent::SubagentStarted(subagent_event)) - .await?; - let alias = pending.alias_for_child_session(child_session_id.clone()); - alignment_state.insert_alias(child_session_id, alias.clone()); - Ok(Some(alias)) -} - -fn route_event_for_session( - event: NormalizedEvent, - sessions: &mut HashMap, - alignment_state: &mut SessionAlignmentState, -) -> Option<(NormalizedEvent, String, bool)> { - let mut event = alignment_state.route_event(event); - let explicit_subagent_alias = alignment::explicit_subagent_alias(&mut event); - let session_id = event.session_id().to_string(); - let is_agent_started = matches!(&event, NormalizedEvent::AgentStarted(_)); - - if event.is_terminal() && !sessions.contains_key(&session_id) { - return None; - } - if !apply_explicit_subagent_alias( - &mut event, - sessions, - alignment_state, - explicit_subagent_alias, - ) { - return None; - } - Some((event, session_id, is_agent_started)) -} - -fn apply_explicit_subagent_alias( - event: &mut NormalizedEvent, - sessions: &mut HashMap, - alignment_state: &mut SessionAlignmentState, - explicit_subagent_alias: Option<(String, SessionAlias)>, -) -> bool { - let Some((child_session_id, alias)) = explicit_subagent_alias else { - alignment_state.align_explicit_subagent_end(event); - return true; - }; - - if sessions - .get(&child_session_id) - .is_some_and(|session| !session.can_reparent_as_subagent_alias()) - { - return false; - } - sessions.remove(&child_session_id); - alignment_state.insert_alias(child_session_id, alias); - alignment_state.align_explicit_subagent_end(event); - true -} - impl Session { // Constructs per-session runtime state without creating a scope yet. The root agent scope is // opened lazily on the first event or gateway LLM call so sessions created from hints and pure diff --git a/crates/cli/src/sessions/routing.rs b/crates/cli/src/sessions/routing.rs new file mode 100644 index 000000000..44de606e0 --- /dev/null +++ b/crates/cli/src/sessions/routing.rs @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Child-session aliasing and lifecycle-event routing. + +use std::collections::HashMap; + +use serde_json::Value; + +use crate::agents::shared::alignment::{ + self, PendingSubagentStart, SessionAlias, SessionAlignmentState, merge_metadata, +}; +use crate::configuration::SessionConfig; +use crate::error::CliError; +use crate::events::{AgentKind, NormalizedEvent, SessionEvent}; + +use super::{LlmGatewayStart, Session}; + +pub(super) fn apply_start_alias(start: &mut LlmGatewayStart, alias: &SessionAlias) { + start.session_id = Some(alias.parent_session_id.clone()); + start.subagent_id = Some(alias.subagent_id.clone()); + start.metadata = merge_metadata(start.metadata.clone(), alias.metadata()); +} + +pub(super) async fn queue_or_promote_child_start( + event: &mut NormalizedEvent, + sessions: &mut HashMap, + alignment_state: &mut SessionAlignmentState, + config: SessionConfig, +) -> Result { + let Some((child_session_id, pending)) = alignment::pending_subagent_start(event).await else { + return Ok(false); + }; + if sessions + .get(&child_session_id) + .is_some_and(|session| !session.can_reparent_as_subagent_alias()) + { + return Ok(false); + } + if sessions.contains_key(pending.parent_session_id()) { + alignment_state.remove_pending(&child_session_id); + promote_pending_subagent(sessions, alignment_state, child_session_id, pending, config) + .await?; + } else { + sessions.remove(&child_session_id); + alignment_state.insert_pending(child_session_id, pending); + } + Ok(true) +} + +pub(super) async fn apply_event_to_session( + sessions: &mut HashMap, + session_id: &str, + event: NormalizedEvent, + event_kind: AgentKind, + config: SessionConfig, + is_agent_started: bool, +) -> Result { + let session = sessions + .entry(session_id.to_string()) + .or_insert_with(|| Session::new(session_id.to_string(), event_kind, config)); + if is_agent_started + && session.agent_kind == AgentKind::Gateway + && event_kind != AgentKind::Gateway + { + session.agent_kind = event_kind; + } + session.apply(event).await?; + Ok(session.is_empty()) +} + +pub(super) async fn promote_pending_subagents_for_parent( + sessions: &mut HashMap, + alignment_state: &mut SessionAlignmentState, + parent_session_id: &str, + config: SessionConfig, +) -> Result<(), CliError> { + for (child_session_id, pending) in alignment_state.pending_for_parent(parent_session_id) { + promote_pending_subagent( + sessions, + alignment_state, + child_session_id, + pending, + config.clone(), + ) + .await?; + } + Ok(()) +} + +pub(super) async fn promote_pending_subagent( + sessions: &mut HashMap, + alignment_state: &mut SessionAlignmentState, + child_session_id: String, + pending: PendingSubagentStart, + config: SessionConfig, +) -> Result, CliError> { + if sessions + .get(&child_session_id) + .is_some_and(|session| !session.can_reparent_as_subagent_alias()) + { + return Ok(None); + } + sessions.remove(&child_session_id); + let parent_session_id = pending.parent_session_id().to_string(); + let parent_session = sessions + .entry(parent_session_id.clone()) + .or_insert_with(|| { + Session::new(parent_session_id.clone(), pending.event.agent_kind, config) + }); + if !parent_session.session_started && parent_session.agent_scope.is_none() { + parent_session + .apply(NormalizedEvent::AgentStarted(SessionEvent { + session_id: parent_session_id, + agent_kind: pending.event.agent_kind, + event_name: "implicit_parent_for_aligned_subagent".into(), + payload: Value::Null, + metadata: Value::Null, + })) + .await?; + } + parent_session + .apply(NormalizedEvent::SubagentStarted( + pending.subagent_start_event(), + )) + .await?; + let alias = pending.alias_for_child_session(child_session_id.clone()); + alignment_state.insert_alias(child_session_id, alias.clone()); + Ok(Some(alias)) +} + +pub(super) fn route_event_for_session( + event: NormalizedEvent, + sessions: &mut HashMap, + alignment_state: &mut SessionAlignmentState, +) -> Option<(NormalizedEvent, String, bool)> { + let mut event = alignment_state.route_event(event); + let explicit_subagent_alias = alignment::explicit_subagent_alias(&mut event); + let session_id = event.session_id().to_string(); + let is_agent_started = matches!(&event, NormalizedEvent::AgentStarted(_)); + + if event.is_terminal() && !sessions.contains_key(&session_id) { + return None; + } + if !apply_explicit_subagent_alias( + &mut event, + sessions, + alignment_state, + explicit_subagent_alias, + ) { + return None; + } + Some((event, session_id, is_agent_started)) +} + +fn apply_explicit_subagent_alias( + event: &mut NormalizedEvent, + sessions: &mut HashMap, + alignment_state: &mut SessionAlignmentState, + explicit_subagent_alias: Option<(String, SessionAlias)>, +) -> bool { + let Some((child_session_id, alias)) = explicit_subagent_alias else { + alignment_state.align_explicit_subagent_end(event); + return true; + }; + if sessions + .get(&child_session_id) + .is_some_and(|session| !session.can_reparent_as_subagent_alias()) + { + return false; + } + sessions.remove(&child_session_id); + alignment_state.insert_alias(child_session_id, alias); + alignment_state.align_explicit_subagent_end(event); + true +} From 20d45486a6e3683a70fec228bf84c1f06c7f113c Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:23:57 -0400 Subject: [PATCH 065/102] refactor(cli): isolate diagnostic probes Signed-off-by: Will Killian --- crates/cli/src/diagnostics/mod.rs | 68 +------------------------- crates/cli/src/diagnostics/probes.rs | 73 ++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 66 deletions(-) create mode 100644 crates/cli/src/diagnostics/probes.rs diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index 308ee9b6d..8d50ae2df 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -11,10 +11,12 @@ mod environment; mod model; +mod probes; mod render; use environment::collect_environment; pub(crate) use model::*; +use probes::*; use render::*; use std::path::{Path, PathBuf}; @@ -1020,72 +1022,6 @@ async fn probe_atof_websocket( } } -fn check_directory(name: &'static str, path: &Path) -> Check { - match check_dir_writable(path) { - Ok(()) => Check { - name, - status: Status::Pass, - details: format!("{} (appears writable)", path.display()), - }, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Check { - name, - status: Status::Warn, - details: format!("{}: not present; runtime will create it", path.display()), - }, - Err(err) => Check { - name, - status: Status::Fail, - details: format!("{}: {err}", path.display()), - }, - } -} - -fn check_dir_writable(dir: &Path) -> Result<(), std::io::Error> { - let metadata = std::fs::metadata(dir)?; - if !metadata.is_dir() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "path is not a directory", - )); - } - if metadata.permissions().readonly() { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "directory is read-only", - )); - } - Ok(()) -} - -async fn probe_http_named(name: &'static str, url: &str) -> Check { - let client = match reqwest::Client::builder().timeout(NETWORK_TIMEOUT).build() { - Ok(c) => c, - Err(err) => { - return Check { - name, - status: Status::Fail, - details: format!("could not build HTTP client: {err}"), - }; - } - }; - match client.get(url).send().await { - Ok(resp) => Check { - name, - status: if resp.status().is_success() || resp.status().is_redirection() { - Status::Pass - } else { - Status::Warn - }, - details: format!("{} (HTTP {})", url, resp.status().as_u16()), - }, - Err(err) => Check { - name, - status: Status::Fail, - details: format!("{url}: {err}"), - }, - } -} - fn collect_completions(home: Option<&std::path::Path>) -> Vec { let mut checks = Vec::new(); let shell = std::env::var("SHELL").ok().and_then(|s| { diff --git a/crates/cli/src/diagnostics/probes.rs b/crates/cli/src/diagnostics/probes.rs new file mode 100644 index 000000000..ed5bcccc7 --- /dev/null +++ b/crates/cli/src/diagnostics/probes.rs @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Bounded filesystem and network diagnostic probes. + +use super::{Check, NETWORK_TIMEOUT, Status}; +use std::path::Path; + +pub(super) fn check_directory(name: &'static str, path: &Path) -> Check { + match check_dir_writable(path) { + Ok(()) => Check { + name, + status: Status::Pass, + details: format!("{} (appears writable)", path.display()), + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Check { + name, + status: Status::Warn, + details: format!("{}: not present; runtime will create it", path.display()), + }, + Err(error) => Check { + name, + status: Status::Fail, + details: format!("{}: {error}", path.display()), + }, + } +} + +pub(super) fn check_dir_writable(directory: &Path) -> Result<(), std::io::Error> { + let metadata = std::fs::metadata(directory)?; + if !metadata.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path is not a directory", + )); + } + if metadata.permissions().readonly() { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "directory is read-only", + )); + } + Ok(()) +} + +pub(super) async fn probe_http_named(name: &'static str, url: &str) -> Check { + let client = match reqwest::Client::builder().timeout(NETWORK_TIMEOUT).build() { + Ok(client) => client, + Err(error) => { + return Check { + name, + status: Status::Fail, + details: format!("could not build HTTP client: {error}"), + }; + } + }; + match client.get(url).send().await { + Ok(response) => Check { + name, + status: if response.status().is_success() || response.status().is_redirection() { + Status::Pass + } else { + Status::Warn + }, + details: format!("{} (HTTP {})", url, response.status().as_u16()), + }, + Err(error) => Check { + name, + status: Status::Fail, + details: format!("{url}: {error}"), + }, + } +} From 8adb1de2505b0c3ced325fb54253e825514833b0 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:26:12 -0400 Subject: [PATCH 066/102] refactor(cli): move file snapshots into filesystem Signed-off-by: Will Killian --- crates/cli/src/agents/claude/host.rs | 6 +- crates/cli/src/agents/codex/host.rs | 8 +- crates/cli/src/agents/shared/host.rs | 97 +---------------- crates/cli/src/filesystem/mod.rs | 4 + crates/cli/src/filesystem/snapshots.rs | 102 ++++++++++++++++++ .../coverage/agents/plugin_host_tests.rs | 1 + 6 files changed, 116 insertions(+), 102 deletions(-) create mode 100644 crates/cli/src/filesystem/snapshots.rs diff --git a/crates/cli/src/agents/claude/host.rs b/crates/cli/src/agents/claude/host.rs index 3fba4bcb1..47792a1c9 100644 --- a/crates/cli/src/agents/claude/host.rs +++ b/crates/cli/src/agents/claude/host.rs @@ -8,9 +8,9 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; -use crate::agents::shared::host::{ - FileSnapshot, backup, backup_path, home_dir, read_json_object, remove_backup, - restore_file_snapshot, snapshot_optional_file, write_json, +use crate::agents::shared::host::{home_dir, read_json_object, write_json}; +use crate::filesystem::{ + FileSnapshot, backup, backup_path, remove_backup, restore_file_snapshot, snapshot_optional_file, }; pub(crate) struct ClaudeSetupSnapshot { diff --git a/crates/cli/src/agents/codex/host.rs b/crates/cli/src/agents/codex/host.rs index 540c1e5cd..ba65ece8b 100644 --- a/crates/cli/src/agents/codex/host.rs +++ b/crates/cli/src/agents/codex/host.rs @@ -20,11 +20,13 @@ use crate::hooks::merge_hooks; use super::app_server::{CodexAppServerClient, CodexHookMetadata, CodexHooksClient}; use crate::agents::shared::host::{ - FileSnapshot, atomic_write, atomic_write_private, backup, backup_path, current_exe, - ensure_table, home_dir, portable_executable_path, read_json_object, remove_backup, - restore_file_snapshot, shell_quote, shell_quote_arg_for_platform, snapshot_optional_file, + atomic_write, atomic_write_private, current_exe, ensure_table, home_dir, + portable_executable_path, read_json_object, shell_quote, shell_quote_arg_for_platform, write_json, }; +use crate::filesystem::{ + FileSnapshot, backup, backup_path, remove_backup, restore_file_snapshot, snapshot_optional_file, +}; pub(crate) const CODEX_PLUGIN_ID: &str = RELAY_PLUGIN_ID; pub(crate) const CODEX_PLUGIN_HOOK_KEY_PREFIX: &str = diff --git a/crates/cli/src/agents/shared/host.rs b/crates/cli/src/agents/shared/host.rs index fe474ed6a..dd9e8f586 100644 --- a/crates/cli/src/agents/shared/host.rs +++ b/crates/cli/src/agents/shared/host.rs @@ -11,11 +11,7 @@ use serde_json::{Value, json}; use toml_edit::{DocumentMut, Item, Table}; pub(crate) use crate::bootstrap::current_exe; -pub(crate) use crate::filesystem::{ - atomic_write, atomic_write_private, atomic_write_with_permissions, -}; -#[cfg(windows)] -use crate::filesystem::{atomic_write_with_windows_dacl, read_windows_dacl}; +pub(crate) use crate::filesystem::{atomic_write, atomic_write_private}; pub(crate) use crate::gateway::client::healthz; pub(crate) fn shell_quote(path: &Path) -> String { @@ -101,46 +97,6 @@ pub(crate) fn write_json(path: &Path, value: &Value) -> Result<(), String> { atomic_write(path, &bytes) } -pub(crate) fn backup(path: &Path) -> Result<(), String> { - let backup = backup_path(path); - if backup.exists() { - return Ok(()); - } - if path.exists() { - fs::copy(path, &backup).map_err(|error| { - format!( - "failed to back up {} to {}: {error}", - path.display(), - backup.display() - ) - })?; - } - Ok(()) -} - -pub(crate) fn remove_backup(path: &Path) -> Result<(), String> { - let backup = backup_path(path); - match fs::remove_file(&backup) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(format!("failed to remove {}: {error}", backup.display())), - } -} - -pub(crate) fn backup_path(path: &Path) -> PathBuf { - let mut extension = path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or_default() - .to_string(); - if extension.is_empty() { - extension = "nemo-relay.bak".into(); - } else { - extension.push_str(".nemo-relay.bak"); - } - path.with_extension(extension) -} - pub(crate) fn home_dir() -> Result { env::var_os("HOME") .or_else(|| env::var_os("USERPROFILE")) @@ -157,57 +113,6 @@ pub(crate) fn print_info(label: &str, message: &str) { println!("info {label}: {message}"); } -pub(crate) struct FileSnapshot { - path: PathBuf, - bytes: Option>, - permissions: Option, - #[cfg(windows)] - dacl: Option>, -} - -pub(crate) fn snapshot_optional_file(path: &Path) -> Result { - match fs::read(path) { - Ok(bytes) => Ok(FileSnapshot { - path: path.to_path_buf(), - bytes: Some(bytes), - permissions: fs::metadata(path).ok().map(|value| value.permissions()), - #[cfg(windows)] - dacl: Some(read_windows_dacl(path).map_err(|error| { - format!( - "failed to read access control for {}: {error}", - path.display() - ) - })?), - }), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(FileSnapshot { - path: path.to_path_buf(), - bytes: None, - permissions: None, - #[cfg(windows)] - dacl: None, - }), - Err(error) => Err(format!("failed to read {}: {error}", path.display())), - } -} - -pub(crate) fn restore_file_snapshot(snapshot: &FileSnapshot) -> Result<(), String> { - if let Some(bytes) = snapshot.bytes.as_deref() { - #[cfg(windows)] - if let Some(dacl) = snapshot.dacl.as_deref() { - return atomic_write_with_windows_dacl(&snapshot.path, bytes, dacl); - } - return atomic_write_with_permissions(&snapshot.path, bytes, snapshot.permissions.as_ref()); - } - match fs::remove_file(&snapshot.path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(format!( - "failed to remove {}: {error}", - snapshot.path.display() - )), - } -} - #[cfg(windows)] pub(crate) fn portable_executable_path(path: PathBuf) -> PathBuf { use std::ffi::OsString; diff --git a/crates/cli/src/filesystem/mod.rs b/crates/cli/src/filesystem/mod.rs index 7fd6da7b0..4ee34fbdc 100644 --- a/crates/cli/src/filesystem/mod.rs +++ b/crates/cli/src/filesystem/mod.rs @@ -6,6 +6,7 @@ mod atomic; pub(crate) mod bounded; mod locks; +mod snapshots; pub(crate) mod temp; #[cfg(test)] @@ -17,6 +18,9 @@ pub(crate) use atomic::{ read_windows_dacl, windows_path_is_private, }; pub(crate) use locks::{LockAttempt, try_lock_exclusive, try_lock_shared, unlock_file}; +pub(crate) use snapshots::{ + FileSnapshot, backup, backup_path, remove_backup, restore_file_snapshot, snapshot_optional_file, +}; #[cfg(test)] #[path = "../../tests/coverage/shared/file_io_tests.rs"] diff --git a/crates/cli/src/filesystem/snapshots.rs b/crates/cli/src/filesystem/snapshots.rs new file mode 100644 index 000000000..31fee3ece --- /dev/null +++ b/crates/cli/src/filesystem/snapshots.rs @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Optional-file snapshots and stable backup-file management. + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::atomic_write_with_permissions; +#[cfg(windows)] +use super::{atomic_write_with_windows_dacl, read_windows_dacl}; + +pub(crate) fn backup(path: &Path) -> Result<(), String> { + let backup = backup_path(path); + if backup.exists() { + return Ok(()); + } + if path.exists() { + fs::copy(path, &backup).map_err(|error| { + format!( + "failed to back up {} to {}: {error}", + path.display(), + backup.display() + ) + })?; + } + Ok(()) +} + +pub(crate) fn remove_backup(path: &Path) -> Result<(), String> { + let backup = backup_path(path); + match fs::remove_file(&backup) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("failed to remove {}: {error}", backup.display())), + } +} + +pub(crate) fn backup_path(path: &Path) -> PathBuf { + let mut extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_string(); + if extension.is_empty() { + extension = "nemo-relay.bak".into(); + } else { + extension.push_str(".nemo-relay.bak"); + } + path.with_extension(extension) +} + +pub(crate) struct FileSnapshot { + path: PathBuf, + bytes: Option>, + permissions: Option, + #[cfg(windows)] + dacl: Option>, +} + +pub(crate) fn snapshot_optional_file(path: &Path) -> Result { + match fs::read(path) { + Ok(bytes) => Ok(FileSnapshot { + path: path.to_path_buf(), + bytes: Some(bytes), + permissions: fs::metadata(path).ok().map(|value| value.permissions()), + #[cfg(windows)] + dacl: Some(read_windows_dacl(path).map_err(|error| { + format!( + "failed to read access control for {}: {error}", + path.display() + ) + })?), + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(FileSnapshot { + path: path.to_path_buf(), + bytes: None, + permissions: None, + #[cfg(windows)] + dacl: None, + }), + Err(error) => Err(format!("failed to read {}: {error}", path.display())), + } +} + +pub(crate) fn restore_file_snapshot(snapshot: &FileSnapshot) -> Result<(), String> { + if let Some(bytes) = snapshot.bytes.as_deref() { + #[cfg(windows)] + if let Some(dacl) = snapshot.dacl.as_deref() { + return atomic_write_with_windows_dacl(&snapshot.path, bytes, dacl); + } + return atomic_write_with_permissions(&snapshot.path, bytes, snapshot.permissions.as_ref()); + } + match fs::remove_file(&snapshot.path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "failed to remove {}: {error}", + snapshot.path.display() + )), + } +} diff --git a/crates/cli/tests/coverage/agents/plugin_host_tests.rs b/crates/cli/tests/coverage/agents/plugin_host_tests.rs index b5560b12f..8c67f8404 100644 --- a/crates/cli/tests/coverage/agents/plugin_host_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_host_tests.rs @@ -18,6 +18,7 @@ use toml_edit::{DocumentMut, Item, Value as TomlValue}; use super::*; use crate::configuration::{BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey}; +use crate::filesystem::{backup, backup_path, restore_file_snapshot, snapshot_optional_file}; const TEST_PLUGIN_GENERATION: &str = "test-generation"; From 534ff31bbbf2fe7d7c90accc76854030af88b890 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:28:51 -0400 Subject: [PATCH 067/102] refactor(cli): make marketplace layout agent-owned Signed-off-by: Will Killian --- crates/cli/src/agents/mod.rs | 26 ++++++ .../cli/src/installation/marketplace/state.rs | 84 +++++++++++-------- 2 files changed, 76 insertions(+), 34 deletions(-) diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index aa30cf503..4565591ba 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -145,6 +145,32 @@ impl CodingAgent { } } +impl crate::installation::marketplace::state::MarketplaceHostIdentity for CodingAgent { + fn install_arg(self) -> &'static str { + self.install_arg() + } + + fn label(self) -> &'static str { + self.label() + } + + fn marketplace_manifest_relative(self) -> &'static [&'static str] { + match self { + Self::Codex => &[".agents", "plugins", "marketplace.json"], + Self::ClaudeCode => &[".claude-plugin", "marketplace.json"], + Self::Hermes => unreachable!("Hermes does not use marketplace layout"), + } + } + + fn plugin_manifest_relative(self) -> &'static [&'static str] { + match self { + Self::Codex => &[".codex-plugin", "plugin.json"], + Self::ClaudeCode => &[".claude-plugin", "plugin.json"], + Self::Hermes => unreachable!("Hermes does not use marketplace layout"), + } + } +} + pub(crate) fn marketplace_manifest( agent: CodingAgent, marketplace: &str, diff --git a/crates/cli/src/installation/marketplace/state.rs b/crates/cli/src/installation/marketplace/state.rs index 720c1bb1e..68c88a43b 100644 --- a/crates/cli/src/installation/marketplace/state.rs +++ b/crates/cli/src/installation/marketplace/state.rs @@ -10,11 +10,18 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; -use crate::agents::CodingAgent; use crate::installation::generation::GENERATION_FILE_NAME; use super::PLUGIN_NAME; +/// Agent-owned identity and layout information required by marketplace transactions. +pub(crate) trait MarketplaceHostIdentity: Copy { + fn install_arg(self) -> &'static str; + fn label(self) -> &'static str; + fn marketplace_manifest_relative(self) -> &'static [&'static str]; + fn plugin_manifest_relative(self) -> &'static [&'static str]; +} + #[derive(Debug, Clone)] pub(crate) struct PluginInstallOptions { pub(crate) install_dir: PathBuf, @@ -44,7 +51,8 @@ impl HostRegistrationProgress { #[derive(Debug, Clone)] pub(super) struct PluginLayout { - pub(super) host: CodingAgent, + pub(super) host_arg: &'static str, + pub(super) host_label: &'static str, pub(super) marketplace_root: PathBuf, pub(super) marketplace_manifest: PathBuf, pub(super) plugin_root: PathBuf, @@ -57,28 +65,19 @@ pub(super) struct PluginLayout { } impl PluginLayout { - pub(super) fn new(host: CodingAgent, install_dir: &Path) -> Self { + pub(super) fn new(host: impl MarketplaceHostIdentity, install_dir: &Path) -> Self { let marketplace_root = install_dir.join(format!("{}-marketplace", host.install_arg())); - let marketplace_manifest = match host { - CodingAgent::Codex => marketplace_root - .join(".agents") - .join("plugins") - .join("marketplace.json"), - CodingAgent::ClaudeCode => marketplace_root - .join(".claude-plugin") - .join("marketplace.json"), - CodingAgent::Hermes => { - unreachable!("all is expanded before layout resolution") - } - }; + let marketplace_manifest = host + .marketplace_manifest_relative() + .iter() + .fold(marketplace_root.clone(), |path, component| { + path.join(component) + }); let plugin_root = marketplace_root.join("plugins").join(PLUGIN_NAME); - let plugin_manifest = match host { - CodingAgent::Codex => plugin_root.join(".codex-plugin").join("plugin.json"), - CodingAgent::ClaudeCode => plugin_root.join(".claude-plugin").join("plugin.json"), - CodingAgent::Hermes => { - unreachable!("all is expanded before layout resolution") - } - }; + let plugin_manifest = host + .plugin_manifest_relative() + .iter() + .fold(plugin_root.clone(), |path, component| path.join(component)); let mcp_config = plugin_root.join(".mcp.json"); let generation_fence = plugin_root.join(GENERATION_FILE_NAME); let generation_lock = install_dir.join(format!( @@ -88,7 +87,8 @@ impl PluginLayout { let hooks_path = plugin_root.join("hooks").join("hooks.json"); let state_path = state_path(host, install_dir); Self { - host, + host_arg: host.install_arg(), + host_label: host.label(), marketplace_root, marketplace_manifest, plugin_root, @@ -106,7 +106,7 @@ impl PluginLayout { { return Err(format!( "refusing persisted {} plugin state outside the selected install layout {}", - self.host.label(), + self.host_label, self.state_path.display() )); } @@ -132,7 +132,7 @@ impl PluginLayout { if !canonical_marketplace.starts_with(&canonical_install) { return Err(format!( "refusing persisted {} marketplace root outside the selected install directory", - self.host.label() + self.host_label )); } } @@ -203,8 +203,8 @@ pub(super) fn write_state( layout: &PluginLayout, options: &PluginInstallOptions, ) -> Result<(), String> { - write_state_for_host( - layout.host, + write_state_for_host_arg( + layout.host_arg, &PluginState { marketplace_root: layout.marketplace_root.clone(), plugin_root: layout.plugin_root.clone(), @@ -221,7 +221,7 @@ pub(super) fn write_state( } pub(super) fn mark_plugin_setup_installed( - host: CodingAgent, + host: impl MarketplaceHostIdentity, layout: &PluginLayout, options: &PluginInstallOptions, ) -> Result<(), String> { @@ -237,12 +237,21 @@ pub(super) fn mark_plugin_setup_installed( } pub(super) fn write_state_for_host( - host: CodingAgent, + host: impl MarketplaceHostIdentity, state: &PluginState, install_dir: &Path, options: &PluginInstallOptions, ) -> Result<(), String> { - let path = state_path(host, install_dir); + write_state_for_host_arg(host.install_arg(), state, install_dir, options) +} + +fn write_state_for_host_arg( + host_arg: &str, + state: &PluginState, + install_dir: &Path, + options: &PluginInstallOptions, +) -> Result<(), String> { + let path = state_path_for_arg(host_arg, install_dir); if options.dry_run { println!("write {}", path.display()); return Ok(()); @@ -250,7 +259,7 @@ pub(super) fn write_state_for_host( write_json( &path, &json!({ - "host": host.install_arg(), + "host": host_arg, "marketplaceRoot": state.marketplace_root, "pluginRoot": state.plugin_root, "hostUnregistered": state.host_plugin_removed && state.host_marketplace_removed, @@ -261,7 +270,10 @@ pub(super) fn write_state_for_host( ) } -pub(super) fn read_state(host: CodingAgent, install_dir: &Path) -> Option { +pub(super) fn read_state( + host: impl MarketplaceHostIdentity, + install_dir: &Path, +) -> Option { let raw = fs::read_to_string(state_path(host, install_dir)).ok()?; let value = serde_json::from_str::(&raw).ok()?; let legacy_host_unregistered = value @@ -286,8 +298,12 @@ pub(super) fn read_state(host: CodingAgent, install_dir: &Path) -> Option PathBuf { - install_dir.join(format!("{}.json", host.install_arg())) +pub(super) fn state_path(host: impl MarketplaceHostIdentity, install_dir: &Path) -> PathBuf { + state_path_for_arg(host.install_arg(), install_dir) +} + +fn state_path_for_arg(host_arg: &str, install_dir: &Path) -> PathBuf { + install_dir.join(format!("{host_arg}.json")) } pub(super) fn write_json(path: &Path, value: &Value) -> Result<(), String> { From 0837e7042d020fdbf66cb7916aca050a03292d82 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:30:00 -0400 Subject: [PATCH 068/102] refactor(cli): make marketplace assets agent-owned Signed-off-by: Will Killian --- crates/cli/src/agents/mod.rs | 27 ++++++++++++++ .../src/installation/marketplace/assets.rs | 35 +++++++------------ .../cli/src/installation/marketplace/state.rs | 9 +++++ 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 4565591ba..8e8b324cb 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -169,6 +169,33 @@ impl crate::installation::marketplace::state::MarketplaceHostIdentity for Coding Self::Hermes => unreachable!("Hermes does not use marketplace layout"), } } + + fn marketplace_manifest(self, marketplace: &str, plugin: &str) -> serde_json::Value { + marketplace_manifest(self, marketplace, plugin) + } + + fn plugin_manifest(self, plugin: &str) -> serde_json::Value { + plugin_manifest(self, plugin) + } + + fn plugin_mcp_config(self, server: serde_json::Value) -> Result { + plugin_mcp_config(self, server) + } + + fn plugin_hooks( + self, + relay: &std::path::Path, + generation_fence: &std::path::Path, + generation_token: &str, + ) -> Result { + let command = crate::hooks::persistent_hook_forward_command( + relay, + self, + generation_fence, + generation_token, + )?; + Ok(crate::hooks::generated_hooks(self, &command)) + } } pub(crate) fn marketplace_manifest( diff --git a/crates/cli/src/installation/marketplace/assets.rs b/crates/cli/src/installation/marketplace/assets.rs index b2ff3e056..8385a6420 100644 --- a/crates/cli/src/installation/marketplace/assets.rs +++ b/crates/cli/src/installation/marketplace/assets.rs @@ -9,17 +9,17 @@ use std::path::Path; use serde_json::Value; -use crate::agents::CodingAgent; -use crate::hooks::generated_hooks; use crate::installation::generation::{ write_new_generation_with_token_at, write_staged_generation_with_token, }; -use super::state::{PluginInstallOptions, PluginLayout, remove_path, write_json}; +use super::state::{ + MarketplaceHostIdentity, PluginInstallOptions, PluginLayout, remove_path, write_json, +}; use super::{MARKETPLACE_NAME, PLUGIN_NAME}; pub(super) fn write_plugin_marketplace( - host: CodingAgent, + host: impl MarketplaceHostIdentity, layout: &PluginLayout, relay: &Path, options: &PluginInstallOptions, @@ -36,7 +36,7 @@ pub(super) fn write_plugin_marketplace( } pub(super) fn write_plugin_marketplace_for_generation( - host: CodingAgent, + host: impl MarketplaceHostIdentity, layout: &PluginLayout, relay: &Path, active_generation_fence: &Path, @@ -80,23 +80,23 @@ pub(super) fn write_plugin_marketplace_for_generation( Ok(()) } -pub(super) fn marketplace_manifest(host: CodingAgent) -> Value { - crate::agents::marketplace_manifest(host, MARKETPLACE_NAME, PLUGIN_NAME) +pub(super) fn marketplace_manifest(host: impl MarketplaceHostIdentity) -> Value { + host.marketplace_manifest(MARKETPLACE_NAME, PLUGIN_NAME) } -pub(super) fn plugin_manifest(host: CodingAgent) -> Value { - crate::agents::plugin_manifest(host, PLUGIN_NAME) +pub(super) fn plugin_manifest(host: impl MarketplaceHostIdentity) -> Value { + host.plugin_manifest(PLUGIN_NAME) } pub(super) fn plugin_mcp_config( - host: CodingAgent, + host: impl MarketplaceHostIdentity, relay: &Path, generation_fence: &Path, generation_token: &str, ) -> Result { let generation_fence = absolute_or_self(generation_fence); let server = crate::mcp::persistent_server(relay, &generation_fence, generation_token); - crate::agents::plugin_mcp_config(host, server) + host.plugin_mcp_config(server) } fn absolute_or_self(path: &Path) -> std::path::PathBuf { @@ -109,22 +109,13 @@ fn absolute_or_self(path: &Path) -> std::path::PathBuf { } pub(super) fn plugin_hooks( - host: CodingAgent, + host: impl MarketplaceHostIdentity, relay: &Path, generation_fence: &Path, generation_token: &str, ) -> Result { - let agent = host; let generation_fence = absolute_or_self(generation_fence); - Ok(generated_hooks( - agent, - &crate::hooks::persistent_hook_forward_command( - relay, - agent, - &generation_fence, - generation_token, - )?, - )) + host.plugin_hooks(relay, &generation_fence, generation_token) } #[cfg(test)] diff --git a/crates/cli/src/installation/marketplace/state.rs b/crates/cli/src/installation/marketplace/state.rs index 68c88a43b..a98bafa18 100644 --- a/crates/cli/src/installation/marketplace/state.rs +++ b/crates/cli/src/installation/marketplace/state.rs @@ -20,6 +20,15 @@ pub(crate) trait MarketplaceHostIdentity: Copy { fn label(self) -> &'static str; fn marketplace_manifest_relative(self) -> &'static [&'static str]; fn plugin_manifest_relative(self) -> &'static [&'static str]; + fn marketplace_manifest(self, marketplace: &str, plugin: &str) -> Value; + fn plugin_manifest(self, plugin: &str) -> Value; + fn plugin_mcp_config(self, server: Value) -> Result; + fn plugin_hooks( + self, + relay: &Path, + generation_fence: &Path, + generation_token: &str, + ) -> Result; } #[derive(Debug, Clone)] From 9d09f1402ee6d20f3115ca8477e90329ce642f61 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:31:17 -0400 Subject: [PATCH 069/102] refactor(cli): use agent layout for manifest paths Signed-off-by: Will Killian --- .../cli/src/installation/marketplace/mod.rs | 34 +++++++------------ 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/crates/cli/src/installation/marketplace/mod.rs b/crates/cli/src/installation/marketplace/mod.rs index 20ced3e54..9e42a7592 100644 --- a/crates/cli/src/installation/marketplace/mod.rs +++ b/crates/cli/src/installation/marketplace/mod.rs @@ -41,9 +41,10 @@ use setup::{ #[cfg(test)] use setup::{run_plugin_doctor, run_plugin_setup}; use state::{ - CanonicalizeOrSelf, HostRegistrationProgress, HostSelectionMode, PluginInstallOptions, - PluginLayout, PluginState, default_install_dir, mark_plugin_setup_installed, read_state, - remove_path, state_path, write_state, write_state_for_host, + CanonicalizeOrSelf, HostRegistrationProgress, HostSelectionMode, MarketplaceHostIdentity, + PluginInstallOptions, PluginLayout, PluginState, default_install_dir, + mark_plugin_setup_installed, read_state, remove_path, state_path, write_state, + write_state_for_host, }; pub(super) use crate::bootstrap::DEFAULT_URL as DEFAULT_GATEWAY_URL; @@ -1458,27 +1459,16 @@ fn mcp_env_var_names(server: &Value) -> Option> { .collect() } -fn marketplace_manifest_path(host: CodingAgent, root: &Path) -> PathBuf { - match host { - CodingAgent::Codex => root - .join(".agents") - .join("plugins") - .join("marketplace.json"), - CodingAgent::ClaudeCode => root.join(".claude-plugin").join("marketplace.json"), - CodingAgent::Hermes => { - unreachable!("all is expanded before layout resolution") - } - } +fn marketplace_manifest_path(host: impl MarketplaceHostIdentity, root: &Path) -> PathBuf { + host.marketplace_manifest_relative() + .iter() + .fold(root.to_path_buf(), |path, component| path.join(component)) } -fn plugin_manifest_path(host: CodingAgent, root: &Path) -> PathBuf { - match host { - CodingAgent::Codex => root.join(".codex-plugin").join("plugin.json"), - CodingAgent::ClaudeCode => root.join(".claude-plugin").join("plugin.json"), - CodingAgent::Hermes => { - unreachable!("all is expanded before layout resolution") - } - } +fn plugin_manifest_path(host: impl MarketplaceHostIdentity, root: &Path) -> PathBuf { + host.plugin_manifest_relative() + .iter() + .fold(root.to_path_buf(), |path, component| path.join(component)) } fn plugin_mcp_config_path(root: &Path) -> PathBuf { From 3b901e74492f6201b76e03a70aebd95d897dbd5b Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:32:09 -0400 Subject: [PATCH 070/102] refactor(cli): delegate marketplace host identity Signed-off-by: Will Killian --- crates/cli/src/agents/mod.rs | 8 ++++++++ crates/cli/src/installation/marketplace/host.rs | 15 +++++++-------- crates/cli/src/installation/marketplace/state.rs | 2 ++ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 8e8b324cb..8c40f6546 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -154,6 +154,14 @@ impl crate::installation::marketplace::state::MarketplaceHostIdentity for Coding self.label() } + fn executable(self) -> &'static str { + self.executable() + } + + fn validate_version_output(self, output: &str) -> Result<(), String> { + self.validate_version_output(output).map(|_| ()) + } + fn marketplace_manifest_relative(self) -> &'static [&'static str] { match self { Self::Codex => &[".agents", "plugins", "marketplace.json"], diff --git a/crates/cli/src/installation/marketplace/host.rs b/crates/cli/src/installation/marketplace/host.rs index 3013007d5..f965e2af7 100644 --- a/crates/cli/src/installation/marketplace/host.rs +++ b/crates/cli/src/installation/marketplace/host.rs @@ -13,11 +13,11 @@ use serde_json::json; use crate::agents::CodingAgent; -use super::state::PluginInstallOptions; +use super::state::{MarketplaceHostIdentity, PluginInstallOptions}; use super::{MARKETPLACE_NAME, PLUGIN_NAME, RELAY_COMMAND}; pub(super) fn run_host_marketplace_registration( - host: CodingAgent, + host: impl MarketplaceHostIdentity, marketplace_root: &Path, options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -99,7 +99,7 @@ pub(super) fn run_host_plugin_removal( } pub(super) fn run_host_marketplace_removal( - host: CodingAgent, + host: impl MarketplaceHostIdentity, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { @@ -342,7 +342,7 @@ pub(crate) fn validate_relay_mcp( } pub(crate) fn require_host_cli( - host: CodingAgent, + host: impl MarketplaceHostIdentity, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { @@ -357,16 +357,15 @@ pub(crate) fn require_host_cli( } pub(crate) fn validate_host_version( - host: CodingAgent, + host: impl MarketplaceHostIdentity, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { if options.dry_run { return Ok(()); } - let agent = host; - let output = run_capture_command(agent.executable(), &["--version".into()], options, runner)?; - agent.validate_version_output(&output.stdout).map(|_| ()) + let output = run_capture_command(host.executable(), &["--version".into()], options, runner)?; + host.validate_version_output(&output.stdout) } pub(super) fn run_command( diff --git a/crates/cli/src/installation/marketplace/state.rs b/crates/cli/src/installation/marketplace/state.rs index a98bafa18..c6de849ec 100644 --- a/crates/cli/src/installation/marketplace/state.rs +++ b/crates/cli/src/installation/marketplace/state.rs @@ -18,6 +18,8 @@ use super::PLUGIN_NAME; pub(crate) trait MarketplaceHostIdentity: Copy { fn install_arg(self) -> &'static str; fn label(self) -> &'static str; + fn executable(self) -> &'static str; + fn validate_version_output(self, output: &str) -> Result<(), String>; fn marketplace_manifest_relative(self) -> &'static [&'static str]; fn plugin_manifest_relative(self) -> &'static [&'static str]; fn marketplace_manifest(self, marketplace: &str, plugin: &str) -> Value; From 1375ba0a0d7c4536a947f6f11b59e4e82d04107d Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:33:18 -0400 Subject: [PATCH 071/102] refactor(cli): delegate marketplace registration commands Signed-off-by: Will Killian --- crates/cli/src/agents/mod.rs | 24 +++++- .../src/installation/marketplace/assets.rs | 16 ++-- .../cli/src/installation/marketplace/host.rs | 74 +++++-------------- .../cli/src/installation/marketplace/mod.rs | 6 +- .../cli/src/installation/marketplace/state.rs | 17 ++--- 5 files changed, 60 insertions(+), 77 deletions(-) diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 8c40f6546..c9ebf7710 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -145,7 +145,7 @@ impl CodingAgent { } } -impl crate::installation::marketplace::state::MarketplaceHostIdentity for CodingAgent { +impl crate::installation::marketplace::state::MarketplaceHost for CodingAgent { fn install_arg(self) -> &'static str { self.install_arg() } @@ -204,6 +204,28 @@ impl crate::installation::marketplace::state::MarketplaceHostIdentity for Coding )?; Ok(crate::hooks::generated_hooks(self, &command)) } + + fn plugin_registration_args(self, plugin_id: &str) -> Vec { + match self { + Self::Codex => vec!["plugin".into(), "add".into(), plugin_id.into()], + Self::ClaudeCode => vec![ + "plugin".into(), + "install".into(), + plugin_id.into(), + "--scope".into(), + "user".into(), + ], + Self::Hermes => unreachable!("Hermes does not register marketplace plugins"), + } + } + + fn plugin_removal_args(self, plugin_name: &str, plugin_id: &str) -> Vec { + match self { + Self::Codex => vec!["plugin".into(), "remove".into(), plugin_id.into()], + Self::ClaudeCode => vec!["plugin".into(), "uninstall".into(), plugin_name.into()], + Self::Hermes => unreachable!("Hermes does not register marketplace plugins"), + } + } } pub(crate) fn marketplace_manifest( diff --git a/crates/cli/src/installation/marketplace/assets.rs b/crates/cli/src/installation/marketplace/assets.rs index 8385a6420..6092b0e6b 100644 --- a/crates/cli/src/installation/marketplace/assets.rs +++ b/crates/cli/src/installation/marketplace/assets.rs @@ -13,13 +13,11 @@ use crate::installation::generation::{ write_new_generation_with_token_at, write_staged_generation_with_token, }; -use super::state::{ - MarketplaceHostIdentity, PluginInstallOptions, PluginLayout, remove_path, write_json, -}; +use super::state::{MarketplaceHost, PluginInstallOptions, PluginLayout, remove_path, write_json}; use super::{MARKETPLACE_NAME, PLUGIN_NAME}; pub(super) fn write_plugin_marketplace( - host: impl MarketplaceHostIdentity, + host: impl MarketplaceHost, layout: &PluginLayout, relay: &Path, options: &PluginInstallOptions, @@ -36,7 +34,7 @@ pub(super) fn write_plugin_marketplace( } pub(super) fn write_plugin_marketplace_for_generation( - host: impl MarketplaceHostIdentity, + host: impl MarketplaceHost, layout: &PluginLayout, relay: &Path, active_generation_fence: &Path, @@ -80,16 +78,16 @@ pub(super) fn write_plugin_marketplace_for_generation( Ok(()) } -pub(super) fn marketplace_manifest(host: impl MarketplaceHostIdentity) -> Value { +pub(super) fn marketplace_manifest(host: impl MarketplaceHost) -> Value { host.marketplace_manifest(MARKETPLACE_NAME, PLUGIN_NAME) } -pub(super) fn plugin_manifest(host: impl MarketplaceHostIdentity) -> Value { +pub(super) fn plugin_manifest(host: impl MarketplaceHost) -> Value { host.plugin_manifest(PLUGIN_NAME) } pub(super) fn plugin_mcp_config( - host: impl MarketplaceHostIdentity, + host: impl MarketplaceHost, relay: &Path, generation_fence: &Path, generation_token: &str, @@ -109,7 +107,7 @@ fn absolute_or_self(path: &Path) -> std::path::PathBuf { } pub(super) fn plugin_hooks( - host: impl MarketplaceHostIdentity, + host: impl MarketplaceHost, relay: &Path, generation_fence: &Path, generation_token: &str, diff --git a/crates/cli/src/installation/marketplace/host.rs b/crates/cli/src/installation/marketplace/host.rs index f965e2af7..e0b0dbc8e 100644 --- a/crates/cli/src/installation/marketplace/host.rs +++ b/crates/cli/src/installation/marketplace/host.rs @@ -13,11 +13,11 @@ use serde_json::json; use crate::agents::CodingAgent; -use super::state::{MarketplaceHostIdentity, PluginInstallOptions}; +use super::state::{MarketplaceHost, PluginInstallOptions}; use super::{MARKETPLACE_NAME, PLUGIN_NAME, RELAY_COMMAND}; pub(super) fn run_host_marketplace_registration( - host: impl MarketplaceHostIdentity, + host: impl MarketplaceHost, marketplace_root: &Path, options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -36,70 +36,34 @@ pub(super) fn run_host_marketplace_registration( } pub(super) fn run_host_plugin_registration( - host: CodingAgent, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { - match host { - CodingAgent::Codex => run_command( - host.executable(), - &[ - "plugin".into(), - "add".into(), - format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"), - ], - options, - runner, - ), - CodingAgent::ClaudeCode => run_command( - host.executable(), - &[ - "plugin".into(), - "install".into(), - format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"), - "--scope".into(), - "user".into(), - ], - options, - runner, - ), - CodingAgent::Hermes => { - unreachable!("all is expanded before host registration") - } - } + run_command( + host.executable(), + &host.plugin_registration_args(&format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}")), + options, + runner, + ) } pub(super) fn run_host_plugin_removal( - host: CodingAgent, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { - match host { - CodingAgent::Codex => run_command( - host.executable(), - &[ - "plugin".into(), - "remove".into(), - format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"), - ], - options, - runner, - )?, - CodingAgent::ClaudeCode => run_command( - host.executable(), - &["plugin".into(), "uninstall".into(), PLUGIN_NAME.into()], - options, - runner, - )?, - CodingAgent::Hermes => { - unreachable!("all is expanded before host unregistration") - } - } + run_command( + host.executable(), + &host.plugin_removal_args(PLUGIN_NAME, &format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}")), + options, + runner, + )?; Ok(()) } pub(super) fn run_host_marketplace_removal( - host: impl MarketplaceHostIdentity, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { @@ -342,7 +306,7 @@ pub(crate) fn validate_relay_mcp( } pub(crate) fn require_host_cli( - host: impl MarketplaceHostIdentity, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { @@ -357,7 +321,7 @@ pub(crate) fn require_host_cli( } pub(crate) fn validate_host_version( - host: impl MarketplaceHostIdentity, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { diff --git a/crates/cli/src/installation/marketplace/mod.rs b/crates/cli/src/installation/marketplace/mod.rs index 9e42a7592..a78a89612 100644 --- a/crates/cli/src/installation/marketplace/mod.rs +++ b/crates/cli/src/installation/marketplace/mod.rs @@ -41,7 +41,7 @@ use setup::{ #[cfg(test)] use setup::{run_plugin_doctor, run_plugin_setup}; use state::{ - CanonicalizeOrSelf, HostRegistrationProgress, HostSelectionMode, MarketplaceHostIdentity, + CanonicalizeOrSelf, HostRegistrationProgress, HostSelectionMode, MarketplaceHost, PluginInstallOptions, PluginLayout, PluginState, default_install_dir, mark_plugin_setup_installed, read_state, remove_path, state_path, write_state, write_state_for_host, @@ -1459,13 +1459,13 @@ fn mcp_env_var_names(server: &Value) -> Option> { .collect() } -fn marketplace_manifest_path(host: impl MarketplaceHostIdentity, root: &Path) -> PathBuf { +fn marketplace_manifest_path(host: impl MarketplaceHost, root: &Path) -> PathBuf { host.marketplace_manifest_relative() .iter() .fold(root.to_path_buf(), |path, component| path.join(component)) } -fn plugin_manifest_path(host: impl MarketplaceHostIdentity, root: &Path) -> PathBuf { +fn plugin_manifest_path(host: impl MarketplaceHost, root: &Path) -> PathBuf { host.plugin_manifest_relative() .iter() .fold(root.to_path_buf(), |path, component| path.join(component)) diff --git a/crates/cli/src/installation/marketplace/state.rs b/crates/cli/src/installation/marketplace/state.rs index c6de849ec..fc16b5dc0 100644 --- a/crates/cli/src/installation/marketplace/state.rs +++ b/crates/cli/src/installation/marketplace/state.rs @@ -15,7 +15,7 @@ use crate::installation::generation::GENERATION_FILE_NAME; use super::PLUGIN_NAME; /// Agent-owned identity and layout information required by marketplace transactions. -pub(crate) trait MarketplaceHostIdentity: Copy { +pub(crate) trait MarketplaceHost: Copy { fn install_arg(self) -> &'static str; fn label(self) -> &'static str; fn executable(self) -> &'static str; @@ -31,6 +31,8 @@ pub(crate) trait MarketplaceHostIdentity: Copy { generation_fence: &Path, generation_token: &str, ) -> Result; + fn plugin_registration_args(self, plugin_id: &str) -> Vec; + fn plugin_removal_args(self, plugin_name: &str, plugin_id: &str) -> Vec; } #[derive(Debug, Clone)] @@ -76,7 +78,7 @@ pub(super) struct PluginLayout { } impl PluginLayout { - pub(super) fn new(host: impl MarketplaceHostIdentity, install_dir: &Path) -> Self { + pub(super) fn new(host: impl MarketplaceHost, install_dir: &Path) -> Self { let marketplace_root = install_dir.join(format!("{}-marketplace", host.install_arg())); let marketplace_manifest = host .marketplace_manifest_relative() @@ -232,7 +234,7 @@ pub(super) fn write_state( } pub(super) fn mark_plugin_setup_installed( - host: impl MarketplaceHostIdentity, + host: impl MarketplaceHost, layout: &PluginLayout, options: &PluginInstallOptions, ) -> Result<(), String> { @@ -248,7 +250,7 @@ pub(super) fn mark_plugin_setup_installed( } pub(super) fn write_state_for_host( - host: impl MarketplaceHostIdentity, + host: impl MarketplaceHost, state: &PluginState, install_dir: &Path, options: &PluginInstallOptions, @@ -281,10 +283,7 @@ fn write_state_for_host_arg( ) } -pub(super) fn read_state( - host: impl MarketplaceHostIdentity, - install_dir: &Path, -) -> Option { +pub(super) fn read_state(host: impl MarketplaceHost, install_dir: &Path) -> Option { let raw = fs::read_to_string(state_path(host, install_dir)).ok()?; let value = serde_json::from_str::(&raw).ok()?; let legacy_host_unregistered = value @@ -309,7 +308,7 @@ pub(super) fn read_state( }) } -pub(super) fn state_path(host: impl MarketplaceHostIdentity, install_dir: &Path) -> PathBuf { +pub(super) fn state_path(host: impl MarketplaceHost, install_dir: &Path) -> PathBuf { state_path_for_arg(host.install_arg(), install_dir) } From 922bc87eadf4fb740a4c2e8be865be4be04f4297 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:34:32 -0400 Subject: [PATCH 072/102] refactor(cli): define marketplace host contract Signed-off-by: Will Killian --- crates/cli/src/agents/mod.rs | 2 +- .../src/installation/marketplace/assets.rs | 4 +-- .../cli/src/installation/marketplace/host.rs | 4 +-- .../cli/src/installation/marketplace/mod.rs | 10 ++++--- .../cli/src/installation/marketplace/spec.rs | 28 +++++++++++++++++++ .../cli/src/installation/marketplace/state.rs | 23 +-------------- 6 files changed, 40 insertions(+), 31 deletions(-) create mode 100644 crates/cli/src/installation/marketplace/spec.rs diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index c9ebf7710..42bc498ba 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -145,7 +145,7 @@ impl CodingAgent { } } -impl crate::installation::marketplace::state::MarketplaceHost for CodingAgent { +impl crate::installation::marketplace::MarketplaceHost for CodingAgent { fn install_arg(self) -> &'static str { self.install_arg() } diff --git a/crates/cli/src/installation/marketplace/assets.rs b/crates/cli/src/installation/marketplace/assets.rs index 6092b0e6b..1565ca4f9 100644 --- a/crates/cli/src/installation/marketplace/assets.rs +++ b/crates/cli/src/installation/marketplace/assets.rs @@ -13,8 +13,8 @@ use crate::installation::generation::{ write_new_generation_with_token_at, write_staged_generation_with_token, }; -use super::state::{MarketplaceHost, PluginInstallOptions, PluginLayout, remove_path, write_json}; -use super::{MARKETPLACE_NAME, PLUGIN_NAME}; +use super::state::{PluginInstallOptions, PluginLayout, remove_path, write_json}; +use super::{MARKETPLACE_NAME, MarketplaceHost, PLUGIN_NAME}; pub(super) fn write_plugin_marketplace( host: impl MarketplaceHost, diff --git a/crates/cli/src/installation/marketplace/host.rs b/crates/cli/src/installation/marketplace/host.rs index e0b0dbc8e..09513cc29 100644 --- a/crates/cli/src/installation/marketplace/host.rs +++ b/crates/cli/src/installation/marketplace/host.rs @@ -13,8 +13,8 @@ use serde_json::json; use crate::agents::CodingAgent; -use super::state::{MarketplaceHost, PluginInstallOptions}; -use super::{MARKETPLACE_NAME, PLUGIN_NAME, RELAY_COMMAND}; +use super::state::PluginInstallOptions; +use super::{MARKETPLACE_NAME, MarketplaceHost, PLUGIN_NAME, RELAY_COMMAND}; pub(super) fn run_host_marketplace_registration( host: impl MarketplaceHost, diff --git a/crates/cli/src/installation/marketplace/mod.rs b/crates/cli/src/installation/marketplace/mod.rs index a78a89612..720b0c4dc 100644 --- a/crates/cli/src/installation/marketplace/mod.rs +++ b/crates/cli/src/installation/marketplace/mod.rs @@ -6,8 +6,11 @@ mod assets; pub(crate) mod host; mod setup; +mod spec; pub(crate) mod state; +pub(crate) use spec::MarketplaceHost; + use std::fs; use std::path::{Path, PathBuf}; use std::process::ExitCode; @@ -41,10 +44,9 @@ use setup::{ #[cfg(test)] use setup::{run_plugin_doctor, run_plugin_setup}; use state::{ - CanonicalizeOrSelf, HostRegistrationProgress, HostSelectionMode, MarketplaceHost, - PluginInstallOptions, PluginLayout, PluginState, default_install_dir, - mark_plugin_setup_installed, read_state, remove_path, state_path, write_state, - write_state_for_host, + CanonicalizeOrSelf, HostRegistrationProgress, HostSelectionMode, PluginInstallOptions, + PluginLayout, PluginState, default_install_dir, mark_plugin_setup_installed, read_state, + remove_path, state_path, write_state, write_state_for_host, }; pub(super) use crate::bootstrap::DEFAULT_URL as DEFAULT_GATEWAY_URL; diff --git a/crates/cli/src/installation/marketplace/spec.rs b/crates/cli/src/installation/marketplace/spec.rs new file mode 100644 index 000000000..1169f91a0 --- /dev/null +++ b/crates/cli/src/installation/marketplace/spec.rs @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Agent-owned behavior required by the shared marketplace transaction. + +use std::path::Path; + +use serde_json::Value; + +pub(crate) trait MarketplaceHost: Copy { + fn install_arg(self) -> &'static str; + fn label(self) -> &'static str; + fn executable(self) -> &'static str; + fn validate_version_output(self, output: &str) -> Result<(), String>; + fn marketplace_manifest_relative(self) -> &'static [&'static str]; + fn plugin_manifest_relative(self) -> &'static [&'static str]; + fn marketplace_manifest(self, marketplace: &str, plugin: &str) -> Value; + fn plugin_manifest(self, plugin: &str) -> Value; + fn plugin_mcp_config(self, server: Value) -> Result; + fn plugin_hooks( + self, + relay: &Path, + generation_fence: &Path, + generation_token: &str, + ) -> Result; + fn plugin_registration_args(self, plugin_id: &str) -> Vec; + fn plugin_removal_args(self, plugin_name: &str, plugin_id: &str) -> Vec; +} diff --git a/crates/cli/src/installation/marketplace/state.rs b/crates/cli/src/installation/marketplace/state.rs index fc16b5dc0..1ec9a11ca 100644 --- a/crates/cli/src/installation/marketplace/state.rs +++ b/crates/cli/src/installation/marketplace/state.rs @@ -12,28 +12,7 @@ use serde_json::{Value, json}; use crate::installation::generation::GENERATION_FILE_NAME; -use super::PLUGIN_NAME; - -/// Agent-owned identity and layout information required by marketplace transactions. -pub(crate) trait MarketplaceHost: Copy { - fn install_arg(self) -> &'static str; - fn label(self) -> &'static str; - fn executable(self) -> &'static str; - fn validate_version_output(self, output: &str) -> Result<(), String>; - fn marketplace_manifest_relative(self) -> &'static [&'static str]; - fn plugin_manifest_relative(self) -> &'static [&'static str]; - fn marketplace_manifest(self, marketplace: &str, plugin: &str) -> Value; - fn plugin_manifest(self, plugin: &str) -> Value; - fn plugin_mcp_config(self, server: Value) -> Result; - fn plugin_hooks( - self, - relay: &Path, - generation_fence: &Path, - generation_token: &str, - ) -> Result; - fn plugin_registration_args(self, plugin_id: &str) -> Vec; - fn plugin_removal_args(self, plugin_name: &str, plugin_id: &str) -> Vec; -} +use super::{MarketplaceHost, PLUGIN_NAME}; #[derive(Debug, Clone)] pub(crate) struct PluginInstallOptions { From e0962d93bc1128d5cf9919cfe39bfb6320f2e06a Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:36:14 -0400 Subject: [PATCH 073/102] refactor(cli): delegate marketplace registration probes Signed-off-by: Will Killian --- crates/cli/src/agents/mod.rs | 16 +++++++ .../cli/src/installation/marketplace/host.rs | 44 +++++++++++-------- .../cli/src/installation/marketplace/spec.rs | 8 ++++ 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 42bc498ba..1a7aff447 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -226,6 +226,22 @@ impl crate::installation::marketplace::MarketplaceHost for CodingAgent { Self::Hermes => unreachable!("Hermes does not register marketplace plugins"), } } + + fn registration_report( + self, + options: &crate::installation::marketplace::state::PluginInstallOptions, + runner: &dyn crate::installation::marketplace::host::CommandRunner, + ) -> Result { + match self { + Self::Codex => { + crate::installation::marketplace::host::codex_registration_report(options, runner) + } + Self::ClaudeCode => { + crate::installation::marketplace::host::claude_registration_report(options, runner) + } + Self::Hermes => unreachable!("Hermes does not register marketplace plugins"), + } + } } pub(crate) fn marketplace_manifest( diff --git a/crates/cli/src/installation/marketplace/host.rs b/crates/cli/src/installation/marketplace/host.rs index 09513cc29..26ac6db61 100644 --- a/crates/cli/src/installation/marketplace/host.rs +++ b/crates/cli/src/installation/marketplace/host.rs @@ -11,8 +11,6 @@ use serde_json::Value; #[cfg(test)] use serde_json::json; -use crate::agents::CodingAgent; - use super::state::PluginInstallOptions; use super::{MARKETPLACE_NAME, MarketplaceHost, PLUGIN_NAME, RELAY_COMMAND}; @@ -81,9 +79,9 @@ pub(super) fn run_host_marketplace_removal( } #[derive(Debug, Clone)] -pub(super) struct HostRegistrationReport { - pub(super) host_plugin_registered: bool, - pub(super) host_marketplace_registered: bool, +pub(crate) struct HostRegistrationReport { + pub(crate) host_plugin_registered: bool, + pub(crate) host_marketplace_registered: bool, } impl HostRegistrationReport { @@ -103,7 +101,7 @@ impl HostRegistrationReport { #[cfg(test)] pub(super) fn validate_host_registration( - host: CodingAgent, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result { @@ -127,7 +125,7 @@ pub(super) fn validate_host_registration( } pub(super) fn host_registration_report( - host: CodingAgent, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result { @@ -138,18 +136,26 @@ pub(super) fn host_registration_report( }); } require_host_cli(host, options, runner)?; - Ok(match host { - CodingAgent::ClaudeCode => HostRegistrationReport { - host_plugin_registered: claude_plugin_registered(options, runner)?, - host_marketplace_registered: claude_marketplace_registered(options, runner)?, - }, - CodingAgent::Codex => HostRegistrationReport { - host_plugin_registered: codex_plugin_registered(options, runner)?, - host_marketplace_registered: codex_marketplace_registered(options, runner)?, - }, - CodingAgent::Hermes => { - unreachable!("all is expanded before host registration checks") - } + host.registration_report(options, runner) +} + +pub(crate) fn claude_registration_report( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + Ok(HostRegistrationReport { + host_plugin_registered: claude_plugin_registered(options, runner)?, + host_marketplace_registered: claude_marketplace_registered(options, runner)?, + }) +} + +pub(crate) fn codex_registration_report( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + Ok(HostRegistrationReport { + host_plugin_registered: codex_plugin_registered(options, runner)?, + host_marketplace_registered: codex_marketplace_registered(options, runner)?, }) } diff --git a/crates/cli/src/installation/marketplace/spec.rs b/crates/cli/src/installation/marketplace/spec.rs index 1169f91a0..5f3b8dcb6 100644 --- a/crates/cli/src/installation/marketplace/spec.rs +++ b/crates/cli/src/installation/marketplace/spec.rs @@ -7,6 +7,9 @@ use std::path::Path; use serde_json::Value; +use super::host::{CommandRunner, HostRegistrationReport}; +use super::state::PluginInstallOptions; + pub(crate) trait MarketplaceHost: Copy { fn install_arg(self) -> &'static str; fn label(self) -> &'static str; @@ -25,4 +28,9 @@ pub(crate) trait MarketplaceHost: Copy { ) -> Result; fn plugin_registration_args(self, plugin_id: &str) -> Vec; fn plugin_removal_args(self, plugin_name: &str, plugin_id: &str) -> Vec; + fn registration_report( + self, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + ) -> Result; } From bd57f8d5d82b07e42c5a0c80a0ea28f8ea4696b3 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:37:37 -0400 Subject: [PATCH 074/102] refactor(cli): delegate marketplace compatibility policy Signed-off-by: Will Killian --- crates/cli/src/agents/mod.rs | 42 +++++++++++++++ .../cli/src/installation/marketplace/mod.rs | 53 ++++++++----------- .../cli/src/installation/marketplace/spec.rs | 11 ++++ 3 files changed, 74 insertions(+), 32 deletions(-) diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 1a7aff447..933c8c436 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -242,6 +242,48 @@ impl crate::installation::marketplace::MarketplaceHost for CodingAgent { Self::Hermes => unreachable!("Hermes does not register marketplace plugins"), } } + + fn setup_may_mutate_before_success(self) -> bool { + !matches!(self, Self::Codex) + } + + fn unsafe_generation_fence_error(self, problem: &str) -> String { + match self { + Self::Codex => format!( + "cannot safely replace or uninstall an existing Codex plugin because its MCP generation marker {problem}; close all Codex clients and standalone `nemo-relay mcp` processes, run `codex plugin remove nemo-relay-plugin@nemo-relay-local` and `codex plugin marketplace remove nemo-relay-local`, remove the stale marketplace and state from the selected install directory, then run `nemo-relay install codex --force` to create a fenced install (and `nemo-relay uninstall codex` afterward if removal was intended)" + ), + Self::ClaudeCode => format!( + "cannot safely replace or uninstall an existing Claude Code plugin because its MCP generation marker {problem}; close all Claude Code clients and standalone `nemo-relay mcp` processes, run `claude plugin uninstall nemo-relay-plugin` and `claude plugin marketplace remove nemo-relay-local`, remove the stale marketplace and state from the selected install directory, then run `nemo-relay install claude-code --force` to create a fenced install (and `nemo-relay uninstall claude-code` afterward if removal was intended)" + ), + Self::Hermes => unreachable!("Hermes does not use marketplace generations"), + } + } + + fn accepts_legacy_hook_only_plugin(self) -> bool { + matches!(self, Self::ClaudeCode) + } + + fn accepts_mcp_environment_superset(self) -> bool { + matches!(self, Self::Codex) + } + + fn local_install_exists( + self, + marketplace_root: &std::path::Path, + plugin_root: &std::path::Path, + plugin_manifest: &std::path::Path, + generation_fence: &std::path::Path, + ) -> bool { + match self { + Self::Codex => marketplace_root.exists(), + Self::ClaudeCode => { + plugin_manifest.exists() + || plugin_root.join(".mcp.json").exists() + || generation_fence.exists() + } + Self::Hermes => unreachable!("Hermes does not use marketplace installs"), + } + } } pub(crate) fn marketplace_manifest( diff --git a/crates/cli/src/installation/marketplace/mod.rs b/crates/cli/src/installation/marketplace/mod.rs index 720b0c4dc..3f6a68df2 100644 --- a/crates/cli/src/installation/marketplace/mod.rs +++ b/crates/cli/src/installation/marketplace/mod.rs @@ -681,7 +681,7 @@ fn install_host_locked( return Err(error); } registration.host_plugin_added = true; - if !matches!(host, CodingAgent::Codex) { + if host.setup_may_mutate_before_success() { setup_installed = true; } run_plugin_setup_with_generation( @@ -964,7 +964,7 @@ fn retire_replacement_before_rollback( Ok(Some(retirement)) } -fn existing_plugin_install_requires_force_error(host: CodingAgent) -> String { +fn existing_plugin_install_requires_force_error(host: impl MarketplaceHost) -> String { format!( "an existing fenced {} plugin install was found; rerun `nemo-relay install {} --force` to replace it safely", host.label(), @@ -972,7 +972,7 @@ fn existing_plugin_install_requires_force_error(host: CodingAgent) -> String { ) } -fn missing_generation_fence_error(host: CodingAgent, generation_fence: &Path) -> String { +fn missing_generation_fence_error(host: impl MarketplaceHost, generation_fence: &Path) -> String { unsafe_generation_fence_error( host, &format!("is missing at {}", generation_fence.display()), @@ -980,7 +980,7 @@ fn missing_generation_fence_error(host: CodingAgent, generation_fence: &Path) -> } fn invalid_generation_fence_error( - host: CodingAgent, + host: impl MarketplaceHost, generation_fence: &Path, cause: &str, ) -> String { @@ -993,22 +993,15 @@ fn invalid_generation_fence_error( ) } -fn unsafe_generation_fence_error(host: CodingAgent, problem: &str) -> String { - match host { - CodingAgent::Codex => format!( - "cannot safely replace or uninstall an existing Codex plugin because its MCP generation marker {problem}; close all Codex clients and standalone `nemo-relay mcp` processes, run `codex plugin remove nemo-relay-plugin@nemo-relay-local` and `codex plugin marketplace remove nemo-relay-local`, remove the stale marketplace and state from the selected install directory, then run `nemo-relay install codex --force` to create a fenced install (and `nemo-relay uninstall codex` afterward if removal was intended)" - ), - CodingAgent::ClaudeCode => format!( - "cannot safely replace or uninstall an existing Claude Code plugin because its MCP generation marker {problem}; close all Claude Code clients and standalone `nemo-relay mcp` processes, run `claude plugin uninstall nemo-relay-plugin` and `claude plugin marketplace remove nemo-relay-local`, remove the stale marketplace and state from the selected install directory, then run `nemo-relay install claude-code --force` to create a fenced install (and `nemo-relay uninstall claude-code` afterward if removal was intended)" - ), - CodingAgent::Hermes => { - unreachable!("all is expanded before generation validation") - } - } +fn unsafe_generation_fence_error(host: impl MarketplaceHost, problem: &str) -> String { + host.unsafe_generation_fence_error(problem) } -fn legacy_plugin_without_mcp(host: CodingAgent, plugin_root: &Path) -> Result { - if !matches!(host, CodingAgent::ClaudeCode) || plugin_root.join(".mcp.json").exists() { +fn legacy_plugin_without_mcp( + host: impl MarketplaceHost, + plugin_root: &Path, +) -> Result { + if !host.accepts_legacy_hook_only_plugin() || plugin_root.join(".mcp.json").exists() { return Ok(false); } let manifest_path = plugin_manifest_path(host, plugin_root); @@ -1346,7 +1339,7 @@ fn generated_manifest_check(path: &Path, expected: &Value, label: &str) -> Resul } fn generated_mcp_config_check( - host: CodingAgent, + host: impl MarketplaceHost, path: &Path, expected: &Value, ) -> Result { @@ -1354,7 +1347,7 @@ fn generated_mcp_config_check( } fn generated_mcp_config_check_for_platform( - host: CodingAgent, + host: impl MarketplaceHost, path: &Path, expected: &Value, windows: bool, @@ -1370,7 +1363,7 @@ fn generated_mcp_config_check_for_platform( if actual == *expected { return Ok(format!("valid at {}", path.display())); } - if !matches!(host, CodingAgent::Codex) { + if !host.accepts_mcp_environment_superset() { return Err(format!( "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", path.display(), @@ -1629,17 +1622,13 @@ fn prepare_plugin_install( .map(|state| state.plugin_root.clone()) .unwrap_or_else(|| layout.plugin_root.clone()); let previous_generation_fence = previous_plugin_root.join(GENERATION_FILE_NAME); - let local_install_exists = match host { - CodingAgent::Codex => layout.marketplace_root.exists(), - CodingAgent::ClaudeCode => { - plugin_manifest_path(host, &previous_plugin_root).exists() - || previous_plugin_root.join(".mcp.json").exists() - || previous_generation_fence.exists() - } - CodingAgent::Hermes => { - unreachable!("all is expanded before install preflight") - } - }; + let previous_plugin_manifest = plugin_manifest_path(host, &previous_plugin_root); + let local_install_exists = host.local_install_exists( + &layout.marketplace_root, + &previous_plugin_root, + &previous_plugin_manifest, + &previous_generation_fence, + ); let previous_install_exists = state_bytes.is_some() || local_install_exists || plugin_registered diff --git a/crates/cli/src/installation/marketplace/spec.rs b/crates/cli/src/installation/marketplace/spec.rs index 5f3b8dcb6..30d117c5b 100644 --- a/crates/cli/src/installation/marketplace/spec.rs +++ b/crates/cli/src/installation/marketplace/spec.rs @@ -33,4 +33,15 @@ pub(crate) trait MarketplaceHost: Copy { options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result; + fn setup_may_mutate_before_success(self) -> bool; + fn unsafe_generation_fence_error(self, problem: &str) -> String; + fn accepts_legacy_hook_only_plugin(self) -> bool; + fn accepts_mcp_environment_superset(self) -> bool; + fn local_install_exists( + self, + marketplace_root: &Path, + plugin_root: &Path, + plugin_manifest: &Path, + generation_fence: &Path, + ) -> bool; } From 17f39e6b177f5d7d228d4ce17954fe09aa61ebd9 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:44:18 -0400 Subject: [PATCH 075/102] refactor(cli): bind marketplace setup to agent hosts Signed-off-by: Will Killian --- crates/cli/src/agents/mod.rs | 57 ++++++- .../cli/src/installation/marketplace/mod.rs | 83 ++++++----- .../cli/src/installation/marketplace/setup.rs | 139 +++++++++--------- .../cli/src/installation/marketplace/spec.rs | 37 +++++ .../agents/plugin_install_setup_tests.rs | 29 ++-- .../coverage/agents/plugin_install_tests.rs | 72 ++++----- 6 files changed, 246 insertions(+), 171 deletions(-) diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 933c8c436..28b4084d3 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -162,6 +162,10 @@ impl crate::installation::marketplace::MarketplaceHost for CodingAgent { self.validate_version_output(output).map(|_| ()) } + fn version_requirement(self) -> String { + self.version_requirement() + } + fn marketplace_manifest_relative(self) -> &'static [&'static str] { match self { Self::Codex => &[".agents", "plugins", "marketplace.json"], @@ -284,6 +288,55 @@ impl crate::installation::marketplace::MarketplaceHost for CodingAgent { Self::Hermes => unreachable!("Hermes does not use marketplace installs"), } } + + fn setup_action_description(self, action: &str) -> String { + setup_action_description(self, action) + } + + fn snapshot_setup( + self, + ) -> Result, String> { + let snapshot = snapshot_setup(self)?; + Ok(Some( + crate::installation::marketplace::PluginSetupSnapshot::new(move || { + restore_setup_snapshot(&snapshot) + }), + )) + } + + fn setup_plugin( + self, + gateway_url: &str, + plugin_root: &std::path::Path, + generation_token: Option<&str>, + ) -> Result<(), String> { + setup_marketplace_plugin(self, gateway_url, plugin_root, generation_token) + } + + fn uninstall_plugin( + self, + gateway_url: &str, + plugin_root: &std::path::Path, + ) -> Result<(), String> { + uninstall_marketplace_plugin(self, gateway_url, plugin_root) + } + + fn doctor_plugin( + self, + gateway_url: &str, + plugin_root: &std::path::Path, + generation_token: Option<&str>, + ) -> Result<(), String> { + doctor_marketplace_plugin(self, gateway_url, plugin_root, generation_token) + } + + fn doctor_plugin_json( + self, + gateway_url: &str, + plugin_root: &std::path::Path, + ) -> Result { + doctor_marketplace_plugin_json(self, gateway_url, plugin_root) + } } pub(crate) fn marketplace_manifest( @@ -346,8 +399,6 @@ pub(crate) fn prepare_launch( pub(crate) enum SetupSnapshot { Codex(CodexSetupSnapshot), Claude(ClaudeSetupSnapshot), - #[cfg(test)] - Mock, } pub(crate) fn setup_action_description(agent: CodingAgent, action: &str) -> String { @@ -380,8 +431,6 @@ pub(crate) fn restore_setup_snapshot(snapshot: &SetupSnapshot) -> Result<(), Str match snapshot { SetupSnapshot::Codex(snapshot) => restore_codex_setup(snapshot), SetupSnapshot::Claude(snapshot) => restore_claude_setup(snapshot), - #[cfg(test)] - SetupSnapshot::Mock => Ok(()), } } diff --git a/crates/cli/src/installation/marketplace/mod.rs b/crates/cli/src/installation/marketplace/mod.rs index 3f6a68df2..61ff2359b 100644 --- a/crates/cli/src/installation/marketplace/mod.rs +++ b/crates/cli/src/installation/marketplace/mod.rs @@ -9,7 +9,7 @@ mod setup; mod spec; pub(crate) mod state; -pub(crate) use spec::MarketplaceHost; +pub(crate) use spec::{MarketplaceHost, PluginSetupSnapshot}; use std::fs; use std::path::{Path, PathBuf}; @@ -38,7 +38,7 @@ use host::{ run_host_plugin_removal, validate_relay_hook_forward, validate_relay_mcp, }; use setup::{ - PluginSetupRunner, PluginSetupSnapshot, RealPluginSetupRunner, run_plugin_doctor_json, + HostPluginSetupRunner, PluginSetupRunner, run_plugin_doctor_json, run_plugin_doctor_with_generation, run_plugin_setup_with_generation, run_plugin_uninstall, }; #[cfg(test)] @@ -177,7 +177,7 @@ fn spawn_default_host_plugin_readiness( skip_doctor: true, }; let runner = RealCommandRunner; - let setup_runner = RealPluginSetupRunner; + let setup_runner = HostPluginSetupRunner::new(host); let readiness = match host { CodingAgent::Hermes => crate::agents::hermes::install::collect_readiness( &worker_state_path, @@ -243,7 +243,10 @@ fn failed_host_plugin_readiness( readiness } -pub(crate) fn install(host: CodingAgent, command: InstallRequest) -> Result { +pub(crate) fn install( + host: impl MarketplaceHost, + command: InstallRequest, +) -> Result { let operation_lock_dir = if command.dry_run { PathBuf::new() } else { @@ -263,7 +266,7 @@ pub(crate) fn install(host: CodingAgent, command: InstallRequest) -> Result Result { let operation_lock_dir = if command.dry_run { @@ -319,21 +322,22 @@ pub(crate) fn doctor( Ok(ExitCode::SUCCESS) } -fn run_for_host( - host: CodingAgent, +fn run_for_host( + host: H, options: &PluginInstallOptions, mut action: F, ) -> Result where + H: MarketplaceHost, F: FnMut( - CodingAgent, + H, &PluginInstallOptions, &dyn CommandRunner, &dyn PluginSetupRunner, ) -> Result<(), String>, { let runner = RealCommandRunner; - let setup_runner = RealPluginSetupRunner; + let setup_runner = HostPluginSetupRunner::new(host); action(host, options, &runner, &setup_runner).map_err(CliError::Install)?; Ok(ExitCode::SUCCESS) } @@ -343,7 +347,6 @@ fn doctor_json( options: &PluginInstallOptions, ) -> Result { let runner = RealCommandRunner; - let setup_runner = RealPluginSetupRunner; let reports = hosts .iter() .copied() @@ -351,7 +354,10 @@ fn doctor_json( CodingAgent::Hermes => { crate::agents::hermes::install::doctor_json_value(options, &runner) } - _ => doctor_host_json_value(host, options, &runner, &setup_runner), + _ => { + let setup_runner = HostPluginSetupRunner::new(host); + doctor_host_json_value(host, options, &runner, &setup_runner) + } }) .collect::, _>>() .map_err(CliError::Install)?; @@ -430,7 +436,7 @@ pub(crate) fn installed_agents( } fn install_host( - host: CodingAgent, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -445,7 +451,7 @@ fn install_host( } fn install_host_with_operation_timeout( - host: CodingAgent, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -465,7 +471,7 @@ fn install_host_with_operation_timeout( } fn install_host_locked( - host: CodingAgent, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -781,7 +787,7 @@ fn install_host_locked( } fn uninstall_host( - host: CodingAgent, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -796,7 +802,7 @@ fn uninstall_host( } fn uninstall_host_with_operation_timeout( - host: CodingAgent, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -816,7 +822,7 @@ fn uninstall_host_with_operation_timeout( } fn uninstall_host_locked( - host: CodingAgent, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -877,7 +883,7 @@ fn uninstall_host_locked( } fn retire_installed_generation( - host: CodingAgent, + host: impl MarketplaceHost, plugin_root: &Path, expected_generation_lock: &Path, local_install_exists: bool, @@ -912,7 +918,7 @@ fn retire_installed_generation( } fn retire_replacement_before_rollback( - host: CodingAgent, + host: impl MarketplaceHost, layout: &PluginLayout, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, @@ -1025,7 +1031,7 @@ fn legacy_plugin_without_mcp( } fn uninstall_host_with_setup_override( - host: CodingAgent, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -1063,7 +1069,7 @@ fn uninstall_host_with_setup_override( } fn doctor_host( - host: CodingAgent, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -1091,7 +1097,7 @@ fn doctor_host( } fn doctor_host_json_value( - host: CodingAgent, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -1118,7 +1124,7 @@ fn doctor_host_json_value( } fn collect_host_plugin_readiness( - host: CodingAgent, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, @@ -1483,7 +1489,7 @@ struct ReplacementGenerationLock { } fn acquire_replacement_generation_lock( - host: CodingAgent, + host: impl MarketplaceHost, marker_path: &Path, expected_generation_lock: &Path, remove_lock_if_unreferenced: bool, @@ -1587,7 +1593,7 @@ struct PluginInstallPreflight { } fn prepare_plugin_install( - host: CodingAgent, + host: impl MarketplaceHost, layout: &PluginLayout, options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -1753,7 +1759,7 @@ fn generation_lock_is_absent(path: &Path) -> bool { } fn stage_plugin_marketplace( - host: CodingAgent, + host: impl MarketplaceHost, relay: &Path, target: &PluginLayout, initialize_generation_lock: bool, @@ -1775,7 +1781,7 @@ fn stage_plugin_marketplace( } fn stage_plugin_marketplace_at( - host: CodingAgent, + host: impl MarketplaceHost, relay: &Path, target: &PluginLayout, initialize_generation_lock: bool, @@ -1808,7 +1814,7 @@ fn stage_plugin_marketplace_at( } fn begin_force_replacement( - host: CodingAgent, + host: impl MarketplaceHost, layout: &PluginLayout, preflight: PluginInstallPreflight, options: &PluginInstallOptions, @@ -1827,7 +1833,7 @@ fn begin_force_replacement( previous_install_exists: _, generation_retirement, } = preflight; - let setup_snapshot = setup_runner.snapshot(host)?; + let setup_snapshot = setup_runner.snapshot(host.install_arg())?; let backup_parent = previous_marketplace_root .parent() .unwrap_or(&options.install_dir); @@ -1873,12 +1879,7 @@ fn begin_force_replacement( cleanup_state.host_marketplace_removed = !marketplace_registered; let result = (|| { if cleanup_state.plugin_setup_installed { - run_plugin_uninstall( - host, - &cleanup_state.plugin_root, - options, - setup_runner, - )?; + run_plugin_uninstall(host, &cleanup_state.plugin_root, options, setup_runner)?; cleanup_state.plugin_setup_installed = false; } run_host_unregistration( @@ -1950,7 +1951,7 @@ fn begin_force_replacement( } fn restore_force_replacement_after_error( - host: CodingAgent, + host: impl MarketplaceHost, layout: &PluginLayout, snapshot: &mut ForceInstallSnapshot, options: &PluginInstallOptions, @@ -1967,7 +1968,7 @@ fn restore_force_replacement_after_error( } fn restore_force_replacement( - host: CodingAgent, + host: impl MarketplaceHost, layout: &PluginLayout, snapshot: &mut ForceInstallSnapshot, options: &PluginInstallOptions, @@ -2094,7 +2095,7 @@ fn restore_force_replacement( } fn force_cleanup_existing_install( - host: CodingAgent, + host: impl MarketplaceHost, layout: &PluginLayout, options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -2118,7 +2119,7 @@ fn force_cleanup_existing_install( } fn rollback_install( - host: CodingAgent, + host: impl MarketplaceHost, layout: &PluginLayout, registration: HostRegistrationProgress, setup_installed: bool, @@ -2147,7 +2148,7 @@ fn rollback_install( } fn run_host_unregistration( - host: CodingAgent, + host: impl MarketplaceHost, state: &mut PluginState, install_dir: &Path, options: &PluginInstallOptions, @@ -2184,8 +2185,6 @@ fn with_schema(mut value: Value) -> Value { #[cfg(test)] use assets::*; #[cfg(test)] -use setup::setup_action_description; -#[cfg(test)] use state::*; #[cfg(test)] diff --git a/crates/cli/src/installation/marketplace/setup.rs b/crates/cli/src/installation/marketplace/setup.rs index 54572c58c..723bb318e 100644 --- a/crates/cli/src/installation/marketplace/setup.rs +++ b/crates/cli/src/installation/marketplace/setup.rs @@ -1,19 +1,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Host setup, restore, and doctor delegation. +//! Host-bound setup, restore, and doctor delegation. -use crate::agents::CodingAgent; -use serde_json::Value; use std::path::Path; -use super::DEFAULT_GATEWAY_URL; -use super::state::PluginInstallOptions; -use super::state::PluginLayout; +use serde_json::Value; + +use super::state::{PluginInstallOptions, PluginLayout}; +use super::{DEFAULT_GATEWAY_URL, MarketplaceHost, PluginSetupSnapshot}; #[cfg(test)] pub(super) fn run_plugin_setup( - host: CodingAgent, + host: impl MarketplaceHost, layout: &PluginLayout, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, @@ -22,18 +21,18 @@ pub(super) fn run_plugin_setup( } pub(super) fn run_plugin_setup_with_generation( - host: CodingAgent, + host: impl MarketplaceHost, layout: &PluginLayout, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, generation_token: Option<&str>, ) -> Result<(), String> { if options.dry_run { - println!("{}", setup_action_description(host, "configure")); + println!("{}", setup_runner.action_description("configure")); return Ok(()); } setup_runner.setup_with_generation( - host, + host.install_arg(), DEFAULT_GATEWAY_URL, &layout.plugin_root, generation_token, @@ -41,21 +40,21 @@ pub(super) fn run_plugin_setup_with_generation( } pub(super) fn run_plugin_uninstall( - host: CodingAgent, + host: impl MarketplaceHost, plugin_root: &Path, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, ) -> Result<(), String> { if options.dry_run { - println!("{}", setup_action_description(host, "restore")); + println!("{}", setup_runner.action_description("restore")); return Ok(()); } - setup_runner.uninstall(host, DEFAULT_GATEWAY_URL, plugin_root) + setup_runner.uninstall(host.install_arg(), DEFAULT_GATEWAY_URL, plugin_root) } #[cfg(test)] pub(super) fn run_plugin_doctor( - host: CodingAgent, + host: impl MarketplaceHost, plugin_root: &Path, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, @@ -64,155 +63,157 @@ pub(super) fn run_plugin_doctor( } pub(super) fn run_plugin_doctor_with_generation( - host: CodingAgent, + host: impl MarketplaceHost, plugin_root: &Path, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, generation_token: Option<&str>, ) -> Result<(), String> { if options.dry_run { - println!("{}", setup_action_description(host, "doctor")); + println!("{}", setup_runner.action_description("doctor")); return Ok(()); } - setup_runner.doctor_with_generation(host, DEFAULT_GATEWAY_URL, plugin_root, generation_token) + setup_runner.doctor_with_generation( + host.install_arg(), + DEFAULT_GATEWAY_URL, + plugin_root, + generation_token, + ) } pub(super) fn run_plugin_doctor_json( - host: CodingAgent, + host: impl MarketplaceHost, plugin_root: &Path, setup_runner: &dyn PluginSetupRunner, ) -> Result { - setup_runner.doctor_json(host, DEFAULT_GATEWAY_URL, plugin_root) -} - -pub(super) fn setup_action_description(host: CodingAgent, action: &str) -> String { - crate::agents::setup_action_description(host, action) + setup_runner.doctor_json(host.install_arg(), DEFAULT_GATEWAY_URL, plugin_root) } pub(super) trait PluginSetupRunner { - fn snapshot(&self, _host: CodingAgent) -> Result, String> { + fn action_description(&self, action: &str) -> String { + action.to_string() + } + + fn snapshot(&self, _host_arg: &str) -> Result, String> { Ok(None) } - fn restore_snapshot(&self, _snapshot: &PluginSetupSnapshot) -> Result<(), String> { - Ok(()) + fn restore_snapshot(&self, snapshot: &PluginSetupSnapshot) -> Result<(), String> { + snapshot.restore() } fn refresh_gateway(&self) -> Result<(), String> { Ok(()) } - fn setup(&self, host: CodingAgent, gateway_url: &str, plugin_root: &Path) - -> Result<(), String>; + fn setup(&self, host_arg: &str, gateway_url: &str, plugin_root: &Path) -> Result<(), String>; + fn setup_with_generation( &self, - host: CodingAgent, + host_arg: &str, gateway_url: &str, plugin_root: &Path, _generation_token: Option<&str>, ) -> Result<(), String> { - self.setup(host, gateway_url, plugin_root) + self.setup(host_arg, gateway_url, plugin_root) } + fn uninstall( &self, - host: CodingAgent, - gateway_url: &str, - plugin_root: &Path, - ) -> Result<(), String>; - fn doctor( - &self, - host: CodingAgent, + host_arg: &str, gateway_url: &str, plugin_root: &Path, ) -> Result<(), String>; + + fn doctor(&self, host_arg: &str, gateway_url: &str, plugin_root: &Path) -> Result<(), String>; + fn doctor_with_generation( &self, - host: CodingAgent, + host_arg: &str, gateway_url: &str, plugin_root: &Path, _generation_token: Option<&str>, ) -> Result<(), String> { - self.doctor(host, gateway_url, plugin_root) + self.doctor(host_arg, gateway_url, plugin_root) } + fn doctor_json( &self, - host: CodingAgent, + host_arg: &str, gateway_url: &str, plugin_root: &Path, ) -> Result; } -pub(super) struct RealPluginSetupRunner; +pub(super) struct HostPluginSetupRunner { + host: H, +} -pub(super) use crate::agents::SetupSnapshot as PluginSetupSnapshot; +impl HostPluginSetupRunner { + pub(super) const fn new(host: H) -> Self { + Self { host } + } +} -impl PluginSetupRunner for RealPluginSetupRunner { - fn snapshot(&self, host: CodingAgent) -> Result, String> { - crate::agents::snapshot_setup(host).map(Some) +impl PluginSetupRunner for HostPluginSetupRunner { + fn action_description(&self, action: &str) -> String { + self.host.setup_action_description(action) } - fn restore_snapshot(&self, snapshot: &PluginSetupSnapshot) -> Result<(), String> { - crate::agents::restore_setup_snapshot(snapshot) + fn snapshot(&self, _host_arg: &str) -> Result, String> { + self.host.snapshot_setup() } fn refresh_gateway(&self) -> Result<(), String> { crate::agents::stop_plugin_gateway() } - fn setup( - &self, - host: CodingAgent, - gateway_url: &str, - plugin_root: &Path, - ) -> Result<(), String> { - self.setup_with_generation(host, gateway_url, plugin_root, None) + fn setup(&self, _host_arg: &str, gateway_url: &str, plugin_root: &Path) -> Result<(), String> { + self.host.setup_plugin(gateway_url, plugin_root, None) } fn setup_with_generation( &self, - host: CodingAgent, + _host_arg: &str, gateway_url: &str, plugin_root: &Path, generation_token: Option<&str>, ) -> Result<(), String> { - crate::agents::setup_marketplace_plugin(host, gateway_url, plugin_root, generation_token) + self.host + .setup_plugin(gateway_url, plugin_root, generation_token) } fn uninstall( &self, - host: CodingAgent, + _host_arg: &str, gateway_url: &str, plugin_root: &Path, ) -> Result<(), String> { - crate::agents::uninstall_marketplace_plugin(host, gateway_url, plugin_root) + self.host.uninstall_plugin(gateway_url, plugin_root) } - fn doctor( - &self, - host: CodingAgent, - gateway_url: &str, - plugin_root: &Path, - ) -> Result<(), String> { - self.doctor_with_generation(host, gateway_url, plugin_root, None) + fn doctor(&self, _host_arg: &str, gateway_url: &str, plugin_root: &Path) -> Result<(), String> { + self.host.doctor_plugin(gateway_url, plugin_root, None) } fn doctor_with_generation( &self, - host: CodingAgent, + _host_arg: &str, gateway_url: &str, plugin_root: &Path, generation_token: Option<&str>, ) -> Result<(), String> { - crate::agents::doctor_marketplace_plugin(host, gateway_url, plugin_root, generation_token) + self.host + .doctor_plugin(gateway_url, plugin_root, generation_token) } fn doctor_json( &self, - host: CodingAgent, + _host_arg: &str, gateway_url: &str, plugin_root: &Path, ) -> Result { - crate::agents::doctor_marketplace_plugin_json(host, gateway_url, plugin_root) + self.host.doctor_plugin_json(gateway_url, plugin_root) } } diff --git a/crates/cli/src/installation/marketplace/spec.rs b/crates/cli/src/installation/marketplace/spec.rs index 30d117c5b..8fca35a7e 100644 --- a/crates/cli/src/installation/marketplace/spec.rs +++ b/crates/cli/src/installation/marketplace/spec.rs @@ -10,11 +10,32 @@ use serde_json::Value; use super::host::{CommandRunner, HostRegistrationReport}; use super::state::PluginInstallOptions; +pub(crate) enum PluginSetupSnapshot { + Callback(Box Result<(), String>>), + #[cfg(test)] + Mock, +} + +impl PluginSetupSnapshot { + pub(crate) fn new(restore: impl Fn() -> Result<(), String> + 'static) -> Self { + Self::Callback(Box::new(restore)) + } + + pub(crate) fn restore(&self) -> Result<(), String> { + match self { + Self::Callback(restore) => restore(), + #[cfg(test)] + Self::Mock => Ok(()), + } + } +} + pub(crate) trait MarketplaceHost: Copy { fn install_arg(self) -> &'static str; fn label(self) -> &'static str; fn executable(self) -> &'static str; fn validate_version_output(self, output: &str) -> Result<(), String>; + fn version_requirement(self) -> String; fn marketplace_manifest_relative(self) -> &'static [&'static str]; fn plugin_manifest_relative(self) -> &'static [&'static str]; fn marketplace_manifest(self, marketplace: &str, plugin: &str) -> Value; @@ -44,4 +65,20 @@ pub(crate) trait MarketplaceHost: Copy { plugin_manifest: &Path, generation_fence: &Path, ) -> bool; + fn setup_action_description(self, action: &str) -> String; + fn snapshot_setup(self) -> Result, String>; + fn setup_plugin( + self, + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, + ) -> Result<(), String>; + fn uninstall_plugin(self, gateway_url: &str, plugin_root: &Path) -> Result<(), String>; + fn doctor_plugin( + self, + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, + ) -> Result<(), String>; + fn doctor_plugin_json(self, gateway_url: &str, plugin_root: &Path) -> Result; } diff --git a/crates/cli/tests/coverage/agents/plugin_install_setup_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_setup_tests.rs index d098688c4..c88c3467b 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_setup_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_setup_tests.rs @@ -5,6 +5,7 @@ use serde_json::{Value, json}; use std::cell::RefCell; use super::*; +use crate::agents::CodingAgent; struct DefaultsOnlyRunner; @@ -16,7 +17,7 @@ struct GenerationAwareRunner { impl PluginSetupRunner for DefaultsOnlyRunner { fn setup( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -25,7 +26,7 @@ impl PluginSetupRunner for DefaultsOnlyRunner { fn uninstall( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -34,7 +35,7 @@ impl PluginSetupRunner for DefaultsOnlyRunner { fn doctor( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -43,7 +44,7 @@ impl PluginSetupRunner for DefaultsOnlyRunner { fn doctor_json( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result { @@ -54,7 +55,7 @@ impl PluginSetupRunner for DefaultsOnlyRunner { impl PluginSetupRunner for GenerationAwareRunner { fn setup( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -63,7 +64,7 @@ impl PluginSetupRunner for GenerationAwareRunner { fn setup_with_generation( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, generation_token: Option<&str>, @@ -76,7 +77,7 @@ impl PluginSetupRunner for GenerationAwareRunner { fn uninstall( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -85,7 +86,7 @@ impl PluginSetupRunner for GenerationAwareRunner { fn doctor( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -94,7 +95,7 @@ impl PluginSetupRunner for GenerationAwareRunner { fn doctor_with_generation( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, generation_token: Option<&str>, @@ -107,7 +108,7 @@ impl PluginSetupRunner for GenerationAwareRunner { fn doctor_json( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result { @@ -119,7 +120,7 @@ impl PluginSetupRunner for GenerationAwareRunner { fn setup_runner_defaults_are_explicit_no_ops() { let runner = DefaultsOnlyRunner; - assert!(runner.snapshot(CodingAgent::Codex).unwrap().is_none()); + assert!(runner.snapshot("codex").unwrap().is_none()); runner.restore_snapshot(&PluginSetupSnapshot::Mock).unwrap(); runner.refresh_gateway().unwrap(); } @@ -162,8 +163,6 @@ fn setup_and_doctor_receive_the_installer_verified_generation() { #[test] fn setup_descriptions_reject_unknown_actions() { - assert!( - std::panic::catch_unwind(|| setup_action_description(CodingAgent::Codex, "unknown")) - .is_err() - ); + let runner = HostPluginSetupRunner::new(CodingAgent::Codex); + assert!(std::panic::catch_unwind(|| runner.action_description("unknown")).is_err()); } diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index a15d2b050..3715c65af 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -713,7 +713,7 @@ struct FailStateWriteAfterRefresh { } impl PluginSetupRunner for FailStateWriteAfterRefresh { - fn snapshot(&self, _host: CodingAgent) -> Result, String> { + fn snapshot(&self, _host_arg: &str) -> Result, String> { Ok(Some(PluginSetupSnapshot::Mock)) } @@ -730,7 +730,7 @@ impl PluginSetupRunner for FailStateWriteAfterRefresh { fn setup( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -739,7 +739,7 @@ impl PluginSetupRunner for FailStateWriteAfterRefresh { fn uninstall( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -748,7 +748,7 @@ impl PluginSetupRunner for FailStateWriteAfterRefresh { fn doctor( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -757,7 +757,7 @@ impl PluginSetupRunner for FailStateWriteAfterRefresh { fn doctor_json( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result { @@ -766,7 +766,7 @@ impl PluginSetupRunner for FailStateWriteAfterRefresh { } impl PluginSetupRunner for BlockingRefreshFailure { - fn snapshot(&self, _host: CodingAgent) -> Result, String> { + fn snapshot(&self, _host_arg: &str) -> Result, String> { Ok(Some(PluginSetupSnapshot::Mock)) } @@ -782,7 +782,7 @@ impl PluginSetupRunner for BlockingRefreshFailure { fn setup( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -791,7 +791,7 @@ impl PluginSetupRunner for BlockingRefreshFailure { fn uninstall( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -800,7 +800,7 @@ impl PluginSetupRunner for BlockingRefreshFailure { fn doctor( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { @@ -809,7 +809,7 @@ impl PluginSetupRunner for BlockingRefreshFailure { fn doctor_json( &self, - _host: CodingAgent, + _host_arg: &str, _gateway_url: &str, _plugin_root: &Path, ) -> Result { @@ -828,8 +828,8 @@ impl MockSetupRunner { } impl PluginSetupRunner for MockSetupRunner { - fn snapshot(&self, host: CodingAgent) -> Result, String> { - self.record(format!("snapshot {}", host.install_arg()))?; + fn snapshot(&self, host_arg: &str) -> Result, String> { + self.record(format!("snapshot {host_arg}"))?; Ok(Some(PluginSetupSnapshot::Mock)) } @@ -841,43 +841,33 @@ impl PluginSetupRunner for MockSetupRunner { self.record("refresh gateway".into()) } - fn setup( - &self, - host: CodingAgent, - gateway_url: &str, - _plugin_root: &Path, - ) -> Result<(), String> { - self.record(format!("setup {} {gateway_url}", host.install_arg())) + fn setup(&self, host_arg: &str, gateway_url: &str, _plugin_root: &Path) -> Result<(), String> { + self.record(format!("setup {host_arg} {gateway_url}")) } fn uninstall( &self, - host: CodingAgent, + host_arg: &str, gateway_url: &str, _plugin_root: &Path, ) -> Result<(), String> { - self.record(format!("uninstall {} {gateway_url}", host.install_arg())) + self.record(format!("uninstall {host_arg} {gateway_url}")) } - fn doctor( - &self, - host: CodingAgent, - gateway_url: &str, - _plugin_root: &Path, - ) -> Result<(), String> { - self.record(format!("doctor {} {gateway_url}", host.install_arg())) + fn doctor(&self, host_arg: &str, gateway_url: &str, _plugin_root: &Path) -> Result<(), String> { + self.record(format!("doctor {host_arg} {gateway_url}")) } fn doctor_json( &self, - host: CodingAgent, + host_arg: &str, gateway_url: &str, plugin_root: &Path, ) -> Result { self.doctor_roots .borrow_mut() .push(plugin_root.to_path_buf()); - self.record(format!("doctor-json {} {gateway_url}", host.install_arg()))?; + self.record(format!("doctor-json {host_arg} {gateway_url}"))?; Ok(json!({ "ok": true, "checks": {} @@ -1655,53 +1645,53 @@ fn plugin_setup_delegates_and_dry_run_skips_runner_calls() { fn real_plugin_setup_runner_uses_temp_home_for_claude_paths() { let dir = tempdir().unwrap(); let _home = HomeScope::enter(dir.path()); - let runner = RealPluginSetupRunner; + let runner = HostPluginSetupRunner::new(CodingAgent::ClaudeCode); let plugin_root = dir.path().join("plugin"); runner - .setup(CodingAgent::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) + .setup("claude-code", DEFAULT_GATEWAY_URL, &plugin_root) .unwrap(); assert!( runner - .doctor(CodingAgent::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) + .doctor("claude-code", DEFAULT_GATEWAY_URL, &plugin_root) .is_ok() ); let claude_report = runner - .doctor_json(CodingAgent::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) + .doctor_json("claude-code", DEFAULT_GATEWAY_URL, &plugin_root) .unwrap(); assert_eq!( claude_report["checks"]["claude_provider_routing"], json!(true) ); runner - .uninstall(CodingAgent::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) + .uninstall("claude-code", DEFAULT_GATEWAY_URL, &plugin_root) .unwrap(); } #[test] fn setup_action_descriptions_cover_supported_hosts_and_actions() { assert_eq!( - setup_action_description(CodingAgent::Codex, "configure"), + CodingAgent::Codex.setup_action_description("configure"), "configure Codex provider and trust plugin-owned hooks" ); assert_eq!( - setup_action_description(CodingAgent::Codex, "restore"), + CodingAgent::Codex.setup_action_description("restore"), "remove Codex provider and plugin hook trust" ); assert_eq!( - setup_action_description(CodingAgent::Codex, "doctor"), + CodingAgent::Codex.setup_action_description("doctor"), "check Codex provider and plugin-owned hooks" ); assert_eq!( - setup_action_description(CodingAgent::ClaudeCode, "configure"), + CodingAgent::ClaudeCode.setup_action_description("configure"), "enable Claude Code provider routing through NeMo Relay" ); assert_eq!( - setup_action_description(CodingAgent::ClaudeCode, "restore"), + CodingAgent::ClaudeCode.setup_action_description("restore"), "restore Claude Code provider routing from NeMo Relay backup" ); assert_eq!( - setup_action_description(CodingAgent::ClaudeCode, "doctor"), + CodingAgent::ClaudeCode.setup_action_description("doctor"), "check Claude Code provider routing" ); } From 12cb5381002748f69de76b0256c701ad2c70deb2 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:45:52 -0400 Subject: [PATCH 076/102] refactor(cli): move integration selection to agents Signed-off-by: Will Killian --- crates/cli/src/agents/mod.rs | 28 +++++++++ crates/cli/src/commands/diagnostics.rs | 5 +- crates/cli/src/commands/install.rs | 7 +-- .../cli/src/installation/marketplace/mod.rs | 59 +++---------------- .../cli/src/installation/marketplace/state.rs | 6 -- .../coverage/agents/plugin_install_tests.rs | 51 +++------------- 6 files changed, 47 insertions(+), 109 deletions(-) diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 28b4084d3..c289c90d7 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -515,6 +515,34 @@ pub(crate) fn uninstall_integration( } } +pub(crate) fn detected_install_integrations(candidates: &[CodingAgent]) -> Vec { + candidates + .iter() + .copied() + .filter(|agent| crate::process::resolve_executable(agent.executable()).is_some()) + .collect() +} + +pub(crate) fn installed_integrations( + candidates: &[CodingAgent], + install_dir: Option<&Path>, +) -> Vec { + let install_dir = install_dir + .map(Path::to_path_buf) + .unwrap_or_else(crate::installation::marketplace::default_marketplace_install_dir); + candidates + .iter() + .copied() + .filter(|agent| match agent { + CodingAgent::Codex | CodingAgent::ClaudeCode => { + crate::installation::marketplace::persisted_state_exists(*agent, &install_dir) + } + CodingAgent::Hermes => hermes::install::config_path() + .is_ok_and(|path| hermes::persistent_state_exists(&path)), + }) + .collect() +} + pub(crate) use claude::host::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; pub(crate) use codex::host::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; pub(crate) use shared::host::portable_executable_path; diff --git a/crates/cli/src/commands/diagnostics.rs b/crates/cli/src/commands/diagnostics.rs index b862baffa..17f76bfa7 100644 --- a/crates/cli/src/commands/diagnostics.rs +++ b/crates/cli/src/commands/diagnostics.rs @@ -32,10 +32,7 @@ pub(super) async fn execute(command: DoctorCommand) -> Result Result { let request = command.into_runtime(); let candidates = target.agents(); let agents = if target.is_all() { - crate::installation::marketplace::detected_install_agents(&candidates)? + crate::agents::detected_install_integrations(&candidates) } else { candidates }; @@ -107,10 +107,7 @@ pub(super) fn uninstall(command: UninstallCommand) -> Result let request = command.into_runtime(); let candidates = target.agents(); let agents = if target.is_all() { - crate::installation::marketplace::installed_agents( - &candidates, - request.install_dir.as_deref(), - )? + crate::agents::installed_integrations(&candidates, request.install_dir.as_deref()) } else { candidates }; diff --git a/crates/cli/src/installation/marketplace/mod.rs b/crates/cli/src/installation/marketplace/mod.rs index 61ff2359b..342cef61d 100644 --- a/crates/cli/src/installation/marketplace/mod.rs +++ b/crates/cli/src/installation/marketplace/mod.rs @@ -44,9 +44,9 @@ use setup::{ #[cfg(test)] use setup::{run_plugin_doctor, run_plugin_setup}; use state::{ - CanonicalizeOrSelf, HostRegistrationProgress, HostSelectionMode, PluginInstallOptions, - PluginLayout, PluginState, default_install_dir, mark_plugin_setup_installed, read_state, - remove_path, state_path, write_state, write_state_for_host, + CanonicalizeOrSelf, HostRegistrationProgress, PluginInstallOptions, PluginLayout, PluginState, + default_install_dir, mark_plugin_setup_installed, read_state, remove_path, state_path, + write_state, write_state_for_host, }; pub(super) use crate::bootstrap::DEFAULT_URL as DEFAULT_GATEWAY_URL; @@ -382,57 +382,12 @@ fn doctor_json( }) } -fn select_available_agents( - candidates: &[CodingAgent], - mode: HostSelectionMode, - install_dir: &Path, - runner: &dyn CommandRunner, -) -> Result, CliError> { - let mut hosts = Vec::new(); - for &candidate in candidates { - let selected = match mode { - HostSelectionMode::Install => runner - .resolve_executable(candidate.executable()) - .map_err(CliError::Install)? - .is_some(), - HostSelectionMode::InstalledState => match candidate { - CodingAgent::Hermes => crate::agents::hermes::install::config_path() - .is_ok_and(|path| crate::agents::hermes::persistent_state_exists(&path)), - _ => state_path(candidate, install_dir).exists(), - }, - }; - if selected { - hosts.push(candidate); - } - } - Ok(hosts) +pub(crate) fn default_marketplace_install_dir() -> PathBuf { + default_install_dir().canonicalize_or_self() } -pub(crate) fn detected_install_agents( - candidates: &[CodingAgent], -) -> Result, CliError> { - select_available_agents( - candidates, - HostSelectionMode::Install, - &default_install_dir().canonicalize_or_self(), - &RealCommandRunner, - ) -} - -pub(crate) fn installed_agents( - candidates: &[CodingAgent], - install_dir: Option<&Path>, -) -> Result, CliError> { - let install_dir = install_dir - .map(Path::to_path_buf) - .unwrap_or_else(default_install_dir) - .canonicalize_or_self(); - select_available_agents( - candidates, - HostSelectionMode::InstalledState, - &install_dir, - &RealCommandRunner, - ) +pub(crate) fn persisted_state_exists(host: impl MarketplaceHost, install_dir: &Path) -> bool { + state_path(host, install_dir).exists() } fn install_host( diff --git a/crates/cli/src/installation/marketplace/state.rs b/crates/cli/src/installation/marketplace/state.rs index 1ec9a11ca..df3b2a257 100644 --- a/crates/cli/src/installation/marketplace/state.rs +++ b/crates/cli/src/installation/marketplace/state.rs @@ -23,12 +23,6 @@ pub(crate) struct PluginInstallOptions { pub(crate) skip_doctor: bool, } -#[derive(Debug, Clone, Copy)] -pub(super) enum HostSelectionMode { - Install, - InstalledState, -} - #[derive(Debug, Clone, Copy, Default)] pub(super) struct HostRegistrationProgress { pub(super) host_plugin_added: bool, diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index 3715c65af..ff9c16246 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -1953,15 +1953,13 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { std::fs::create_dir_all(&empty_path).unwrap(); let _path = PathScope::set_isolated(&empty_path, &dir.path().join("home")); + assert!(crate::agents::detected_install_integrations(&CodingAgent::ALL).is_empty()); assert!( - detected_install_agents(&CodingAgent::ALL) - .unwrap() - .is_empty() - ); - assert!( - installed_agents(&CodingAgent::ALL, Some(&dir.path().join("install"))) - .unwrap() - .is_empty() + crate::agents::installed_integrations( + &CodingAgent::ALL, + Some(&dir.path().join("install")), + ) + .is_empty() ); let doctor_error = doctor(&[], Some(dir.path().join("install")), true) @@ -2017,54 +2015,23 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { } #[test] -fn select_all_uses_operation_specific_inputs() { +fn installed_selection_uses_persisted_integration_state() { let dir = tempdir().unwrap(); let home = dir.path().join("home"); std::fs::create_dir_all(home.join(".hermes")).unwrap(); let _home = HomeScope::enter(&home); - let runner = MockRunner::default().with_executable("codex", "/bin/codex"); - let selected = select_available_agents( - &CodingAgent::ALL, - HostSelectionMode::Install, - dir.path(), - &runner, - ) - .unwrap(); - assert_eq!(selected, vec![CodingAgent::Codex]); - std::fs::write( state_path(CodingAgent::ClaudeCode, dir.path()), r#"{"marketplaceRoot":"/tmp/m","pluginRoot":"/tmp/p"}"#, ) .unwrap(); - let selected = select_available_agents( - &CodingAgent::ALL, - HostSelectionMode::Install, - dir.path(), - &runner, - ) - .unwrap(); - assert_eq!(selected, vec![CodingAgent::Codex]); - - let selected = select_available_agents( - &CodingAgent::ALL, - HostSelectionMode::InstalledState, - dir.path(), - &runner, - ) - .unwrap(); + let selected = crate::agents::installed_integrations(&CodingAgent::ALL, Some(dir.path())); assert_eq!(selected, vec![CodingAgent::ClaudeCode]); let unrelated_hermes_config = b"# user-owned formatting\nmodel: custom\n"; let hermes_config = home.join(".hermes/config.yaml"); std::fs::write(&hermes_config, unrelated_hermes_config).unwrap(); - let selected = select_available_agents( - &CodingAgent::ALL, - HostSelectionMode::InstalledState, - dir.path(), - &runner, - ) - .unwrap(); + let selected = crate::agents::installed_integrations(&CodingAgent::ALL, Some(dir.path())); assert_eq!(selected, vec![CodingAgent::ClaudeCode]); assert_eq!( std::fs::read(&hermes_config).unwrap(), From 88b5461da8c02bd5249e22656b2df34fb25d26f0 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:48:21 -0400 Subject: [PATCH 077/102] refactor(cli): move integration doctor dispatch to agents Signed-off-by: Will Killian --- crates/cli/src/agents/mod.rs | 31 +++++++ crates/cli/src/commands/diagnostics.rs | 45 +++++++++- .../cli/src/installation/marketplace/mod.rs | 89 ++++--------------- .../coverage/agents/plugin_install_tests.rs | 32 +------ 4 files changed, 94 insertions(+), 103 deletions(-) diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index c289c90d7..78363851e 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -543,6 +543,37 @@ pub(crate) fn installed_integrations( .collect() } +pub(crate) fn doctor_integration( + agent: CodingAgent, + options: &crate::installation::marketplace::state::PluginInstallOptions, +) -> Result<(), crate::error::CliError> { + match agent { + CodingAgent::Codex | CodingAgent::ClaudeCode => { + crate::installation::marketplace::doctor_marketplace_integration(agent, options) + } + CodingAgent::Hermes => { + let runner = crate::installation::marketplace::host::RealCommandRunner; + hermes::install::doctor(options, &runner).map_err(crate::error::CliError::Install) + } + } +} + +pub(crate) fn doctor_integration_report( + agent: CodingAgent, + options: &crate::installation::marketplace::state::PluginInstallOptions, +) -> Result { + match agent { + CodingAgent::Codex | CodingAgent::ClaudeCode => { + crate::installation::marketplace::doctor_marketplace_report(agent, options) + } + CodingAgent::Hermes => { + let runner = crate::installation::marketplace::host::RealCommandRunner; + hermes::install::doctor_json_value(options, &runner) + .map_err(crate::error::CliError::Install) + } + } +} + pub(crate) use claude::host::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; pub(crate) use codex::host::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; pub(crate) use shared::host::portable_executable_path; diff --git a/crates/cli/src/commands/diagnostics.rs b/crates/cli/src/commands/diagnostics.rs index 17f76bfa7..b1d49566c 100644 --- a/crates/cli/src/commands/diagnostics.rs +++ b/crates/cli/src/commands/diagnostics.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; use std::process::ExitCode; use clap::Args; +use serde_json::{Value, json}; use super::install::InstallTarget; use super::root::AgentArg; @@ -36,8 +37,50 @@ pub(super) async fn execute(command: DoctorCommand) -> Result, _>>()?; + let ready = reports + .iter() + .all(|report| report.get("ok").and_then(Value::as_bool) == Some(true)); + let output = if reports.len() > 1 { + json!({ "schema_version": 1, "plugins": reports }) + } else { + with_schema(reports.into_iter().next().expect("reports is not empty")) + }; + println!( + "{}", + serde_json::to_string_pretty(&output) + .map_err(|error| CliError::Install(error.to_string()))? + ); + Ok(if ready { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }) + } else { + for agent in agents { + crate::agents::doctor_integration(agent, &options)?; + } + Ok(ExitCode::SUCCESS) + } } else { crate::diagnostics::run_doctor(command.agent.map(Into::into), command.json).await } } + +fn with_schema(mut value: Value) -> Value { + if let Some(object) = value.as_object_mut() { + object.insert("schema_version".into(), json!(1)); + } + value +} diff --git a/crates/cli/src/installation/marketplace/mod.rs b/crates/cli/src/installation/marketplace/mod.rs index 342cef61d..039239e46 100644 --- a/crates/cli/src/installation/marketplace/mod.rs +++ b/crates/cli/src/installation/marketplace/mod.rs @@ -287,12 +287,8 @@ pub(crate) fn uninstall( run_for_host(host, &options, uninstall_host) } -pub(crate) fn doctor( - hosts: &[CodingAgent], - install_dir: Option, - json: bool, -) -> Result { - let options = PluginInstallOptions { +pub(crate) fn plugin_doctor_options(install_dir: Option) -> PluginInstallOptions { + PluginInstallOptions { install_dir: install_dir .unwrap_or_else(default_install_dir) .canonicalize_or_self(), @@ -300,26 +296,15 @@ pub(crate) fn doctor( force: false, dry_run: false, skip_doctor: true, - }; - if hosts.is_empty() { - return Err(CliError::Install( - "no installed Claude Code, Codex, or Hermes integration state was found".into(), - )); - } - if json { - return doctor_json(hosts, &options); } - for &host in hosts { - run_for_host( - host, - &options, - |host, options, runner, setup_runner| match host { - CodingAgent::Hermes => crate::agents::hermes::install::doctor(options, runner), - _ => doctor_host(host, options, runner, setup_runner), - }, - )?; - } - Ok(ExitCode::SUCCESS) +} + +pub(crate) fn doctor_marketplace_integration( + host: impl MarketplaceHost, + options: &PluginInstallOptions, +) -> Result<(), CliError> { + run_for_host(host, options, doctor_host)?; + Ok(()) } fn run_for_host( @@ -342,44 +327,13 @@ where Ok(ExitCode::SUCCESS) } -fn doctor_json( - hosts: &[CodingAgent], +pub(crate) fn doctor_marketplace_report( + host: impl MarketplaceHost, options: &PluginInstallOptions, -) -> Result { +) -> Result { let runner = RealCommandRunner; - let reports = hosts - .iter() - .copied() - .map(|host| match host { - CodingAgent::Hermes => { - crate::agents::hermes::install::doctor_json_value(options, &runner) - } - _ => { - let setup_runner = HostPluginSetupRunner::new(host); - doctor_host_json_value(host, options, &runner, &setup_runner) - } - }) - .collect::, _>>() - .map_err(CliError::Install)?; - let ready = reports - .iter() - .all(|report| report.get("ok").and_then(Value::as_bool) == Some(true)); - if hosts.len() > 1 { - print_json(&json!({ - "schema_version": 1, - "plugins": reports - })) - } else { - print_json(&with_schema( - reports.into_iter().next().expect("hosts is not empty"), - )) - } - .map_err(CliError::Install)?; - Ok(if ready { - ExitCode::SUCCESS - } else { - ExitCode::FAILURE - }) + let setup_runner = HostPluginSetupRunner::new(host); + doctor_host_json_value(host, options, &runner, &setup_runner).map_err(CliError::Install) } pub(crate) fn default_marketplace_install_dir() -> PathBuf { @@ -2124,19 +2078,6 @@ fn run_host_unregistration( Ok(()) } -fn print_json(value: &Value) -> Result<(), String> { - let rendered = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?; - println!("{rendered}"); - Ok(()) -} - -fn with_schema(mut value: Value) -> Value { - if let Some(object) = value.as_object_mut() { - object.insert("schema_version".into(), json!(1)); - } - value -} - #[cfg(test)] use assets::*; #[cfg(test)] diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index ff9c16246..35fe4b042 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -1962,21 +1962,6 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { .is_empty() ); - let doctor_error = doctor(&[], Some(dir.path().join("install")), true) - .unwrap_err() - .to_string(); - assert!( - doctor_error.contains("no installed Claude Code, Codex, or Hermes integration state"), - "error was: {doctor_error}" - ); - let doctor_human_error = doctor(&[], Some(dir.path().join("install")), false) - .unwrap_err() - .to_string(); - assert!( - doctor_human_error.contains("no installed Claude Code, Codex, or Hermes integration state"), - "error was: {doctor_human_error}" - ); - assert_eq!( install( CodingAgent::Codex, @@ -1991,13 +1976,10 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { std::process::ExitCode::SUCCESS ); - let codex_doctor_error = doctor( - &[CodingAgent::Codex], - Some(dir.path().join("install")), - false, - ) - .unwrap_err() - .to_string(); + let doctor_options = plugin_doctor_options(Some(dir.path().join("install"))); + let codex_doctor_error = crate::agents::doctor_integration(CodingAgent::Codex, &doctor_options) + .unwrap_err() + .to_string(); assert!( codex_doctor_error.contains("nemo-relay install codex --force"), "error was: {codex_doctor_error}" @@ -2005,12 +1987,6 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { assert_eq!(CodingAgent::Codex.as_arg(), "codex"); assert_eq!(CodingAgent::Codex.label(), "Codex"); - print_json(&json!({"ok": true})).unwrap(); - assert_eq!( - with_schema(json!({"ok": true})), - json!({"ok": true, "schema_version": 1}) - ); - assert_eq!(with_schema(json!("not-an-object")), json!("not-an-object")); assert_eq!(CodingAgent::Codex.executable(), "codex"); } From b6b040ad63b5107c0406f59a0215c4fd6b767ead Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:52:18 -0400 Subject: [PATCH 078/102] refactor(cli): move integration readiness to agents Signed-off-by: Will Killian --- crates/cli/src/agents/mod.rs | 145 +++++++++++++++++- crates/cli/src/diagnostics/mod.rs | 2 +- .../cli/src/installation/marketplace/mod.rs | 136 ++-------------- .../coverage/agents/plugin_install_tests.rs | 41 ++--- 4 files changed, 183 insertions(+), 141 deletions(-) diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 78363851e..2589ed507 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -574,6 +574,149 @@ pub(crate) fn doctor_integration_report( } } +struct PendingIntegrationReadiness { + agent: CodingAgent, + state_path: PathBuf, + receiver: std::sync::mpsc::Receiver, +} + +pub(crate) fn collect_default_integration_readiness() +-> Vec { + const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + + let install_dir = crate::installation::marketplace::default_marketplace_install_dir(); + let agents = installed_integrations(&CodingAgent::ALL, Some(&install_dir)); + let pending = agents + .into_iter() + .map(|agent| spawn_integration_readiness(agent, install_dir.clone())) + .collect::>(); + let deadline = std::time::Instant::now() + TIMEOUT; + pending + .into_iter() + .map(|pending| { + receive_integration_readiness( + pending, + deadline.saturating_duration_since(std::time::Instant::now()), + &install_dir, + ) + }) + .collect() +} + +fn receive_integration_readiness( + pending: PendingIntegrationReadiness, + timeout: std::time::Duration, + install_dir: &Path, +) -> crate::installation::marketplace::HostPluginReadiness { + match pending.receiver.recv_timeout(timeout) { + Ok(readiness) => readiness, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => failed_integration_readiness( + pending.agent, + pending.state_path, + install_dir, + "timed out while collecting persistent-integration readiness", + ), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => failed_integration_readiness( + pending.agent, + pending.state_path, + install_dir, + "persistent-integration readiness collector stopped unexpectedly", + ), + } +} + +#[cfg(test)] +pub(crate) fn receive_integration_readiness_for_test( + agent: CodingAgent, + state_path: PathBuf, + receiver: std::sync::mpsc::Receiver, + install_dir: &Path, + timeout: std::time::Duration, +) -> crate::installation::marketplace::HostPluginReadiness { + receive_integration_readiness( + PendingIntegrationReadiness { + agent, + state_path, + receiver, + }, + timeout, + install_dir, + ) +} + +fn spawn_integration_readiness( + agent: CodingAgent, + install_dir: PathBuf, +) -> PendingIntegrationReadiness { + let state_path = match agent { + CodingAgent::Codex | CodingAgent::ClaudeCode => { + crate::installation::marketplace::marketplace_state_path(agent, &install_dir) + } + CodingAgent::Hermes => { + hermes::install::config_path().unwrap_or_else(|_| install_dir.join("hermes.json")) + } + }; + let worker_state_path = state_path.clone(); + let worker_install_dir = install_dir.clone(); + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let options = crate::installation::marketplace::state::PluginInstallOptions { + install_dir: worker_install_dir, + operation_lock_dir: PathBuf::new(), + force: false, + dry_run: false, + skip_doctor: true, + }; + let runner = crate::installation::marketplace::host::RealCommandRunner; + let readiness = match agent { + CodingAgent::Codex | CodingAgent::ClaudeCode => { + crate::installation::marketplace::collect_marketplace_readiness( + agent, &options, &runner, + ) + } + CodingAgent::Hermes => { + hermes::install::collect_readiness(&worker_state_path, &options, &runner) + } + }; + let _ = sender.send(readiness); + }); + PendingIntegrationReadiness { + agent, + state_path, + receiver, + } +} + +fn failed_integration_readiness( + agent: CodingAgent, + state_path: PathBuf, + install_dir: &Path, + details: &str, +) -> crate::installation::marketplace::HostPluginReadiness { + let (marketplace, plugin) = match agent { + CodingAgent::Codex | CodingAgent::ClaudeCode => { + let (marketplace, plugin) = + crate::installation::marketplace::marketplace_install_roots(agent, install_dir); + (Some(marketplace), Some(plugin)) + } + CodingAgent::Hermes => (None, None), + }; + let mut readiness = crate::installation::marketplace::HostPluginReadiness { + host: agent.install_arg().to_string(), + remediation: format!("nemo-relay install {} --force", agent.install_arg()), + state_path, + marketplace, + plugin, + checks: Vec::new(), + relay: None, + host_plugin_registered: None, + host_marketplace_registered: None, + plugin_setup: None, + }; + readiness.push("Host readiness", Err(details.to_string())); + readiness +} + pub(crate) use claude::host::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; pub(crate) use codex::host::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; pub(crate) use shared::host::portable_executable_path; @@ -581,7 +724,7 @@ pub(crate) use shared::host::shell_quote_arg_for_platform; #[cfg(test)] pub(crate) use shared::host::strip_windows_verbatim_prefix; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde_json::{Value, json}; diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index 8d50ae2df..b4d6ab169 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -104,7 +104,7 @@ pub(crate) async fn collect_report( &plugin_diagnostics, ), agents: collect_agents(target_agent, &resolved).await, - host_plugins: crate::installation::marketplace::collect_default_host_plugin_readiness(), + host_plugins: crate::agents::collect_default_integration_readiness(), observability: collect_observability(&resolved.gateway).await, completions: collect_completions(home.as_deref()), }) diff --git a/crates/cli/src/installation/marketplace/mod.rs b/crates/cli/src/installation/marketplace/mod.rs index 039239e46..cb6a73c92 100644 --- a/crates/cli/src/installation/marketplace/mod.rs +++ b/crates/cli/src/installation/marketplace/mod.rs @@ -14,13 +14,11 @@ pub(crate) use spec::{MarketplaceHost, PluginSetupSnapshot}; use std::fs; use std::path::{Path, PathBuf}; use std::process::ExitCode; -use std::sync::mpsc::{self, Receiver}; -use std::time::{Duration, Instant}; +use std::time::Duration; use serde::Serialize; use serde_json::{Value, json}; -use crate::agents::CodingAgent; use crate::error::CliError; use crate::installation::generation::{ GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration, @@ -53,7 +51,6 @@ pub(super) use crate::bootstrap::DEFAULT_URL as DEFAULT_GATEWAY_URL; pub(super) const MARKETPLACE_NAME: &str = "nemo-relay-local"; pub(super) const PLUGIN_NAME: &str = "nemo-relay-plugin"; pub(super) const RELAY_COMMAND: &str = "nemo-relay"; -const DEFAULT_HOST_PLUGIN_READINESS_TIMEOUT: Duration = Duration::from_secs(5); fn default_operation_lock_dir() -> Result { std::env::var_os("HOME") @@ -119,128 +116,25 @@ impl HostPluginReadiness { } } -struct PendingHostPluginReadiness { - host: CodingAgent, - state_path: PathBuf, - receiver: Receiver, +pub(crate) fn marketplace_state_path(host: impl MarketplaceHost, install_dir: &Path) -> PathBuf { + state_path(host, install_dir) } -/// Collects default-location persistent-integration readiness without printing or mutating state. -/// -/// Only hosts with a persisted install-state record are included. This keeps ordinary -/// transparent-run users from failing the top-level doctor merely because they have not opted -/// into a persistent coding-agent integration. -pub(crate) fn collect_default_host_plugin_readiness() -> Vec { - let install_dir = default_install_dir().canonicalize_or_self(); - let mut hosts = [CodingAgent::Codex, CodingAgent::ClaudeCode] - .into_iter() - .filter(|host| state_path(*host, &install_dir).exists()) - .collect::>(); - if crate::agents::hermes::install::config_path() - .is_ok_and(|path| crate::agents::hermes::persistent_state_exists(&path)) - { - hosts.push(CodingAgent::Hermes); - } - let pending = hosts - .into_iter() - .map(|host| spawn_default_host_plugin_readiness(host, install_dir.clone())) - .collect::>(); - let deadline = Instant::now() + DEFAULT_HOST_PLUGIN_READINESS_TIMEOUT; - pending - .into_iter() - .map(|pending| { - receive_host_plugin_readiness( - pending, - deadline.saturating_duration_since(Instant::now()), - ) - }) - .collect() -} - -fn spawn_default_host_plugin_readiness( - host: CodingAgent, - install_dir: PathBuf, -) -> PendingHostPluginReadiness { - let state_path = match host { - CodingAgent::Hermes => crate::agents::hermes::install::config_path() - .unwrap_or_else(|_| state_path(CodingAgent::Hermes, &install_dir)), - _ => state_path(host, &install_dir), - }; - let worker_state_path = state_path.clone(); - let (sender, receiver) = mpsc::sync_channel(1); - std::thread::spawn(move || { - let options = PluginInstallOptions { - install_dir, - operation_lock_dir: PathBuf::new(), - force: false, - dry_run: false, - skip_doctor: true, - }; - let runner = RealCommandRunner; - let setup_runner = HostPluginSetupRunner::new(host); - let readiness = match host { - CodingAgent::Hermes => crate::agents::hermes::install::collect_readiness( - &worker_state_path, - &options, - &runner, - ), - _ => collect_host_plugin_readiness(host, &options, &runner, &setup_runner), - }; - let _ = sender.send(readiness); - }); - PendingHostPluginReadiness { - host, - state_path, - receiver, - } -} - -fn receive_host_plugin_readiness( - pending: PendingHostPluginReadiness, - timeout: Duration, -) -> HostPluginReadiness { - match pending.receiver.recv_timeout(timeout) { - Ok(readiness) => readiness, - Err(mpsc::RecvTimeoutError::Timeout) => failed_host_plugin_readiness( - pending.host, - pending.state_path, - "timed out while collecting persistent-integration readiness", - ), - Err(mpsc::RecvTimeoutError::Disconnected) => failed_host_plugin_readiness( - pending.host, - pending.state_path, - "persistent-integration readiness collector stopped unexpectedly", - ), - } +pub(crate) fn marketplace_install_roots( + host: impl MarketplaceHost, + install_dir: &Path, +) -> (PathBuf, PathBuf) { + let layout = PluginLayout::new(host, install_dir); + (layout.marketplace_root, layout.plugin_root) } -fn failed_host_plugin_readiness( - host: CodingAgent, - state_path: PathBuf, - details: impl Into, +pub(crate) fn collect_marketplace_readiness( + host: impl MarketplaceHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, ) -> HostPluginReadiness { - let (marketplace, plugin) = match host { - CodingAgent::Hermes => (None, None), - _ => { - let layout = - PluginLayout::new(host, state_path.parent().unwrap_or_else(|| Path::new("."))); - (Some(layout.marketplace_root), Some(layout.plugin_root)) - } - }; - let mut readiness = HostPluginReadiness { - host: host.install_arg().to_string(), - remediation: format!("nemo-relay install {} --force", host.install_arg()), - state_path, - marketplace, - plugin, - checks: Vec::new(), - relay: None, - host_plugin_registered: None, - host_marketplace_registered: None, - plugin_setup: None, - }; - readiness.push("Host readiness", Err(details.into())); - readiness + let setup_runner = HostPluginSetupRunner::new(host); + collect_host_plugin_readiness(host, options, runner, &setup_runner) } pub(crate) fn install( diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index 35fe4b042..d7b779226 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -77,19 +77,21 @@ fn windows_verbatim_relay_paths_are_normalized_for_mcp_config() { #[test] fn readiness_worker_returns_a_report_and_handles_channel_disconnects() { let dir = tempdir().unwrap(); - let pending = spawn_default_host_plugin_readiness(CodingAgent::Codex, dir.path().to_path_buf()); - let readiness = receive_host_plugin_readiness(pending, Duration::from_secs(5)); + let readiness = collect_marketplace_readiness( + CodingAgent::Codex, + &options(dir.path()), + &MockRunner::default(), + ); assert_eq!(readiness.host, "codex"); assert!(!readiness.checks.is_empty()); let (sender, receiver) = std::sync::mpsc::sync_channel(1); drop(sender); - let readiness = receive_host_plugin_readiness( - PendingHostPluginReadiness { - host: CodingAgent::ClaudeCode, - state_path: dir.path().join("claude-state.json"), - receiver, - }, + let readiness = crate::agents::receive_integration_readiness_for_test( + CodingAgent::ClaudeCode, + dir.path().join("claude-state.json"), + receiver, + dir.path(), Duration::from_secs(1), ); assert!(!readiness.ok()); @@ -99,10 +101,14 @@ fn readiness_worker_returns_a_report_and_handles_channel_disconnects() { .contains("collector stopped unexpectedly") ); - let hermes = failed_host_plugin_readiness( + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + drop(sender); + let hermes = crate::agents::receive_integration_readiness_for_test( CodingAgent::Hermes, dir.path().join("config.yaml"), - "fixture failure", + receiver, + dir.path(), + Duration::from_secs(1), ); assert!(hermes.marketplace.is_none()); assert!(hermes.plugin.is_none()); @@ -2061,7 +2067,7 @@ fn hermes_doctor_probes_the_configured_relay_and_top_level_doctor_discovers_it() ); } - let readiness = collect_default_host_plugin_readiness(); + let readiness = crate::agents::collect_default_integration_readiness(); let hermes = readiness .iter() .find(|readiness| readiness.host == "hermes") @@ -4930,15 +4936,14 @@ fn doctor_json_preserves_unknown_host_registration_state() { #[test] fn timed_out_host_plugin_readiness_is_actionable() { let state_path = PathBuf::from("/tmp/nemo-relay/codex.json"); - let (sender, receiver) = mpsc::sync_channel(1); + let (sender, receiver) = std::sync::mpsc::sync_channel(1); let _sender = sender; - let report = receive_host_plugin_readiness( - PendingHostPluginReadiness { - host: CodingAgent::Codex, - state_path: state_path.clone(), - receiver, - }, + let report = crate::agents::receive_integration_readiness_for_test( + CodingAgent::Codex, + state_path.clone(), + receiver, + Path::new("/tmp/nemo-relay"), Duration::ZERO, ); From ea01cc7daa48de6d08911cacde3e289b4743ddef Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:54:17 -0400 Subject: [PATCH 079/102] refactor(cli): make installation agent neutral Signed-off-by: Will Killian --- crates/cli/src/agents/codex/host.rs | 6 +- crates/cli/src/agents/mod.rs | 8 +- crates/cli/src/agents/shared/host.rs | 89 +------------------ .../src/installation/marketplace/assets.rs | 8 -- .../cli/src/installation/marketplace/host.rs | 4 +- .../cli/src/installation/marketplace/mod.rs | 2 - .../cli/src/installation/marketplace/setup.rs | 2 +- crates/cli/src/process/mod.rs | 80 +++++++++++++++++ .../coverage/agents/plugin_install_tests.rs | 2 +- 9 files changed, 92 insertions(+), 109 deletions(-) diff --git a/crates/cli/src/agents/codex/host.rs b/crates/cli/src/agents/codex/host.rs index ba65ece8b..8231c4f15 100644 --- a/crates/cli/src/agents/codex/host.rs +++ b/crates/cli/src/agents/codex/host.rs @@ -20,13 +20,13 @@ use crate::hooks::merge_hooks; use super::app_server::{CodexAppServerClient, CodexHookMetadata, CodexHooksClient}; use crate::agents::shared::host::{ - atomic_write, atomic_write_private, current_exe, ensure_table, home_dir, - portable_executable_path, read_json_object, shell_quote, shell_quote_arg_for_platform, - write_json, + atomic_write, atomic_write_private, current_exe, ensure_table, home_dir, read_json_object, + shell_quote, write_json, }; use crate::filesystem::{ FileSnapshot, backup, backup_path, remove_backup, restore_file_snapshot, snapshot_optional_file, }; +use crate::process::{portable_executable_path, shell_quote_arg_for_platform}; pub(crate) const CODEX_PLUGIN_ID: &str = RELAY_PLUGIN_ID; pub(crate) const CODEX_PLUGIN_HOOK_KEY_PREFIX: &str = diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 2589ed507..908c9eb0a 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -717,12 +717,12 @@ fn failed_integration_readiness( readiness } +pub(crate) use crate::process::portable_executable_path; +pub(crate) use crate::process::shell_quote_arg_for_platform; +#[cfg(test)] +pub(crate) use crate::process::strip_windows_verbatim_prefix; pub(crate) use claude::host::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; pub(crate) use codex::host::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; -pub(crate) use shared::host::portable_executable_path; -pub(crate) use shared::host::shell_quote_arg_for_platform; -#[cfg(test)] -pub(crate) use shared::host::strip_windows_verbatim_prefix; use std::path::{Path, PathBuf}; diff --git a/crates/cli/src/agents/shared/host.rs b/crates/cli/src/agents/shared/host.rs index dd9e8f586..23176b891 100644 --- a/crates/cli/src/agents/shared/host.rs +++ b/crates/cli/src/agents/shared/host.rs @@ -19,54 +19,7 @@ pub(crate) fn shell_quote(path: &Path) -> String { } pub(crate) fn shell_quote_for_platform(path: &Path, windows: bool) -> String { - shell_quote_arg_for_platform(&path.display().to_string(), windows) -} - -pub(crate) fn shell_quote_arg_for_platform(raw: &str, windows: bool) -> String { - if windows { - return cmd_quote_arg(raw); - } - posix_quote_arg(raw) -} - -fn posix_quote_arg(raw: &str) -> String { - if raw.is_empty() { - "''".into() - } else if raw - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | ':' | '.' | '_' | '-')) - { - raw.to_string() - } else { - format!("'{}'", raw.replace('\'', "'\\''")) - } -} - -fn cmd_quote_arg(raw: &str) -> String { - if raw.is_empty() { - "\"\"".into() - } else if raw.chars().all(|ch| { - ch.is_ascii_alphanumeric() - || matches!(ch, '/' | '\\' | ':' | '.' | '_' | '-' | '=' | '@' | '+') - }) { - raw.to_string() - } else { - let mut escaped = String::new(); - for ch in raw.chars() { - match ch { - // cmd expands percent variables even inside quotes. Insert a zero-length - // substring expansion before the literal percent, matching Rust's hardened - // batch-file encoder, so values such as `%USERPROFILE%` remain literal. - '%' => escaped.push_str("%%cd:~,%"), - // Double quotes are represented by a paired quote inside a quoted cmd token. - '"' => escaped.push_str("\"\""), - _ => escaped.push(ch), - } - } - // cmd metacharacters such as &, |, <, >, and ^ are literal inside this quote pair. A - // caret inside the quotes would become part of the argument, so do not add one. - format!("\"{escaped}\"") - } + crate::process::shell_quote_arg_for_platform(&path.display().to_string(), windows) } pub(crate) fn ensure_table<'a>(doc: &'a mut DocumentMut, name: &str) -> &'a mut Table { @@ -112,43 +65,3 @@ pub(crate) fn print_check(label: &str, ok: bool) -> bool { pub(crate) fn print_info(label: &str, message: &str) { println!("info {label}: {message}"); } - -#[cfg(windows)] -pub(crate) fn portable_executable_path(path: PathBuf) -> PathBuf { - use std::ffi::OsString; - use std::os::windows::ffi::{OsStrExt, OsStringExt}; - - let encoded = path.as_os_str().encode_wide().collect::>(); - strip_windows_verbatim_prefix(&encoded) - .map(|value| OsString::from_wide(&value)) - .map(PathBuf::from) - .unwrap_or(path) -} - -#[cfg(not(windows))] -pub(crate) fn portable_executable_path(path: PathBuf) -> PathBuf { - path -} - -#[cfg(any(test, windows))] -pub(crate) fn strip_windows_verbatim_prefix(encoded: &[u16]) -> Option> { - const VERBATIM_PREFIX: &[u16] = &[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16]; - const VERBATIM_UNC_PREFIX: &[u16] = &[ - b'\\' as u16, - b'\\' as u16, - b'?' as u16, - b'\\' as u16, - b'U' as u16, - b'N' as u16, - b'C' as u16, - b'\\' as u16, - ]; - - if let Some(rest) = encoded.strip_prefix(VERBATIM_UNC_PREFIX) { - let mut normalized = vec![b'\\' as u16, b'\\' as u16]; - normalized.extend_from_slice(rest); - Some(normalized) - } else { - encoded.strip_prefix(VERBATIM_PREFIX).map(ToOwned::to_owned) - } -} diff --git a/crates/cli/src/installation/marketplace/assets.rs b/crates/cli/src/installation/marketplace/assets.rs index 1565ca4f9..c93e8eed2 100644 --- a/crates/cli/src/installation/marketplace/assets.rs +++ b/crates/cli/src/installation/marketplace/assets.rs @@ -115,11 +115,3 @@ pub(super) fn plugin_hooks( let generation_fence = absolute_or_self(generation_fence); host.plugin_hooks(relay, &generation_fence, generation_token) } - -#[cfg(test)] -pub(super) fn plugin_mcp_env_vars_from( - environment: impl IntoIterator, - config: Option<&Value>, -) -> Vec { - crate::agents::codex_mcp_env_vars_from(environment, config) -} diff --git a/crates/cli/src/installation/marketplace/host.rs b/crates/cli/src/installation/marketplace/host.rs index 26ac6db61..8ce2595f8 100644 --- a/crates/cli/src/installation/marketplace/host.rs +++ b/crates/cli/src/installation/marketplace/host.rs @@ -268,7 +268,7 @@ pub(crate) fn require_relay( runner .current_executable() .map(|path| path.canonicalize().unwrap_or(path)) - .map(crate::agents::portable_executable_path) + .map(crate::process::portable_executable_path) } pub(crate) fn validate_relay_hook_forward( @@ -414,7 +414,7 @@ pub(super) fn format_command(program: &str, args: &[String]) -> String { "$ {}", parts .iter() - .map(|part| { crate::agents::shell_quote_arg_for_platform(part, cfg!(windows)) }) + .map(|part| crate::process::shell_quote_arg_for_platform(part, cfg!(windows))) .collect::>() .join(" ") ) diff --git a/crates/cli/src/installation/marketplace/mod.rs b/crates/cli/src/installation/marketplace/mod.rs index cb6a73c92..38d3daa44 100644 --- a/crates/cli/src/installation/marketplace/mod.rs +++ b/crates/cli/src/installation/marketplace/mod.rs @@ -1972,8 +1972,6 @@ fn run_host_unregistration( Ok(()) } -#[cfg(test)] -use assets::*; #[cfg(test)] use state::*; diff --git a/crates/cli/src/installation/marketplace/setup.rs b/crates/cli/src/installation/marketplace/setup.rs index 723bb318e..71dcee1c1 100644 --- a/crates/cli/src/installation/marketplace/setup.rs +++ b/crates/cli/src/installation/marketplace/setup.rs @@ -165,7 +165,7 @@ impl PluginSetupRunner for HostPluginSetupRunner { } fn refresh_gateway(&self) -> Result<(), String> { - crate::agents::stop_plugin_gateway() + crate::bootstrap::state::stop_owned_and_reset(crate::bootstrap::DEFAULT_URL) } fn setup(&self, _host_arg: &str, gateway_url: &str, plugin_root: &Path) -> Result<(), String> { diff --git a/crates/cli/src/process/mod.rs b/crates/cli/src/process/mod.rs index 228fe5f34..4fb6f7d7c 100644 --- a/crates/cli/src/process/mod.rs +++ b/crates/cli/src/process/mod.rs @@ -18,6 +18,86 @@ use std::process::Command; use crate::agents::CodingAgent; +pub(crate) fn shell_quote_arg_for_platform(raw: &str, windows: bool) -> String { + if windows { + return cmd_quote_arg(raw); + } + posix_quote_arg(raw) +} + +fn posix_quote_arg(raw: &str) -> String { + if raw.is_empty() { + "''".into() + } else if raw + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | ':' | '.' | '_' | '-')) + { + raw.to_string() + } else { + format!("'{}'", raw.replace('\'', "'\\''")) + } +} + +fn cmd_quote_arg(raw: &str) -> String { + if raw.is_empty() { + return "\"\"".into(); + } + if raw.chars().all(|ch| { + ch.is_ascii_alphanumeric() + || matches!(ch, '/' | '\\' | ':' | '.' | '_' | '-' | '=' | '@' | '+') + }) { + return raw.to_string(); + } + let mut escaped = String::new(); + for ch in raw.chars() { + match ch { + '%' => escaped.push_str("%%cd:~,%"), + '"' => escaped.push_str("\"\""), + _ => escaped.push(ch), + } + } + format!("\"{escaped}\"") +} + +#[cfg(windows)] +pub(crate) fn portable_executable_path(path: PathBuf) -> PathBuf { + use std::ffi::OsString; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + + let encoded = path.as_os_str().encode_wide().collect::>(); + strip_windows_verbatim_prefix(&encoded) + .map(|value| OsString::from_wide(&value)) + .map(PathBuf::from) + .unwrap_or(path) +} + +#[cfg(not(windows))] +pub(crate) fn portable_executable_path(path: PathBuf) -> PathBuf { + path +} + +#[cfg(any(test, windows))] +pub(crate) fn strip_windows_verbatim_prefix(encoded: &[u16]) -> Option> { + const PREFIX: &[u16] = &[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16]; + const UNC: &[u16] = &[ + b'\\' as u16, + b'\\' as u16, + b'?' as u16, + b'\\' as u16, + b'U' as u16, + b'N' as u16, + b'C' as u16, + b'\\' as u16, + ]; + if let Some(rest) = encoded.strip_prefix(UNC) { + let mut normalized = vec![b'\\' as u16, b'\\' as u16]; + normalized.extend_from_slice(rest); + Some(normalized) + } else { + encoded.strip_prefix(PREFIX).map(ToOwned::to_owned) + } +} + /// Parses the intentionally simple command strings accepted by `[agents.*].command`. /// /// Complex shell expressions belong after `nemo-relay run --`; configuration values are argv diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index d7b779226..6719cc47d 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -1444,7 +1444,7 @@ fn codex_mcp_env_vars_include_approved_dynamic_and_config_references_only() { } }] }); - let names = plugin_mcp_env_vars_from( + let names = crate::agents::codex_mcp_env_vars_from( [ "NEMO_RELAY_CUSTOM_SETTING", "OTEL_CUSTOM_SETTING", From 601535cb1fc810fc7fbf1712a09fea7b9cd41c46 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:55:30 -0400 Subject: [PATCH 080/102] refactor(cli): move setup model into configure command Signed-off-by: Will Killian --- crates/cli/src/commands/configure/mod.rs | 3 ++- .../{configuration/setup.rs => commands/configure/model.rs} | 2 +- crates/cli/src/commands/configure/wizard.rs | 2 +- crates/cli/src/configuration/mod.rs | 1 - 4 files changed, 4 insertions(+), 4 deletions(-) rename crates/cli/src/{configuration/setup.rs => commands/configure/model.rs} (99%) diff --git a/crates/cli/src/commands/configure/mod.rs b/crates/cli/src/commands/configure/mod.rs index ca1e8c5d1..b281d91de 100644 --- a/crates/cli/src/commands/configure/mod.rs +++ b/crates/cli/src/commands/configure/mod.rs @@ -8,6 +8,7 @@ use clap::Args; use super::root::AgentArg; use crate::error::CliError; +mod model; mod wizard; pub(super) use wizard::run; @@ -25,7 +26,7 @@ pub(crate) struct ConfigCommand { pub(super) async fn execute(command: ConfigCommand) -> Result { let agent = command.agent.map(Into::into); if command.reset { - crate::configuration::setup::reset(agent)?; + model::reset(agent)?; } else { wizard::run(agent).await?; } diff --git a/crates/cli/src/configuration/setup.rs b/crates/cli/src/commands/configure/model.rs similarity index 99% rename from crates/cli/src/configuration/setup.rs rename to crates/cli/src/commands/configure/model.rs index ef4bce99f..03eeac344 100644 --- a/crates/cli/src/configuration/setup.rs +++ b/crates/cli/src/commands/configure/model.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Testable setup configuration model and file helpers. +//! Testable setup configuration model and file helpers owned by the configure command. use std::path::{Path, PathBuf}; diff --git a/crates/cli/src/commands/configure/wizard.rs b/crates/cli/src/commands/configure/wizard.rs index c91127072..b0b06c21b 100644 --- a/crates/cli/src/commands/configure/wizard.rs +++ b/crates/cli/src/commands/configure/wizard.rs @@ -23,8 +23,8 @@ use self::model::{ home_dir, plugins_edit_command_for_scope, plugins_resume_command, preview_paths, read_existing_defaults, save_config, }; +use super::model; use crate::agents::CodingAgent; -use crate::configuration::setup as model; use crate::error::CliError; #[cfg(test)] diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs index cb561a567..9c442bceb 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -pub(crate) mod setup; mod types; pub(crate) use types::*; From 3fa1aabbb89adfbd07d8e7a63ebe70f2d4ef06ee Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:56:57 -0400 Subject: [PATCH 081/102] refactor(cli): move hook diagnosis into agents Signed-off-by: Will Killian --- crates/cli/src/agents/claude/doctor.rs | 6 ++++++ crates/cli/src/agents/claude/mod.rs | 1 + crates/cli/src/agents/codex/doctor.rs | 6 ++++++ crates/cli/src/agents/codex/mod.rs | 1 + crates/cli/src/agents/hermes/doctor.rs | 13 +++++++++++++ crates/cli/src/agents/hermes/mod.rs | 1 + crates/cli/src/agents/mod.rs | 16 ++++++++++++++++ crates/cli/src/diagnostics/mod.rs | 25 ++++--------------------- 8 files changed, 48 insertions(+), 21 deletions(-) create mode 100644 crates/cli/src/agents/claude/doctor.rs create mode 100644 crates/cli/src/agents/codex/doctor.rs create mode 100644 crates/cli/src/agents/hermes/doctor.rs diff --git a/crates/cli/src/agents/claude/doctor.rs b/crates/cli/src/agents/claude/doctor.rs new file mode 100644 index 000000000..f398a62c0 --- /dev/null +++ b/crates/cli/src/agents/claude/doctor.rs @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +pub(crate) fn hook_status() -> Result { + Ok("hooks: injected during run".into()) +} diff --git a/crates/cli/src/agents/claude/mod.rs b/crates/cli/src/agents/claude/mod.rs index 2c98250a1..b08ab9b11 100644 --- a/crates/cli/src/agents/claude/mod.rs +++ b/crates/cli/src/agents/claude/mod.rs @@ -6,6 +6,7 @@ use semver::Version; use super::AgentDescriptor; pub(super) mod assets; +pub(crate) mod doctor; pub(super) mod host; pub(crate) mod install; pub(crate) mod launch; diff --git a/crates/cli/src/agents/codex/doctor.rs b/crates/cli/src/agents/codex/doctor.rs new file mode 100644 index 000000000..f398a62c0 --- /dev/null +++ b/crates/cli/src/agents/codex/doctor.rs @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +pub(crate) fn hook_status() -> Result { + Ok("hooks: injected during run".into()) +} diff --git a/crates/cli/src/agents/codex/mod.rs b/crates/cli/src/agents/codex/mod.rs index 903434d41..c3db8c5a5 100644 --- a/crates/cli/src/agents/codex/mod.rs +++ b/crates/cli/src/agents/codex/mod.rs @@ -7,6 +7,7 @@ use super::AgentDescriptor; pub(super) mod app_server; pub(super) mod assets; +pub(crate) mod doctor; pub(super) mod host; pub(crate) mod install; pub(crate) mod launch; diff --git a/crates/cli/src/agents/hermes/doctor.rs b/crates/cli/src/agents/hermes/doctor.rs new file mode 100644 index 000000000..0f8b19509 --- /dev/null +++ b/crates/cli/src/agents/hermes/doctor.rs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::Path; + +pub(crate) fn hook_status(hooks_path: Option<&Path>) -> Result { + match hooks_path { + Some(path) => super::diagnose_persistent(path).map_err(|error| { + format!("persistent MCP/hooks: {error}; run `nemo-relay install hermes --force`") + }), + None => Ok("hooks: injected through an isolated HERMES_HOME during run".into()), + } +} diff --git a/crates/cli/src/agents/hermes/mod.rs b/crates/cli/src/agents/hermes/mod.rs index 8aeaf87f2..3b6577dc7 100644 --- a/crates/cli/src/agents/hermes/mod.rs +++ b/crates/cli/src/agents/hermes/mod.rs @@ -41,6 +41,7 @@ pub(super) fn parse_version(raw: &str) -> Option { } mod config; +pub(crate) mod doctor; mod files; pub(crate) mod install; mod integration; diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 908c9eb0a..dae0f1591 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -396,6 +396,22 @@ pub(crate) fn prepare_launch( } } +pub(crate) fn configured(agent: CodingAgent, configs: &crate::configuration::AgentConfigs) -> bool { + configs.get(agent).command.is_some() + || matches!(agent, CodingAgent::Hermes) && configs.hermes.hooks_path.is_some() +} + +pub(crate) fn hook_status( + agent: CodingAgent, + configs: &crate::configuration::AgentConfigs, +) -> Result { + match agent { + CodingAgent::Codex => codex::doctor::hook_status(), + CodingAgent::ClaudeCode => claude::doctor::hook_status(), + CodingAgent::Hermes => hermes::doctor::hook_status(configs.hermes.hooks_path.as_deref()), + } +} + pub(crate) enum SetupSnapshot { Codex(CodexSetupSnapshot), Claude(ClaudeSetupSnapshot), diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index b4d6ab169..995ce14bc 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -388,8 +388,7 @@ fn configured_agent_command(agent: CodingAgent, agents: &AgentConfigs) -> Option } fn agent_configured(agent: CodingAgent, agents: &AgentConfigs) -> bool { - configured_agent_command(agent, agents).is_some() - || (matches!(agent, CodingAgent::Hermes) && agents.hermes.hooks_path.is_some()) + crate::agents::configured(agent, agents) } fn configured_agent_names(agents: &AgentConfigs) -> Vec { @@ -419,25 +418,9 @@ fn combine_status(base: Status, hook: Status, readiness_required: bool) -> Statu } fn hook_status(agent: CodingAgent, agents: &AgentConfigs) -> (Status, String) { - match agent { - CodingAgent::ClaudeCode | CodingAgent::Codex => { - (Status::Pass, "hooks: injected during run".into()) - } - CodingAgent::Hermes => match agents.hermes.hooks_path.as_deref() { - Some(path) => match crate::agents::hermes::diagnose_persistent(path) { - Ok(details) => (Status::Pass, details), - Err(error) => ( - Status::Fail, - format!( - "persistent MCP/hooks: {error}; run `nemo-relay install hermes --force`" - ), - ), - }, - None => ( - Status::Pass, - "hooks: injected through an isolated HERMES_HOME during run".into(), - ), - }, + match crate::agents::hook_status(agent, agents) { + Ok(details) => (Status::Pass, details), + Err(details) => (Status::Fail, details), } } From 072d4d5c80168bfd3d55ec0547084143e335c46f Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:58:06 -0400 Subject: [PATCH 082/102] test(cli): enforce runtime architecture boundaries Signed-off-by: Will Killian --- crates/cli/src/agents/mod.rs | 13 ++++- crates/cli/src/configuration/types.rs | 11 ---- crates/cli/src/diagnostics/mod.rs | 2 +- crates/cli/src/process/launcher.rs | 2 +- crates/cli/tests/architecture_tests.rs | 78 ++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 14 deletions(-) diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index dae0f1591..f8a769ee8 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -397,10 +397,21 @@ pub(crate) fn prepare_launch( } pub(crate) fn configured(agent: CodingAgent, configs: &crate::configuration::AgentConfigs) -> bool { - configs.get(agent).command.is_some() + config(agent, configs).command.is_some() || matches!(agent, CodingAgent::Hermes) && configs.hermes.hooks_path.is_some() } +pub(crate) const fn config( + agent: CodingAgent, + configs: &crate::configuration::AgentConfigs, +) -> &crate::configuration::AgentCommandConfig { + match agent { + CodingAgent::ClaudeCode => &configs.claude, + CodingAgent::Codex => &configs.codex, + CodingAgent::Hermes => &configs.hermes, + } +} + pub(crate) fn hook_status( agent: CodingAgent, configs: &crate::configuration::AgentConfigs, diff --git a/crates/cli/src/configuration/types.rs b/crates/cli/src/configuration/types.rs index 39a23c07b..615ef1875 100644 --- a/crates/cli/src/configuration/types.rs +++ b/crates/cli/src/configuration/types.rs @@ -11,7 +11,6 @@ use serde::Serialize; use serde_json::{Map, Value}; use strum::{Display, IntoStaticStr}; -use crate::agents::CodingAgent; use crate::plugins::policy::DynamicPluginHostPolicy; use super::{ @@ -104,16 +103,6 @@ pub(crate) struct AgentCommandConfig { pub(crate) hooks_path: Option, } -impl AgentConfigs { - pub(crate) const fn get(&self, agent: CodingAgent) -> &AgentCommandConfig { - match agent { - CodingAgent::ClaudeCode => &self.claude, - CodingAgent::Codex => &self.codex, - CodingAgent::Hermes => &self.hermes, - } - } -} - impl Default for GatewayConfig { fn default() -> Self { Self { diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index 995ce14bc..86073d530 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -384,7 +384,7 @@ fn agent_command(agent: CodingAgent, agents: &AgentConfigs) -> String { } fn configured_agent_command(agent: CodingAgent, agents: &AgentConfigs) -> Option<&String> { - agents.get(agent).command.as_ref() + crate::agents::config(agent, agents).command.as_ref() } fn agent_configured(agent: CodingAgent, agents: &AgentConfigs) -> bool { diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index 9fe7a360d..4efc53a9e 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -303,7 +303,7 @@ async fn validate_agent_version(agent: CodingAgent, probe: &[String]) -> Result< // whitespace splitting because config command values are a convenience fallback; complex shell // commands should be passed after `--` by the caller. fn configured_command(agent: CodingAgent, agents: &AgentConfigs) -> Option> { - let command = agents.get(agent).command.as_ref()?; + let command = crate::agents::config(agent, agents).command.as_ref()?; let argv = crate::process::command_argv(command); (!argv.is_empty()).then_some(argv) } diff --git a/crates/cli/tests/architecture_tests.rs b/crates/cli/tests/architecture_tests.rs index 06534a890..86328be68 100644 --- a/crates/cli/tests/architecture_tests.rs +++ b/crates/cli/tests/architecture_tests.rs @@ -122,3 +122,81 @@ fn agent_directories_do_not_import_one_another_or_commands() { } } } + +#[test] +fn retired_horizontal_and_monolithic_modules_do_not_return() { + let src = source_root(); + for path in [ + "agents/install", + "agents/host.rs", + "agents/adapters.rs", + "agents/alignment.rs", + "commands/arguments.rs", + "configuration/setup.rs", + ] { + assert!(!src.join(path).exists(), "retired module returned: {path}"); + } +} + +#[test] +fn shared_installation_is_agent_neutral() { + let installation = source_root().join("installation"); + for path in rust_files(&installation) { + let source = fs::read_to_string(&path).unwrap(); + for marker in ["crate::agents", "CodingAgent", "IntegrationHost"] { + assert!( + !source.contains(marker), + "{} contains host-selection marker {marker}", + path.display() + ); + } + } +} + +#[test] +fn all_target_is_command_only() { + let src = source_root(); + for path in rust_files(&src) { + if path.starts_with(src.join("commands")) { + continue; + } + let source = fs::read_to_string(&path).unwrap(); + for marker in ["IntegrationHost", "InstallTarget", "CodingAgent::All"] { + assert!( + !source.contains(marker), + "{} contains command target marker {marker}", + path.display() + ); + } + } +} + +#[test] +fn shared_runtime_subsystems_do_not_dispatch_host_variants() { + let src = source_root(); + for subsystem in [ + "installation", + "process", + "configuration", + "diagnostics", + "gateway", + "sessions", + "hooks", + "filesystem", + ] { + for path in rust_files(&src.join(subsystem)) { + let source = fs::read_to_string(&path).unwrap(); + for marker in [ + "CodingAgent::Codex", + "CodingAgent::ClaudeCode", + "CodingAgent::Hermes", + ] { + assert!( + !source.contains(marker), + "{} dispatches host variant {marker}", + path.display() + ); + } + } + } +} From 5dadcce107416ca63c7a5b7a79ada7061460fa0d Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 12 Jul 2026 23:59:33 -0400 Subject: [PATCH 083/102] refactor(cli): normalize plugin service modules Signed-off-by: Will Killian --- crates/cli/src/plugins/{lifecycle.rs => lifecycle/mod.rs} | 2 +- crates/cli/src/plugins/{schema.rs => schema/mod.rs} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename crates/cli/src/plugins/{lifecycle.rs => lifecycle/mod.rs} (99%) rename crates/cli/src/plugins/{schema.rs => schema/mod.rs} (99%) diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle/mod.rs similarity index 99% rename from crates/cli/src/plugins/lifecycle.rs rename to crates/cli/src/plugins/lifecycle/mod.rs index 259aca492..8fcef7e89 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle/mod.rs @@ -2248,5 +2248,5 @@ fn required_startup_failure( } #[cfg(test)] -#[path = "../../tests/coverage/shared/plugins_lifecycle_tests.rs"] +#[path = "../../../tests/coverage/shared/plugins_lifecycle_tests.rs"] mod tests; diff --git a/crates/cli/src/plugins/schema.rs b/crates/cli/src/plugins/schema/mod.rs similarity index 99% rename from crates/cli/src/plugins/schema.rs rename to crates/cli/src/plugins/schema/mod.rs index 5e52f39d7..16cbc3e49 100644 --- a/crates/cli/src/plugins/schema.rs +++ b/crates/cli/src/plugins/schema/mod.rs @@ -1310,5 +1310,5 @@ fn find_anchor<'a>(schema: &'a Value, anchor: &str) -> Option<&'a Value> { } #[cfg(test)] -#[path = "../../tests/coverage/shared/plugins_schema_tests.rs"] +#[path = "../../../tests/coverage/shared/plugins_schema_tests.rs"] mod tests; From cf3da2f7c518bb9c4d6d7d79080b03ae30ccccfc Mon Sep 17 00:00:00 2001 From: Will Killian Date: Mon, 13 Jul 2026 00:09:42 -0400 Subject: [PATCH 084/102] fix(cli): canonicalize persisted install paths Signed-off-by: Will Killian --- crates/cli/src/installation/generation.rs | 17 +++++++++-- .../cli/src/installation/marketplace/state.rs | 12 +++++++- .../coverage/agents/plugin_install_tests.rs | 24 ++++++++++++++++ .../shared/install_generation_tests.rs | 21 ++++++++++++++ scripts/test-codex-plugin-e2e.sh | 28 +++++++++++++------ 5 files changed, 90 insertions(+), 12 deletions(-) diff --git a/crates/cli/src/installation/generation.rs b/crates/cli/src/installation/generation.rs index 71aa6b3b4..7a096c135 100644 --- a/crates/cli/src/installation/generation.rs +++ b/crates/cli/src/installation/generation.rs @@ -399,7 +399,7 @@ impl GenerationRetirement { let lock_path = observed.lock_path().to_owned(); if let Some(allowed) = allowed_external_lock && !is_legacy_sibling_lock(path, &lock_path)? - && absolute_lock_path(allowed)? != absolute_lock_path(&lock_path)? + && !same_lock_path(allowed, &lock_path)? { return Err(format!( "MCP install generation {} references an external lock outside its plugin layout", @@ -828,7 +828,20 @@ fn generation_lock_path(path: &Path) -> PathBuf { } fn is_legacy_sibling_lock(marker_path: &Path, lock_path: &Path) -> Result { - Ok(absolute_lock_path(&generation_lock_path(marker_path))? == absolute_lock_path(lock_path)?) + same_lock_path(&generation_lock_path(marker_path), lock_path) +} + +fn same_lock_path(left: &Path, right: &Path) -> Result { + let left = absolute_lock_path(left)?; + let right = absolute_lock_path(right)?; + if left == right { + return Ok(true); + } + Ok(left + .canonicalize() + .ok() + .zip(right.canonicalize().ok()) + .is_some_and(|(left, right)| left == right)) } #[cfg(test)] diff --git a/crates/cli/src/installation/marketplace/state.rs b/crates/cli/src/installation/marketplace/state.rs index df3b2a257..af87e27db 100644 --- a/crates/cli/src/installation/marketplace/state.rs +++ b/crates/cli/src/installation/marketplace/state.rs @@ -88,7 +88,8 @@ impl PluginLayout { } pub(super) fn validate_persisted_state(&self, state: &PluginState) -> Result<(), String> { - if state.marketplace_root != self.marketplace_root || state.plugin_root != self.plugin_root + if !same_selected_path(&state.marketplace_root, &self.marketplace_root) + || !same_selected_path(&state.plugin_root, &self.plugin_root) { return Err(format!( "refusing persisted {} plugin state outside the selected install layout {}", @@ -126,6 +127,15 @@ impl PluginLayout { } } +fn same_selected_path(persisted: &Path, selected: &Path) -> bool { + persisted == selected + || persisted + .canonicalize() + .ok() + .zip(selected.canonicalize().ok()) + .is_some_and(|(persisted, selected)| persisted == selected) +} + #[derive(Debug, Clone)] pub(super) struct PluginState { pub(super) marketplace_root: PathBuf, diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index 6719cc47d..24a34e2b5 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -2796,6 +2796,30 @@ fn force_install_rejects_persisted_roots_outside_selected_layout() { assert_no_force_replacement_residue(&relocated_dir); } +#[cfg(unix)] +#[test] +fn persisted_roots_accept_an_equivalent_symlinked_install_path() { + use std::os::unix::fs::symlink; + + let dir = tempdir().unwrap(); + let canonical = dir.path().join("canonical"); + let selected = dir.path().join("selected"); + std::fs::create_dir_all(&canonical).unwrap(); + symlink(&canonical, &selected).unwrap(); + let selected_layout = PluginLayout::new(CodingAgent::Codex, &selected); + std::fs::create_dir_all(&selected_layout.plugin_root).unwrap(); + let canonical_layout = PluginLayout::new(CodingAgent::Codex, &canonical); + let state = PluginState { + marketplace_root: canonical_layout.marketplace_root, + plugin_root: canonical_layout.plugin_root, + host_plugin_removed: false, + host_marketplace_removed: false, + plugin_setup_installed: true, + }; + + selected_layout.validate_persisted_state(&state).unwrap(); +} + #[test] fn uninstall_rejects_persisted_roots_outside_selected_layout() { let dir = tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/shared/install_generation_tests.rs b/crates/cli/tests/coverage/shared/install_generation_tests.rs index 37de34a3e..0a6069503 100644 --- a/crates/cli/tests/coverage/shared/install_generation_tests.rs +++ b/crates/cli/tests/coverage/shared/install_generation_tests.rs @@ -25,6 +25,27 @@ fn plugin_retirement_rejects_an_external_lock_outside_its_layout() { assert!(unrelated_lock.exists()); } +#[cfg(unix)] +#[test] +fn plugin_retirement_accepts_an_equivalent_symlinked_external_lock_path() { + use std::os::unix::fs::symlink; + + let dir = tempdir().unwrap(); + let canonical = dir.path().join("canonical"); + let selected = dir.path().join("selected"); + std::fs::create_dir_all(&canonical).unwrap(); + symlink(&canonical, &selected).unwrap(); + let marker = selected.join("plugin").join(GENERATION_FILE_NAME); + let selected_lock = selected.join("generation.lock"); + let canonical_lock = canonical.join("generation.lock"); + write_new_generation_with_token_at(&marker, &canonical_lock).unwrap(); + + let mut retirement = GenerationRetirement::acquire_for_plugin(&marker, &selected_lock) + .unwrap() + .expect("generation exists"); + retirement.restore_after_rollback().unwrap(); +} + #[test] fn generation_markers_have_one_canonical_encoding() { let lock_path = PathBuf::from("generation.lock"); diff --git a/scripts/test-codex-plugin-e2e.sh b/scripts/test-codex-plugin-e2e.sh index df541e404..9a4e28fb5 100755 --- a/scripts/test-codex-plugin-e2e.sh +++ b/scripts/test-codex-plugin-e2e.sh @@ -32,6 +32,16 @@ if matches: PY } +read_sidecar_pid() { + python3 - "$1" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + print(json.load(source)["pid"]) +PY +} + cleanup() { codex_pgids=("") for pgid_file in "$work"/codex-*.pgid; do @@ -59,9 +69,9 @@ cleanup() { kill "$provider_pid" 2>/dev/null || true wait "$provider_pid" 2>/dev/null || true fi - pid_file="$(find_sidecar_file 'sidecar-*.pid')" + pid_file="$(find_sidecar_file 'sidecar-*.owner.json')" if [[ -n "$pid_file" && -f "$pid_file" ]]; then - sidecar_pid="$(cat "$pid_file" 2>/dev/null || true)" + sidecar_pid="$(read_sidecar_pid "$pid_file" 2>/dev/null || true)" if [[ "$sidecar_pid" =~ ^[0-9]+$ ]]; then kill "$sidecar_pid" 2>/dev/null || true for _ in $(seq 1 50); do @@ -508,9 +518,9 @@ if ! wait_for_mcp_initialize "$holder_stdout" "$holder_pid"; then cat "$holder_stderr" >&2 exit 1 fi -old_sidecar_pid_file="$(find_sidecar_file 'sidecar-*.pid')" +old_sidecar_pid_file="$(find_sidecar_file 'sidecar-*.owner.json')" [[ -s "$old_sidecar_pid_file" ]] -old_sidecar_pid="$(cat "$old_sidecar_pid_file")" +old_sidecar_pid="$(read_sidecar_pid "$old_sidecar_pid_file")" kill -0 "$old_sidecar_pid" exec 9>&- if ! wait_for_process_exit "$holder_pid"; then @@ -545,10 +555,10 @@ replacement_stdout="$work/mcp-replacement.stdout" replacement_stderr="$work/mcp-replacement.stderr" run_mcp_once "$replacement_stdout" "$replacement_stderr" 3 grep -q '"serverInfo"' "$replacement_stdout" -replacement_pid_file="$(find_sidecar_file 'sidecar-*.pid')" +replacement_pid_file="$(find_sidecar_file 'sidecar-*.owner.json')" replacement_owner_file="$(find_sidecar_file 'sidecar-*.owner.json')" [[ -s "$replacement_pid_file" && -s "$replacement_owner_file" ]] -replacement_pid="$(cat "$replacement_pid_file")" +replacement_pid="$(read_sidecar_pid "$replacement_pid_file")" [[ "$replacement_pid" != "$old_sidecar_pid" ]] kill -0 "$replacement_pid" stop_owned_sidecar "$replacement_owner_file" @@ -593,16 +603,16 @@ while time.monotonic() < deadline: raise SystemExit("concurrent Codex requests did not reach the provider within 25 seconds") PY [[ "$(cat "$provider_barrier/arrivals")" -eq 2 ]] -sidecar_pid_file="$(find_sidecar_file 'sidecar-*.pid')" +sidecar_pid_file="$(find_sidecar_file 'sidecar-*.owner.json')" [[ -s "$sidecar_pid_file" ]] -shared_sidecar_pid="$(cat "$sidecar_pid_file")" +shared_sidecar_pid="$(read_sidecar_pid "$sidecar_pid_file")" [[ "$shared_sidecar_pid" =~ ^[0-9]+$ ]] kill -0 "$shared_sidecar_pid" touch "$provider_barrier/release" wait "$first_pid" wait "$second_pid" background_pids=("") -[[ "$(cat "$sidecar_pid_file")" == "$shared_sidecar_pid" ]] +[[ "$(read_sidecar_pid "$sidecar_pid_file")" == "$shared_sidecar_pid" ]] kill -0 "$shared_sidecar_pid" python3 - <<'PY' import socket From 335b45259feb4b67d4fcf76845c6c5e6b837a9d9 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Mon, 13 Jul 2026 00:34:19 -0400 Subject: [PATCH 085/102] fix(cli): restore cross-platform test builds Signed-off-by: Will Killian --- crates/cli/src/filesystem/mod.rs | 4 +++- crates/cli/src/process/launcher.rs | 34 ------------------------------ crates/cli/tests/cli_tests.rs | 2 +- 3 files changed, 4 insertions(+), 36 deletions(-) diff --git a/crates/cli/src/filesystem/mod.rs b/crates/cli/src/filesystem/mod.rs index 4ee34fbdc..0ecf7604e 100644 --- a/crates/cli/src/filesystem/mod.rs +++ b/crates/cli/src/filesystem/mod.rs @@ -11,11 +11,13 @@ pub(crate) mod temp; #[cfg(test)] pub(crate) use atomic::fail_next_atomic_write; +#[cfg(all(test, windows))] +pub(crate) use atomic::windows_path_is_private; pub(crate) use atomic::{atomic_write, atomic_write_private, atomic_write_with_permissions}; #[cfg(windows)] pub(crate) use atomic::{ atomic_write_with_windows_dacl, open_private_windows_file, protect_private_windows_path, - read_windows_dacl, windows_path_is_private, + read_windows_dacl, }; pub(crate) use locks::{LockAttempt, try_lock_exclusive, try_lock_shared, unlock_file}; pub(crate) use snapshots::{ diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index 4efc53a9e..fdb3203ea 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -759,40 +759,6 @@ fn path_with_transparent_hook_dir() -> Option { // it here prevents a prompt token named `codex` or `claude` from becoming an accidental insertion // target while preserving configured wrapper prefixes. -// Creates a per-process Hermes home whose user state points at the original profile while the -// config and hook approval files remain private to this transparent run. Hermes has no standalone -// config-file override, so `HERMES_HOME` is its supported process-scoped configuration boundary. - -#[cfg(windows)] -fn create_windows_junction(source: &Path, destination: &Path) -> Result<(), CliError> { - use std::os::windows::process::CommandExt; - - // Directory junctions do not require Developer Mode or SeCreateSymbolicLinkPrivilege. Paths - // travel through environment variables so the fixed cmd program never interpolates user - // content into shell syntax; delayed expansion is disabled for literal exclamation marks. - let mut command = std::process::Command::new( - std::env::var_os("COMSPEC").unwrap_or_else(|| std::ffi::OsString::from("cmd.exe")), - ); - command.args(["/d", "/e:on", "/v:off", "/s", "/c"]); - // `cmd.exe` parses the command after `/c` itself rather than with the Windows CRT rules used - // by `Command::arg`. The outer quote pair is required so the inner path quotes survive `/s`. - command - .raw_arg(r#""mklink /J "%NEMO_RELAY_JUNCTION_DEST%" "%NEMO_RELAY_JUNCTION_SOURCE%" >nul""#); - let status = command - .env("NEMO_RELAY_JUNCTION_SOURCE", source) - .env("NEMO_RELAY_JUNCTION_DEST", destination) - .status()?; - if status.success() { - Ok(()) - } else { - Err(CliError::Launch(format!( - "failed to create Hermes state junction {} -> {}: {status}", - destination.display(), - source.display() - ))) - } -} - // Chooses the Hermes config used as the source for a transparent-run overlay. If setup recorded a // specific path, reuse it; otherwise fall back to the active Hermes home. diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index fe2a88a93..bc5c01554 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -2905,7 +2905,7 @@ delay_after_continue = False def handle_continue(_signal, _frame): if delay_after_continue: - print("AGENT_BG_DELAY", flush=True) + os.write(sys.stdout.fileno(), b"AGENT_BG_DELAY\n") time.sleep(1.5) signal.signal(signal.SIGCONT, handle_continue) From 37ae6058bd0c94e88ab8fe6edc64ac2f8390b934 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Mon, 13 Jul 2026 00:48:18 -0400 Subject: [PATCH 086/102] test(cli): expose platform helpers to coverage modules Signed-off-by: Will Killian --- crates/cli/src/filesystem/atomic.rs | 2 +- crates/cli/src/filesystem/locks.rs | 2 +- crates/cli/src/filesystem/mod.rs | 6 ++++-- crates/cli/src/hooks/mod.rs | 2 ++ crates/cli/tests/coverage/shared/config_tests.rs | 6 +++--- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/cli/src/filesystem/atomic.rs b/crates/cli/src/filesystem/atomic.rs index 440fe7173..ca172168b 100644 --- a/crates/cli/src/filesystem/atomic.rs +++ b/crates/cli/src/filesystem/atomic.rs @@ -475,7 +475,7 @@ fn open_windows_file( } #[cfg(windows)] -fn windows_wide(value: impl AsRef) -> Vec { +pub(super) fn windows_wide(value: impl AsRef) -> Vec { use std::os::windows::ffi::OsStrExt; value.as_ref().encode_wide().chain(Some(0)).collect() diff --git a/crates/cli/src/filesystem/locks.rs b/crates/cli/src/filesystem/locks.rs index 96fd0f672..922e2dbc4 100644 --- a/crates/cli/src/filesystem/locks.rs +++ b/crates/cli/src/filesystem/locks.rs @@ -30,7 +30,7 @@ pub(crate) fn unlock_file(file: &File) -> io::Result<()> { FileExt::unlock(file) } -fn normalize_lock_attempt(result: io::Result<()>) -> io::Result { +pub(super) fn normalize_lock_attempt(result: io::Result<()>) -> io::Result { match result { Ok(()) => Ok(LockAttempt::Acquired), Err(error) if lock_is_contended(&error) => Ok(LockAttempt::Contended), diff --git a/crates/cli/src/filesystem/mod.rs b/crates/cli/src/filesystem/mod.rs index 0ecf7604e..808c1796d 100644 --- a/crates/cli/src/filesystem/mod.rs +++ b/crates/cli/src/filesystem/mod.rs @@ -11,14 +11,16 @@ pub(crate) mod temp; #[cfg(test)] pub(crate) use atomic::fail_next_atomic_write; -#[cfg(all(test, windows))] -pub(crate) use atomic::windows_path_is_private; pub(crate) use atomic::{atomic_write, atomic_write_private, atomic_write_with_permissions}; #[cfg(windows)] pub(crate) use atomic::{ atomic_write_with_windows_dacl, open_private_windows_file, protect_private_windows_path, read_windows_dacl, }; +#[cfg(all(test, windows))] +pub(crate) use atomic::{windows_path_is_private, windows_wide}; +#[cfg(all(test, windows))] +pub(crate) use locks::normalize_lock_attempt; pub(crate) use locks::{LockAttempt, try_lock_exclusive, try_lock_shared, unlock_file}; pub(crate) use snapshots::{ FileSnapshot, backup, backup_path, remove_backup, restore_file_snapshot, snapshot_optional_file, diff --git a/crates/cli/src/hooks/mod.rs b/crates/cli/src/hooks/mod.rs index ec6625602..33653fa48 100644 --- a/crates/cli/src/hooks/mod.rs +++ b/crates/cli/src/hooks/mod.rs @@ -19,6 +19,8 @@ pub(crate) use delivery::{gateway_headers, insert_header, read_hook_payload_from pub(crate) use destination::{ HookGatewayLifecycle, resolve_hook_destination, transparent_gateway_spec, }; +#[cfg(all(test, windows))] +pub(crate) use encoding::windows_powershell_path; #[cfg(test)] pub(crate) use encoding::{ decode_windows_hook_command, encoded_windows_hook_command, event_matches_tools, diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index 1aa63f574..ce9d28453 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -3,9 +3,9 @@ use super::*; use crate::agents::CodingAgent; -use crate::filesystem::bounded::{ - MAX_BOUNDED_FILE_BYTES as MAX_BOOTSTRAP_IDENTITY_FILE_BYTES, read_bounded_regular_file, -}; +use crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES as MAX_BOOTSTRAP_IDENTITY_FILE_BYTES; +#[cfg(unix)] +use crate::filesystem::bounded::read_bounded_regular_file; use crate::hooks::GatewayMode; use axum::http::HeaderValue; use base64::Engine; From 0d4a7435f3ef76fed56137c6446317746f85b172 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Mon, 13 Jul 2026 00:59:55 -0400 Subject: [PATCH 087/102] test(cli): widen helper visibility within crate Signed-off-by: Will Killian --- crates/cli/src/filesystem/atomic.rs | 2 +- crates/cli/src/filesystem/locks.rs | 2 +- crates/cli/src/hooks/encoding.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/filesystem/atomic.rs b/crates/cli/src/filesystem/atomic.rs index ca172168b..89c9abfca 100644 --- a/crates/cli/src/filesystem/atomic.rs +++ b/crates/cli/src/filesystem/atomic.rs @@ -475,7 +475,7 @@ fn open_windows_file( } #[cfg(windows)] -pub(super) fn windows_wide(value: impl AsRef) -> Vec { +pub(crate) fn windows_wide(value: impl AsRef) -> Vec { use std::os::windows::ffi::OsStrExt; value.as_ref().encode_wide().chain(Some(0)).collect() diff --git a/crates/cli/src/filesystem/locks.rs b/crates/cli/src/filesystem/locks.rs index 922e2dbc4..21c9eabf3 100644 --- a/crates/cli/src/filesystem/locks.rs +++ b/crates/cli/src/filesystem/locks.rs @@ -30,7 +30,7 @@ pub(crate) fn unlock_file(file: &File) -> io::Result<()> { FileExt::unlock(file) } -pub(super) fn normalize_lock_attempt(result: io::Result<()>) -> io::Result { +pub(crate) fn normalize_lock_attempt(result: io::Result<()>) -> io::Result { match result { Ok(()) => Ok(LockAttempt::Acquired), Err(error) if lock_is_contended(&error) => Ok(LockAttempt::Contended), diff --git a/crates/cli/src/hooks/encoding.rs b/crates/cli/src/hooks/encoding.rs index a62e299b3..e89bf83ca 100644 --- a/crates/cli/src/hooks/encoding.rs +++ b/crates/cli/src/hooks/encoding.rs @@ -186,7 +186,7 @@ pub(super) fn windows_powershell_launcher() -> Result { } #[cfg(windows)] -pub(super) fn windows_powershell_path() -> Result { +pub(crate) fn windows_powershell_path() -> Result { use std::os::windows::ffi::OsStringExt; use windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW; From e54c97d2f51fa1ca8f2b6ef054af3b21bfc7c758 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Mon, 13 Jul 2026 08:03:44 -0400 Subject: [PATCH 088/102] test(cli): restore coverage boundary after refactor Signed-off-by: Will Killian --- codecov.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/codecov.yml b/codecov.yml index 1cb5518db..55b332a21 100644 --- a/codecov.yml +++ b/codecov.yml @@ -69,7 +69,7 @@ component_management: - "crates/cli/src" statuses: - type: project - target: 88% + target: 90% threshold: 0.5% base: auto if_ci_failed: error @@ -137,8 +137,9 @@ ignore: - "crates/cli/tests/" # CLI TTY shells are exercised by smoke tests, but their prompt loops are # intentionally split away from testable model modules. - - "crates/cli/src/plugins.rs" - - "crates/cli/src/setup.rs" + - "crates/cli/src/plugins/mod.rs" + - "crates/cli/src/plugins/dynamic_editor.rs" + - "crates/cli/src/commands/configure/wizard.rs" - "**/tests-js/**" # The Node binding currently reports JS package coverage separately; exclude the # native Rust bridge until we have direct Rust-side coverage for this crate. From 7a23cad882e70034d76e34ecc3661000f1ebfb1b Mon Sep 17 00:00:00 2001 From: Will Killian Date: Mon, 13 Jul 2026 08:25:34 -0400 Subject: [PATCH 089/102] docs(cli): explain hook forwarding flags Signed-off-by: Will Killian --- crates/cli/src/commands/hook_forward.rs | 13 +++++++++++++ crates/cli/tests/cli_tests.rs | 25 +++++++++++++++++++++++++ docs/nemo-relay-cli/basic-usage.mdx | 10 +++++++++- integrations/coding-agents/README.md | 7 +++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/hook_forward.rs b/crates/cli/src/commands/hook_forward.rs index 122590caf..9b767570f 100644 --- a/crates/cli/src/commands/hook_forward.rs +++ b/crates/cli/src/commands/hook_forward.rs @@ -10,28 +10,41 @@ use crate::error::CliError; #[derive(Debug, Clone, Args)] pub(crate) struct HookForwardCommand { + /// Coding agent whose canonical lifecycle payload is read from standard input. #[arg(value_enum)] pub(crate) agent: AgentArg, + /// Base URL of the Relay gateway that receives the lifecycle payload. #[arg(long)] pub(crate) gateway_url: Option, + /// Installer-owned generation marker used to reject stale persistent hooks. #[arg(long, hide = true)] pub(crate) generation_file: Option, + /// Expected identity of the installer-owned generation marker. #[arg(long, hide = true)] pub(crate) generation_token: Option, + /// Forward to an existing compatible gateway without an install-generation fence. + /// + /// Intended for source plugins and custom automation. This mode verifies the gateway but + /// never launches or recovers Relay. #[arg(long, conflicts_with_all = ["generation_file", "generation_token"])] pub(crate) forward_only: bool, + /// Mark a hook as owned by a process-private `nemo-relay run` gateway. #[arg( long, hide = true, conflicts_with_all = ["generation_file", "generation_token", "forward_only"] )] pub(crate) transparent_run: bool, + /// Configuration profile recorded with the forwarded session metadata. #[arg(long)] pub(crate) profile: Option, + /// JSON value added to the forwarded session metadata. #[arg(long)] pub(crate) session_metadata: Option, + /// Expected gateway behavior recorded with the forwarded session metadata. #[arg(long, value_enum)] pub(crate) gateway_mode: Option, + /// Return a failure when the payload cannot be delivered or Relay rejects it. #[arg(long)] pub(crate) fail_closed: bool, } diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index bc5c01554..13b3d5320 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -2501,6 +2501,31 @@ fn cli_help_lists_model_pricing_command_only() { } } +#[test] +fn cli_hook_forward_help_documents_operational_flags() { + let output = Command::new(gateway_bin()) + .args(["hook-forward", "--help"]) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + + for expected in [ + "Coding agent whose canonical lifecycle payload is read from standard input", + "Base URL of the Relay gateway that receives the lifecycle payload", + "Forward to an existing compatible gateway without an install-generation fence", + "Configuration profile recorded with the forwarded session metadata", + "JSON value added to the forwarded session metadata", + "Expected gateway behavior recorded with the forwarded session metadata", + "Return a failure when the payload cannot be delivered or Relay rejects it", + ] { + assert!( + stdout.contains(expected), + "missing help text: {expected}\n{stdout}" + ); + } +} + #[test] fn cli_help_lists_plugin_install_commands() { let output = Command::new(gateway_bin()).arg("--help").output().unwrap(); diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index 4ce8cbf23..c8e0ae9ce 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -359,11 +359,19 @@ to the matching endpoint, and prints the endpoint response. It fails open by default so observability outages do not block the coding agent. Add `--fail-closed` only when policy requires hook delivery to block the agent. -Optional flags map to gateway headers: +Available flags: +- `--gateway-url ` selects the Relay gateway that receives the payload. +- `--forward-only` allows source plugins and custom automation to use an + existing compatible gateway without an installer-owned generation fence. It + verifies the gateway but never launches or recovers Relay. Generated + installed hooks use a private generation fence instead. - `--session-metadata` sets `x-nemo-relay-session-metadata`. - `--profile` sets `x-nemo-relay-config-profile`. - `--gateway-mode` sets `x-nemo-relay-gateway-mode`. +- `--fail-closed` returns a failure when delivery fails or Relay rejects the + hook. Without it, forwarding fails open so an observability outage does not + block the coding agent. ## Agent Guides diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 8dd1f74e2..522c0261d 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -257,11 +257,18 @@ hook command status to the host. Useful `hook-forward` options: +- `--gateway-url ` selects the Relay gateway that receives the payload. +- `--forward-only` allows a source plugin or custom automation to use an + existing compatible gateway without an installer-owned generation fence. It + verifies the gateway but never launches or recovers Relay. Generated + installed hooks use a private generation fence instead. - `--session-metadata ''` adds structured metadata to the agent begin event. - `--profile ` records a configuration profile in session metadata. - `--gateway-mode hook-only|passthrough|required` records the expected gateway behavior in session metadata. +- `--fail-closed` returns a failure when delivery fails or Relay rejects the + hook instead of allowing the coding agent to continue. ## LLM Gateway From 88fb790feee5ef54adb3a64af08e38668fc0f442 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Mon, 13 Jul 2026 08:28:33 -0400 Subject: [PATCH 090/102] test(cli): avoid pinning help prose Signed-off-by: Will Killian --- crates/cli/tests/cli_tests.rs | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 13b3d5320..bc5c01554 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -2501,31 +2501,6 @@ fn cli_help_lists_model_pricing_command_only() { } } -#[test] -fn cli_hook_forward_help_documents_operational_flags() { - let output = Command::new(gateway_bin()) - .args(["hook-forward", "--help"]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - - for expected in [ - "Coding agent whose canonical lifecycle payload is read from standard input", - "Base URL of the Relay gateway that receives the lifecycle payload", - "Forward to an existing compatible gateway without an install-generation fence", - "Configuration profile recorded with the forwarded session metadata", - "JSON value added to the forwarded session metadata", - "Expected gateway behavior recorded with the forwarded session metadata", - "Return a failure when the payload cannot be delivered or Relay rejects it", - ] { - assert!( - stdout.contains(expected), - "missing help text: {expected}\n{stdout}" - ); - } -} - #[test] fn cli_help_lists_plugin_install_commands() { let output = Command::new(gateway_bin()).arg("--help").output().unwrap(); From dba61440d019afa493818946a95212a40193d7b2 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Mon, 13 Jul 2026 08:57:33 -0400 Subject: [PATCH 091/102] chore: split documentation from functionality PR Signed-off-by: Will Killian --- README.md | 23 +- crates/cli/README.md | 12 - docs/about-nemo-relay/release-notes/index.mdx | 2 +- .../release-notes/known-issues.mdx | 16 +- .../grpc-worker/python/about.mdx | 15 - docs/getting-started/installation.mdx | 24 +- docs/getting-started/quick-start/index.mdx | 2 +- docs/nemo-relay-cli/about.mdx | 24 +- docs/nemo-relay-cli/basic-usage.mdx | 78 ++--- docs/nemo-relay-cli/claude-code.mdx | 69 +--- docs/nemo-relay-cli/codex.mdx | 208 +++-------- docs/nemo-relay-cli/hermes.mdx | 303 ++++++++-------- docs/nemo-relay-cli/plugin-installation.mdx | 328 ++++-------------- integrations/coding-agents/README.md | 191 +++------- .../coding-agents/claude-code/README.md | 67 ++-- integrations/coding-agents/codex/README.md | 239 +++---------- 16 files changed, 445 insertions(+), 1156 deletions(-) diff --git a/README.md b/README.md index 738c94dde..ecbca300a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ trajectory file, you have concrete data to inspect, debug, and build on. ### Local Agent Trajectory This walkthrough shows an end-to-end quick success setup. Install the -`nemo-relay-cli`, turn on local exporters, run Codex, Claude Code, or Hermes +`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. @@ -101,7 +101,7 @@ then configure these sections: > Run `nemo-relay plugins edit` without `--project` only when you want > user-level exporter settings that apply across projects. -#### 3. Run a Coding Agent Through Relay +#### 3. Run Codex or Claude Code Through Relay Run the Relay wrapper for the host CLI installed on your machine. For example: @@ -113,10 +113,6 @@ nemo-relay codex -- exec "Summarize this repository." nemo-relay claude -- "Summarize this repository." ``` -```bash -nemo-relay hermes -- -z "Summarize this repository." -``` - 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 @@ -124,15 +120,10 @@ and provider settings for that launched process, then shuts the gateway down when the agent exits. > [!WARNING] -> `nemo-relay install codex` automatically trusts only the exact hooks owned by -> `nemo-relay-plugin@nemo-relay-local`. It does not trust unrelated user, -> project, or plugin hooks. Manual or source-marketplace installs can still -> require review. Restart an already running Codex app after persistent -> installation. On Windows, a restrictive host Job Object can keep the shared -> Relay gateway scoped to the host process lifetime. -> The Codex Desktop App has additional limitations. +> If generated hooks are inactive, Codex users must review and activate them +> before events appear. The Codex Desktop App has additional limitations. > Refer to the [Codex CLI guide](https://docs.nvidia.com/nemo/relay/nemo-relay-cli/codex) for the -> current lifecycle, startup, and troubleshooting details. +> current hook activation caveat and troubleshooting steps. #### 4. Verify the Run @@ -296,8 +287,8 @@ coverage. | Agent | Observability | Security | Optimization | Notes | |:--|:--:|:--:|:--:|:--| | Claude Code | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed LLM observability are supported. | -| Codex | Yes | Yes | Partial | Persistent install verifies the exact plugin hooks. Each `Stop` finalizes a turn snapshot; the supported generated schema does not install `SessionEnd`. | -| Hermes Agent | Yes | Yes | Partial | User config installs the shared native MCP gateway lifecycle plus exact trusted hooks; gateway-routed or hook-backed LLM observability is 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. | ### Public API Integrations diff --git a/crates/cli/README.md b/crates/cli/README.md index 05632028f..b5436a5d6 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -49,12 +49,6 @@ with the installed `nemo-relay` command rather than link against the crate. CLI overrides for deterministic non-interactive use. - **Hook forwarding server**: A local gateway accepts agent hook events and provider-shaped OpenAI or Anthropic requests. -- **Persistent agent integration**: `nemo-relay install` configures Codex, - Claude Code, or Hermes Agent with one generated MCP bootstrap and the host's - canonical lifecycle hooks. -- **Shared gateway lifecycle**: Every persistent integration launches the same - host-neutral `nemo-relay mcp` client. Concurrent clients share one native - gateway on `127.0.0.1:47632`. ## Installation Options @@ -107,12 +101,6 @@ nemo-relay codex nemo-relay claude -- "summarize this repository" ``` -Install persistent integrations for the supported agent CLIs on `PATH`: - -```bash -nemo-relay install all -``` - Use `run --dry-run` to inspect resolved config without spawning the agent: ```bash diff --git a/docs/about-nemo-relay/release-notes/index.mdx b/docs/about-nemo-relay/release-notes/index.mdx index 8a1d1eb2c..48e65a6af 100644 --- a/docs/about-nemo-relay/release-notes/index.mdx +++ b/docs/about-nemo-relay/release-notes/index.mdx @@ -57,7 +57,7 @@ For the major 0.5 additions, start with: - [Build Plugins](/build-plugins/about) - [Plugin Configuration Files](/configure-plugins/plugin-configuration-files) - [Installation](/getting-started/installation) -- [Coding Agent Installation and Diagnostics](/nemo-relay-cli/plugin-installation) +- [Plugin Installation and Diagnostics](/nemo-relay-cli/plugin-installation) - [LLM Request Intercept Outcomes](/reference/llm-request-intercept-outcomes) - [Tool Execution Intercept Outcomes](/reference/tool-execution-intercept-outcomes) - [Instrument an LLM Call](/instrument-applications/instrument-llm-call) diff --git a/docs/about-nemo-relay/release-notes/known-issues.mdx b/docs/about-nemo-relay/release-notes/known-issues.mdx index b2a139f2a..7d1b64a4d 100644 --- a/docs/about-nemo-relay/release-notes/known-issues.mdx +++ b/docs/about-nemo-relay/release-notes/known-issues.mdx @@ -9,20 +9,6 @@ SPDX-License-Identifier: Apache-2.0 */} This page lists current limitations and support notes for the release documentation set. -## NVIDIA NeMo Relay 0.6 - -- Codex can perform provider discovery and request `/models` before launching - required plugin MCP servers. That cold-start request can fail and be retried; - the captured turn and `/responses` traffic wait for verified Relay gateway - readiness. -- On Windows, a restrictive host Job Object can prevent a coding-agent plugin's - Relay gateway from breaking away. In that case the gateway remains scoped to - the host job and might not remain available for the full 300-second idle reuse - window. -- The Codex 0.143 plugin hook schema does not expose `SessionEnd`. Relay writes a - cumulative trajectory snapshot for every delivered `Stop`, so the final turn - snapshot is also the final session snapshot. - ## NVIDIA NeMo Relay 0.5 These notes apply to the NVIDIA NeMo Relay 0.5 release. @@ -33,6 +19,8 @@ These notes apply to the NVIDIA NeMo Relay 0.5 release. - The NeMo Relay CLI is experimental. Coding-agent observability support varies with host plugin, hook, and provider-routing capabilities. Hooks alone cannot produce complete LLM request and response spans. +- Complete first-request capture in Codex plugin mode depends on Codex firing + an installed hook before the first provider request. - Node.js 24 or newer is required for Node.js binding and package workflows. - OpenClaw support uses public hook-backed telemetry with partial security and optimization support. Security is limited to pre-tool conditional guardrails, diff --git a/docs/build-plugins/dynamic-plugins/grpc-worker/python/about.mdx b/docs/build-plugins/dynamic-plugins/grpc-worker/python/about.mdx index 1c6db7772..6317330a5 100644 --- a/docs/build-plugins/dynamic-plugins/grpc-worker/python/about.mdx +++ b/docs/build-plugins/dynamic-plugins/grpc-worker/python/about.mdx @@ -211,14 +211,6 @@ print(f"sha256:{sha256(artifact.read_bytes()).hexdigest()}") PY ``` -Relay requires the module portion of `load.entrypoint` to resolve directly -under `source.manifest_root` to exactly one Python source file: either -`module/path.py` or `module/path/__init__.py`. That canonical file must be the -integrity-checked `source.artifact`. Relay rejects ambiguous modules and custom -build-backend mappings, including `src/` layouts that cannot be derived from the -manifest. This prevents an unsigned sibling module from becoming the executed -entrypoint. - ## Register the Plugin Run the following commands from the project directory: @@ -235,13 +227,6 @@ the `source.manifest_root` project into it. Relay uses that recorded environment when it starts the worker. Do not start the worker directly: Relay supplies its worker socket, host socket, activation ID, and activation token. -Relay records a digest of the installed environment in a locally authenticated -marker bound to the integrity-checked entrypoint artifact. The marker detects -changes between provisioning and activation, but it is not a security boundary -against a process running as the same operating-system user, which can access -the same user-owned keys and files. Worker process isolation is not a security -sandbox. - ## Configure the Plugin After `plugins add` registers the worker, edit diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 1f505d655..45bd1b363 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -61,17 +61,16 @@ inherit the persistent `PATH` change. Verify that the command is available: nemo-relay --version ``` -After installation, `nemo-relay` can also install persistent Claude Code, -Codex, and Hermes Agent integrations: +After installation, `nemo-relay` can also install persistent Claude Code and +Codex host plugins: ```bash nemo-relay install claude-code nemo-relay install codex -nemo-relay install hermes ``` -Refer to [Coding Agent Installation](/nemo-relay-cli/plugin-installation) for -the persistent integration workflow. +Refer to [Plugin Installation](/nemo-relay-cli/plugin-installation) for the host +plugin workflow. The installers download the published SHA-256 checksum for the selected release @@ -231,18 +230,17 @@ configuration and verification steps. ### Hermes Agent -After installing the NeMo Relay CLI, install and diagnose the Hermes user -integration: +Install the latest NeMo Relay Python package in the environment that runs +Hermes, then enable the bundled Hermes plugin: ```bash -nemo-relay install hermes -nemo-relay doctor --plugin hermes +pip install nemo-relay +hermes plugins enable observability/nemo_relay ``` -Relay adds a native stdio MCP entry and the exact trusted lifecycle hooks that -share one user-level gateway acquired as soon as the MCP process starts. Refer -to the [Hermes Agent guide](/nemo-relay-cli/hermes) for provider routing, -lifecycle, and removal steps. +The plugin ID is `observability/nemo_relay`. See the +[Hermes NeMo Relay plugin guide](https://github.com/NousResearch/hermes-agent/tree/main/plugins/observability/nemo_relay) +for configuration and verification steps. ### Python Framework Integrations diff --git a/docs/getting-started/quick-start/index.mdx b/docs/getting-started/quick-start/index.mdx index 29e61b57c..f1152f1e4 100644 --- a/docs/getting-started/quick-start/index.mdx +++ b/docs/getting-started/quick-start/index.mdx @@ -26,7 +26,7 @@ not yet know which guide owns the working path. | Layer | Use When | Start Here | Success Check | |---|---|---|---| | CLI and gateway | A coding-agent harness owns invocation and provider routing | [CLI Basic Usage](/nemo-relay-cli/basic-usage) | The wrapped agent runs with Relay active and emits hook output, plus gateway-routed LLM lifecycle output when provider routing is active. | -| Persistent coding-agent installs | You want the maintained install path for Codex, Claude Code, or Hermes Agent instead of the transparent wrapper | [Coding Agent Installation](/nemo-relay-cli/plugin-installation) | `nemo-relay doctor --plugin ` confirms the installed integration is ready. | +| Persistent host-plugin installs | You want the maintained install path for Codex or Claude Code instead of the transparent wrapper | [Plugin Installation](/nemo-relay-cli/plugin-installation) | `nemo-relay doctor --plugin ` confirms the installed host plugin is registered and ready. | | Direct Python or Node.js application APIs | Your application owns the tool or LLM callback | [Python Quick Start](/getting-started/quick-start/python) or [Node.js Quick Start](/getting-started/quick-start/nodejs) | The sample prints event lines plus tool and LLM results. | | Direct Rust application APIs | Your Rust application owns the tool or LLM callback | [Rust Quick Start](/getting-started/quick-start/rust) | The sample prints scope, tool, and LLM lifecycle output plus the `initialized` mark event. | | Plugin-managed runtime setup | You need process-level exporter or plugin behavior from `plugins.toml` | [Plugin Configuration Files](/configure-plugins/plugin-configuration-files) | The selected plugin path activates and writes the expected output or behavior. | diff --git a/docs/nemo-relay-cli/about.mdx b/docs/nemo-relay-cli/about.mdx index 5481c5330..32211aba7 100644 --- a/docs/nemo-relay-cli/about.mdx +++ b/docs/nemo-relay-cli/about.mdx @@ -20,13 +20,6 @@ model traffic through the gateway, and diagnose local configuration. The `nemo-relay-cli` Cargo package remains available for source-based installation and unsupported platforms. -Persistent integrations use one lifecycle path: - -`Codex / Claude Code / Hermes Agent` → `nemo-relay mcp` → `shared Relay gateway` - -The host-specific layer installs and emits canonical lifecycle hooks. The MCP -process is host-neutral and keeps the shared user-level gateway available. - ## Start Here When Use these guides when you need to: @@ -34,8 +27,8 @@ Use these guides when you need to: - Observe Claude Code, Codex, or Hermes Agent sessions locally. - Configure coding-agent hooks for NeMo Relay lifecycle events. - Route model-provider traffic through the local NeMo Relay gateway. -- Install persistent Claude Code, Codex, or Hermes Agent integrations without - wrapping the agent command. +- Install persistent Claude Code or Codex host plugins without wrapping the + agent command. - Validate and install model pricing catalog sources for local cost estimates. - Export local sessions to Agent Trajectory Interchange Format (ATIF), Agent Trajectory Observability Format (ATOF) JSONL, OpenTelemetry, or @@ -56,8 +49,8 @@ controls. | Agent | Observability | Security | Optimization | Notes | | --- | --- | --- | --- | --- | | Claude Code | Yes | Yes | Partial | Pre-tool hook responses are supported. LLM optimization uses gateway-routed traffic; full coverage depends on loaded Claude Code hooks. | -| Codex | Yes | Yes | Partial | Persistent install verifies all 10 hooks in the supported schema. Each `Stop` finalizes a turn snapshot because the plugin schema does not expose `SessionEnd`. | -| Hermes Agent | Yes | Yes | Partial | User config installs the shared native MCP gateway lifecycle plus exact trusted hooks. Optimization depends on Hermes shell and API-request hook coverage. | +| Codex | Yes | Yes | Partial | Hook forwarding and gateway-routed LLM optimization are supported after hooks are reviewed and activated. The missing session-end hook limits full coverage. | +| Hermes Agent | Yes | Yes | Partial | Hook forwarding, pre-tool guardrails, and Hermes API-request telemetry are supported. Optimization depends on Hermes shell and API-request hook coverage. | ## Guides @@ -65,15 +58,16 @@ Use these guide links to move from CLI setup into agent-specific instructions. - [Basic Usage](/nemo-relay-cli/basic-usage) explains gateway routes, transparent runs, shared configuration, hook forwarding, and runtime mapping. -- [Coding Agent Installation](/nemo-relay-cli/plugin-installation) covers - persistent Claude Code, Codex, and Hermes Agent setup. +- [Plugin Installation](/nemo-relay-cli/plugin-installation) covers persistent + Claude Code and Codex plugin setup through host marketplaces. - [Claude Code](/nemo-relay-cli/claude-code) covers transparent Claude Code runs, Anthropic gateway routing, ATIF verification, and unsupported Claude application modes. - [Codex](/nemo-relay-cli/codex) covers transparent Codex CLI runs, local GUI/app caveats, model provider routing, and remote-task limits. -- [Hermes Agent](/nemo-relay-cli/hermes) covers Hermes MCP lifecycle setup, - exact hook trust, provider routing, and session-finalize behavior. +- [Hermes Agent](/nemo-relay-cli/hermes) covers Hermes shell hook installation, + dynamic gateway URL handling, session-finalize behavior, and hook consent + caveats. Start with [Basic Usage](/nemo-relay-cli/basic-usage), then use the guide for the coding agent that you want to observe. diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index c8e0ae9ce..674ebed8b 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -54,12 +54,7 @@ under the active session scope. Use the agent shortcuts for no-install local observability. The wrapper starts a gateway on a dynamic `127.0.0.1` port, injects the resolved hook and gateway configuration into the launched coding agent, and stops the gateway when the -agent exits. Relay owns the launched wrapper and its descendants as one process -tree, so a gateway failure cannot leave an agent running without its private -gateway and hook configuration. Interactive Unix launches transfer foreground -terminal ownership and preserve Ctrl-C and Ctrl-Z job control. Non-interactive -Unix launches forward termination signals to the complete agent process tree -before Relay removes the injected configuration. +agent exits. ```bash nemo-relay codex @@ -74,14 +69,6 @@ instead of the built-in shortcut: nemo-relay run -- codex ``` -For Claude Code and Codex, transparent mode leaves the caller's source settings, -selected profile, and installed plugin state unchanged. A process marker makes -any installed Relay MCP borrow the wrapper-owned dynamic gateway. Claude's -persistent hooks exit without forwarding; Codex disables the known Relay plugin -hook identities in its process-local CLI state. Only the injected wrapper hooks -deliver events. Hermes gets the same isolation through its process-private -configuration overlay. - If a launcher or wrapper hides the real agent name, set that wrapper as the configured command and pass `--agent`. The same pattern applies to Claude Code, Codex, and Hermes: @@ -95,36 +82,24 @@ command = "my-codex-wrapper" nemo-relay run --agent codex ``` -For Hermes, interactive setup configures only Relay's transparent wrapper. -Persistent MCP and trusted shell-hook state is owned by -`nemo-relay install hermes`. Transparent `run --agent hermes` exports the -dynamic `NEMO_RELAY_GATEWAY_URL` through a process-private `HERMES_HOME` -overlay; it never rewrites the user's Hermes config. +Hermes is different from the other transparent modes: `run --agent hermes` +starts the gateway and exports the dynamic `NEMO_RELAY_GATEWAY_URL`, but Hermes +shell hooks still need to be installed or otherwise approved in Hermes config. Use `--dry-run --print` to inspect the generated hook config, gateway environment, gateway URL, and final command without launching the agent. -## Persistent Host Integrations +## Persistent Host Plugins -Use persistent integration installation when Claude Code, Codex, or Hermes -Agent should load Relay instead of using a wrapper command: +Use persistent plugin installation when Claude Code or Codex should load NeMo +Relay through the host's plugin system instead of through a wrapper command: ```bash nemo-relay install claude-code nemo-relay install codex -nemo-relay install hermes nemo-relay install all ``` -Claude Code and Codex use marketplace plugins. Hermes exposes the same stdio -MCP lifecycle through user configuration. - -Hermes, Claude Code, and Codex MCP clients can share the native gateway on -`127.0.0.1:47632`. Every `nemo-relay mcp` process acquires that gateway as soon -as it launches. Claude Code uses `alwaysLoad` to wait for the MCP connection; -Hermes starts discovery asynchronously, so its canonical hook command also -closes the remaining cold-start race and retries the original payload once. - Plugin installation writes a local marketplace, installs the generated `nemo-relay-plugin`, and configures the host-specific hook and provider routing needed for full observability. It still relies on the same local `nemo-relay` @@ -137,8 +112,8 @@ nemo-relay doctor --plugin codex nemo-relay uninstall codex ``` -Refer to [Coding Agent Installation](/nemo-relay-cli/plugin-installation) for -install directories, host-specific behavior, and the shared-sidecar lifecycle. +Refer to [Plugin Installation](/nemo-relay-cli/plugin-installation) for install +directories, host-specific behavior, and Codex lazy sidecar constraints. ## Shared Configuration @@ -331,8 +306,8 @@ Generated hook bundles subscribe to the events needed for that mapping: | Agent | LLM lifecycle and correlation hooks | Scope, tool, and mark hooks | | --- | --- | --- | -| Claude Code | `UserPromptSubmit`, `Stop` | `SessionStart`, `SessionEnd`, `UserPromptExpansion`, `SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `Notification`, `PreCompact`, `PostCompact` | -| Codex | `UserPromptSubmit`, `Stop` | `SessionStart`, `SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `PreCompact`, `PostCompact` | +| Claude Code | `UserPromptSubmit`, `AfterAgentResponse`, `AfterAgentThought`, `Stop` | `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `Notification`, `PreCompact` | +| Codex | `UserPromptSubmit`, `AfterAgentResponse`, `AfterAgentThought`, `Stop` | `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `Notification`, `PreCompact` | | Hermes | `pre_api_request`, `post_api_request`, `api_request_error`, `pre_llm_call`, `post_llm_call` | `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`, `subagent_start`, `subagent_stop`, `pre_tool_call`, `post_tool_call` | Hermes `pre_api_request`, `post_api_request`, and `api_request_error` hooks @@ -341,37 +316,26 @@ map to NeMo Relay LLM start/end events when present. Hermes `pre_llm_call` and ## Hook Forwarding -Transparent Claude Code and Codex hooks invoke -`nemo-relay hook-forward ` with the canonical payload on standard input. -The wrapper-owned hook command embeds its ephemeral gateway URL and is marked -as transparent so it cannot recover the fixed gateway. +Hooks generated by the wrapper (Claude/Codex ephemeral, Hermes via +setup) invoke `nemo-relay hook-forward ` from stdin. Inside the wrapper +the gateway URL comes from `NEMO_RELAY_GATEWAY_URL` injected on every run; +outside the wrapper (Hermes standalone, IDE-launched Claude/Codex) the hook +command falls back to its embedded `--gateway-url`. -Persistent Claude Code, Codex, and Hermes hooks also use -`nemo-relay hook-forward `. A persistent generated command identifies -the fixed gateway and private install-generation fence explicitly, waits for -and verifies the MCP-owned Relay gateway, then forwards the unchanged payload -once. Hermes -setup stores the canonical absolute command and trusts its exact event pairs; -it does not enable global hook auto-acceptance. +Hooks installed by Claude Code and Codex plugins use the plugin shim path +managed by `nemo-relay install`. That path starts or reuses the local plugin +sidecar before forwarding the hook payload. `hook-forward` reads the canonical hook payload from standard input, sends it to the matching endpoint, and prints the endpoint response. It fails open by default so observability outages do not block the coding agent. Add `--fail-closed` only when policy requires hook delivery to block the agent. -Available flags: +Optional flags map to gateway headers: -- `--gateway-url ` selects the Relay gateway that receives the payload. -- `--forward-only` allows source plugins and custom automation to use an - existing compatible gateway without an installer-owned generation fence. It - verifies the gateway but never launches or recovers Relay. Generated - installed hooks use a private generation fence instead. - `--session-metadata` sets `x-nemo-relay-session-metadata`. - `--profile` sets `x-nemo-relay-config-profile`. - `--gateway-mode` sets `x-nemo-relay-gateway-mode`. -- `--fail-closed` returns a failure when delivery fails or Relay rejects the - hook. Without it, forwarding fails open so an observability outage does not - block the coding agent. ## Agent Guides @@ -380,7 +344,7 @@ application-mode caveats. - [Claude Code](/nemo-relay-cli/claude-code) - [Codex](/nemo-relay-cli/codex) -- [Coding Agent Installation](/nemo-relay-cli/plugin-installation) +- [Plugin Installation](/nemo-relay-cli/plugin-installation) - [Hermes Agent](/nemo-relay-cli/hermes) Each guide covers transparent run setup, gateway routing, hook smoke tests, diff --git a/docs/nemo-relay-cli/claude-code.mdx b/docs/nemo-relay-cli/claude-code.mdx index 64cf10b78..abc80726a 100644 --- a/docs/nemo-relay-cli/claude-code.mdx +++ b/docs/nemo-relay-cli/claude-code.mdx @@ -28,18 +28,8 @@ nemo-relay claude -- "summarize this repository" This shortcut is equivalent to `nemo-relay run -- claude`. The wrapper starts a gateway on a dynamic `127.0.0.1` port, creates a temporary Claude plugin -directory with NeMo Relay hooks, and passes that plugin with `--plugin-dir`. -Because Claude Code gives its first `--settings` source precedence over the -process environment, Relay also creates a private settings overlay that -preserves that source and overrides only `ANTHROPIC_BASE_URL` for the launched -process. The source settings and installed plugin enablement remain unchanged. -If a Relay plugin is already enabled, its MCP process authenticates and borrows -the dynamic gateway instead of launching the fixed sidecar, then monitors that -exact gateway while MCP stdio remains open. Its persistent hooks exit without -forwarding. Only the wrapper-owned temporary hooks deliver lifecycle payloads, -and those hooks authenticate the wrapper gateway before sending a payload, so -installed and source-marketplace plugin IDs cannot duplicate the captured -stream. +directory with NeMo Relay hooks, passes that plugin with `--plugin-dir`, and +sets `ANTHROPIC_BASE_URL` to the gateway URL for the launched process. Inspect what would be launched without starting Claude Code: @@ -64,26 +54,6 @@ The installer creates a local marketplace, installs provider routing through the local NeMo Relay sidecar. It uses the existing `nemo-relay` binary on `PATH`; it does not install a plugin-local Relay binary. -The plugin starts `nemo-relay mcp`, a lightweight Rust lifecycle client that -starts or reuses the shared gateway on `127.0.0.1:47632` immediately when the -MCP process launches. The client verifies the gateway identity and effective -persistent configuration, heartbeats it while MCP stdio remains open, and -performs one coordinated restart if the gateway becomes unhealthy. Claude -Code, Codex, and Hermes MCP clients share a compatible gateway, and the gateway -exits after the final client's idle timeout. The MCP server advertises no tools. - -The generated entry sets `alwaysLoad: true`, so Claude Code 2.1.121 or newer -waits for the MCP connection before session startup. The installed command hook -carries the same install-generation fence, waits for the MCP-owned gateway, and -forwards the canonical payload once. MCP owns the session-long gateway -lifecycle. - -Persistent plugin mode loads system and user Relay configuration only and uses -the user configuration directory as its working directory. Use the transparent -wrapper for project-specific `.nemo-relay` configuration. Run -`nemo-relay install claude-code --force` to replace an existing -generation-fenced installation safely. - Check or remove the installed plugin with: ```bash @@ -91,8 +61,8 @@ nemo-relay doctor --plugin claude-code nemo-relay uninstall claude-code ``` -Refer to [Coding Agent Installation](/nemo-relay-cli/plugin-installation) for -install directories, rollback behavior, and source marketplace notes. +Refer to [Plugin Installation](/nemo-relay-cli/plugin-installation) for install +directories, rollback behavior, and source marketplace notes. ## Shared Config @@ -143,19 +113,19 @@ claude ``` The gateway forwards Anthropic `/v1/messages`, `/v1/messages/count_tokens`, and -model routes without rewriting provider JSON. Hook events require either the -persistent NeMo Relay plugin or a transparent `nemo-relay claude` or -`nemo-relay run --agent claude` invocation, which injects ephemeral hooks into -the launched process. +model routes without rewriting provider JSON. Hook events (tool calls, session +markers) are only captured when running through `nemo-relay claude` or +`nemo-relay run --agent claude`, which inject ephemeral hooks into the launched +process. ## Captured Events Generated Claude Code hooks include `SessionStart`, `SessionEnd`, -`UserPromptSubmit`, `UserPromptExpansion`, `PreToolUse`, `PostToolUse`, -`PostToolUseFailure`, `PermissionRequest`, `SubagentStart`, `SubagentStop`, -`Notification`, `Stop`, `PreCompact`, and `PostCompact`. Relay normalizes them -as scope, prompt, tool, mark, notification, compaction, subagent, or private LLM -correlation events according to the hook payload. +`SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, +`PostToolUseFailure`, `Notification`, and `PreCompact` for scope, tool, and +mark events. `UserPromptSubmit`, `AfterAgentResponse`, `AfterAgentThought`, and +`Stop` are retained as private LLM correlation hints and are not emitted as +standalone NeMo Relay events. Tool hooks preserve canonical fields such as `tool_use_id`, `tool_name`, `tool_input`, `error`, `duration_ms`, and `is_interrupt`. Subagent hooks use @@ -232,13 +202,12 @@ mark under that turn. ## Hook Limitations -Claude Code hooks are available only when Claude Code loads the persistent NeMo -Relay plugin or the ephemeral plugin generated by `nemo-relay claude` or -`nemo-relay run --agent claude`. +Claude Code hooks are available only when Claude Code loads the NeMo Relay +plugin, such as through `nemo-relay claude` or `nemo-relay run --agent claude`. The standalone gateway can still observe Anthropic LLM traffic, but it cannot invent missing tool, prompt, compaction, notification, or subagent hooks. -`UserPromptSubmit` contributes prompt and LLM correlation. `Stop` is a private -correlation and turn-boundary hint rather than a standalone user-visible mark. -The other hooks normalize to their corresponding scope, tool, mark, -notification, compaction, or subagent semantics. +`UserPromptSubmit`, `AfterAgentResponse`, `AfterAgentThought`, and `Stop` are +used as private correlation and turn-boundary hints. They are not exported as +standalone user-visible mark events unless they also produce a scoped turn, +tool, LLM, or lifecycle observation. diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index 1043d27f2..2446e1ff4 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -14,21 +14,17 @@ local gateway cannot observe provider traffic that never reaches the machine. ## Requirements -`codex-cli >= 0.143.0`. Persistent installation requires exactly one discovered, -enabled, and trusted handler for all 10 events in the supported Codex hook schema: -`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, -`PermissionRequest`, `SubagentStart`, `SubagentStop`, `Stop`, `PreCompact`, and -`PostCompact`. Persistent setup also requires the -`features.hooks` flag, plugin app-server metadata, and the `nemo-relay-openai` -provider alias. The installer checks the Codex version before it changes any -files. +`codex-cli >= 0.129.0`. The gateway uses the `features.hooks` flag and the +`nemo-relay-openai` provider alias, both of which require this version. Earlier +versions either reject the provider override or do not recognize the hooks +feature flag. -Codex requires hooks to be trusted before they run. `nemo-relay install codex` -uses the Codex app-server API to trust and verify only the exact hooks owned by -`nemo-relay-plugin@nemo-relay-local`. Source-marketplace or manually installed -hooks can still require review. This behavior is tracked upstream as -[openai/codex#21639](https://github.com/openai/codex/issues/21639). +As of Codex 0.129, Codex requires hooks to be manually reviewed and activated +before they run. Generated NeMo Relay hook configuration is not enough on its own +if Codex leaves those hooks inactive. Review and activate the installed or +injected hooks in Codex before expecting NeMo Relay events. This is being tracked +upstream as [openai/codex#21639](https://github.com/openai/codex/issues/21639). @@ -48,15 +44,9 @@ nemo-relay codex -- exec "Summarize this repository." This shortcut is equivalent to `nemo-relay run -- codex`. The wrapper starts a gateway on a dynamic `127.0.0.1` port, enables Codex hooks with CLI config -overrides, injects hook commands that embed that gateway URL, and points Codex -at a temporary `nemo-relay-openai` provider alias while preserving Codex's -OpenAI auth path. Exact trust for the generated hooks and process-local disable -entries for the known local and source Relay plugin hook identities are -supplied in the same CLI layer. Relay does not replace or rewrite the selected -Codex profile. If a Relay plugin is already enabled, its MCP process -authenticates, borrows, and monitors that exact dynamic gateway; only the -wrapper-owned hooks remain enabled for that process. Those hooks authenticate -the wrapper gateway before sending lifecycle payloads. +overrides, injects hook commands that use `NEMO_RELAY_GATEWAY_URL`, and points +Codex at a temporary `nemo-relay-openai` provider alias that uses the gateway +URL while preserving Codex's OpenAI auth path. Inspect what would be launched without starting Codex: @@ -94,79 +84,9 @@ The installer creates a local marketplace, installs existing `nemo-relay` binary on `PATH` but does not install a plugin-local Relay binary. -The required plugin MCP process is a lightweight lifecycle client. As soon as -it launches, it starts or reuses a detached Rust -`nemo-relay --bind 127.0.0.1:47632` sidecar. It completes MCP initialization -only after `/healthz` confirms the Relay identity, version, bootstrap protocol, -and effective persistent configuration. The compatibility check uses a one-way -fingerprint that includes relevant environment values; it -does not return credentials or configuration secrets. Dynamic plugin manifests, -artifacts, and signatures included in this identity must be regular files. The -complete activation snapshot, including adjacent runtime files and a copied -managed Python environment, is limited to 100,000 filesystem entries and 512 -MiB in total, with a maximum directory traversal depth of 128. When startup -reports an activation snapshot budget error, remove unrelated files from the -manifest or load-target directory, flatten deeply nested directories, or reduce -the managed Python environment before retrying. Concurrent Codex, Claude Code, -and Hermes processes can share the sidecar. Each MCP client sends a heartbeat -while its stdio connection is open, and the sidecar exits after 300 seconds -without activity by default. No -wrapper, launch agent, system user service, scheduled task, login item, or -persistent supervisor is installed. - -On Windows, Relay requests Job Object breakaway when the host job permits it. -When breakaway is unavailable, the gateway remains scoped to the host job and -the usual 300-second idle reuse window can end early. - -Persistent plugin mode loads system and user Relay configuration only. It does -not load a project's `.nemo-relay` files. The sidecar starts in the user Relay -configuration directory, so relative exporter paths are deterministic. Use a -transparent `nemo-relay run` when project-specific configuration is required. - -The generated MCP entry forwards variable names, never values, for provider -credentials, Relay runtime settings, common OpenTelemetry, AWS, proxy, and -certificate settings. It also forwards approved `NEMO_RELAY_`, `OTEL_`, and -`AWS_` names that exist during installation and credential variable names -referenced by user observability configuration. Rerun -`nemo-relay install codex --force` when doctor reports a missing forwarded -variable. - -The installer derives a per-user client proof for managed provider requests and -writes the secret-bearing Codex configuration with an owner-only mode on Unix -and a protected owner/System DACL on Windows. Relay consumes that proof before -middleware, observability, or upstream -forwarding. - -Installer-owned hook commands pin `http://127.0.0.1:47632` and the exact -install-generation fence explicitly. Hooks wait for the MCP-owned gateway and -forward each canonical payload once; they never launch or recover Relay. An -ambient `NEMO_RELAY_GATEWAY_URL` cannot split hook traffic from the required -MCP-managed gateway. - -If user configuration, forwarded credentials, or the Relay version changes -while an old sidecar is still running, the new MCP client reports an actionable -compatibility conflict instead of silently reusing the wrong process. Running -`nemo-relay install codex --force` retires a sidecar owned by the prior install -through Relay's private shutdown handshake before it refreshes the plugin. -Running ordinary install again against an existing fenced Codex installation -does not replace files; use `--force` for the fenced replacement transaction. - -An existing install without MCP generation fencing cannot be retired safely, -even when Codex reports that the plugin is no longer registered, because an -already-running MCP process can outlive registration. If upgrade or uninstall -reports a missing MCP generation marker, close all Codex clients and standalone -`nemo-relay mcp` processes, then enter: - -```bash -codex plugin remove nemo-relay-plugin@nemo-relay-local -codex plugin marketplace remove nemo-relay-local -``` - -Remove `codex-marketplace` and `codex.json` from the install directory named in -the error, then retry `nemo-relay install codex --force` to install a fenced -generation. If removal was the original goal, run `nemo-relay uninstall codex` -immediately afterward; the fenced reinstall lets Relay remove provider and hook -trust state transactionally. +Codex plugin mode uses hook-supervised on-demand startup only. It does not install a +wrapper, user-level daemon, launch agent, system user service, scheduled +task, login item, or persistent supervisor. To check for the installed plugin, enter: @@ -180,13 +100,14 @@ To remove the plugin, enter: nemo-relay uninstall codex ``` -Refer to [Coding Agent Installation](/nemo-relay-cli/plugin-installation) for -install directories, shared-sidecar behavior, rollback behavior, and source +Refer to [Plugin Installation](/nemo-relay-cli/plugin-installation) for install +directories, first-request capture limits, rollback behavior, and source marketplace notes. -## Configure Transparent Runs +## Shared Config -Create `.nemo-relay/config.toml` for project defaults: +Create `.nemo-relay/config.toml` for project defaults or +`~/.config/nemo-relay/config.toml` for user defaults: ```toml [upstream] @@ -196,7 +117,7 @@ openai_base_url = "https://api.openai.com/v1" command = "codex" ``` -Configure project observability with `nemo-relay plugins edit --project` or +Then configure observability with `nemo-relay plugins edit --project` or `.nemo-relay/plugins.toml`: ```toml @@ -211,35 +132,8 @@ enabled = true output_directory = ".nemo-relay/atif" ``` -Run `nemo-relay run --agent codex` to use project-specific configuration. The -ATIF files from this example are written under the project at -`.nemo-relay/atif`. - -## Configure the Persistent Plugin - -Create `~/.config/nemo-relay/config.toml`, or -`$XDG_CONFIG_HOME/nemo-relay/config.toml` when `XDG_CONFIG_HOME` is set, for -persistent provider defaults. Use `nemo-relay plugins edit` without -`--project` to write user-scoped observability configuration. For example, set -the ATIF output directory to `atif`: - -```toml -version = 1 - -[[components]] -kind = "observability" -enabled = true - -[components.config.atif] -enabled = true -output_directory = "atif" -``` - -The persistent sidecar deliberately ignores project layers, merges only system -and user configuration, and starts in the user Relay configuration directory. -The relative output directory in this example therefore resolves to -`$XDG_CONFIG_HOME/nemo-relay/atif`, or `~/.config/nemo-relay/atif` when -`XDG_CONFIG_HOME` is not set. +Run `nemo-relay run --agent codex` to use the configured command and plugin +config. User config takes priority over project and system config. ## Standalone Gateway @@ -285,23 +179,17 @@ traffic to pass through the gateway. ## Captured Events -The required generated set includes `SessionStart`, `UserPromptSubmit`, -`PreToolUse`, `PostToolUse`, `PermissionRequest`, `SubagentStart`, -`SubagentStop`, `Stop`, `PreCompact`, and `PostCompact`. Relay requires the Codex -app-server to discover exactly one enabled, trusted handler for every generated -event. `PostToolUseFailure`, `Notification`, and `SessionEnd` are not in the -Codex 0.143 plugin hook schema, so Relay does not generate undiscoverable handlers -for them. Relay maps delivered events to agent, turn, subagent, tool, and mark -lifecycle events; prompt and stop payloads also provide LLM correlation context. +Generated Codex hooks include `SessionStart`, `SessionEnd`, `SubagentStart`, +`SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, +`Notification`, and `PreCompact` for scope, tool, and mark events. +`UserPromptSubmit`, `AfterAgentResponse`, `AfterAgentThought`, and `Stop` are +retained as private LLM correlation hints and are not emitted as standalone +NeMo Relay events. The transparent wrapper passes hook entries as Codex CLI config overrides and -sets `features.hooks=true` for that launched process. It also disables the known -installed Relay hook identities in the same process-local state, without -changing persistent trust or enablement. Persistent install writes -`.codex/config.toml` with `features.hooks = true`, and the installed plugin's -`hooks/hooks.json` remains the sole persistent Relay hook source. During -upgrades, the installer removes legacy Relay groups from `.codex/hooks.json` -while preserving unrelated hooks and backups. +sets `features.hooks=true` for that launched process. Persistent install writes +`.codex/config.toml` with `features.hooks = true` and merges generated hook +entries into `.codex/hooks.json`. ## Smoke Test @@ -320,27 +208,20 @@ an empty JSON object. ## Verify Export End the Codex session and confirm Agent Trajectory Interchange Format (ATIF) -exists at the path for the selected workflow. - -For a transparent project run, enter: +exists: ```bash ls .nemo-relay/atif ``` -For a persistent plugin configured with the relative user-scoped path above, -enter: - -```bash -ls "${XDG_CONFIG_HOME:-$HOME/.config}/nemo-relay/atif" -``` - -The Codex plugin schema does not expose `SessionEnd`, so the gateway uses each -per-turn `Stop` hook to write `.atif.json`. The file grows cumulatively -across turns, so the final `Stop` write reflects the full session. If the file is -missing, confirm `features.hooks = true`, hook config -loading, and that `plugins.toml` enables the ATIF exporter with a writable -`output_directory`. +The gateway writes `.atif.json` after every conversation turn for +Codex sessions (Codex's hook surface has no `SessionEnd`-equivalent event, so +the gateway uses each per-turn `Stop` hook to snapshot the trajectory; the file +grows cumulatively across turns and the final write reflects the full session). +For agents that do emit a session-end hook, the same file is written once on +session close. If the file is missing, confirm `features.hooks = true`, hook +config loading, and that `plugins.toml` enables the ATIF exporter with a +writable `output_directory`. ## Troubleshoot LLM Lifecycle @@ -353,10 +234,3 @@ If LLM spans exist but attach to the session instead of a subagent, pass `x-nemo-relay-subagent-id` on gateway requests or include shared `conversation_id`, `generation_id`, or `request_id` values in both hook payloads and provider requests. - -## Cold-Start Limitation - -Codex can request `/models` while it is discovering the provider, before it -launches plugin MCP servers. That pre-MCP request can fail and be retried. The -required MCP server still blocks the captured turn and `/responses` request -until the shared Relay gateway is ready. diff --git a/docs/nemo-relay-cli/hermes.mdx b/docs/nemo-relay-cli/hermes.mdx index dc393a2ad..28e81fb4f 100644 --- a/docs/nemo-relay-cli/hermes.mdx +++ b/docs/nemo-relay-cli/hermes.mdx @@ -1,212 +1,207 @@ --- title: "Hermes Agent" -description: "Configure Hermes to start and share the native NeMo Relay gateway through MCP." +description: "" position: 7 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} -NeMo Relay observes Hermes sessions through Hermes's native stdio MCP and shell -hook configuration: +The NeMo Relay CLI wrapper and gateway allows you to observe local Hermes Agent sessions. The guide below shows you how to observe these sessions. -```text -Hermes -> Relay MCP -> Relay Gateway -> Model provider - | - +-> shared lifecycle lease on 127.0.0.1:47632 -``` +This path is different from the upstream Hermes +`observability/nemo_relay` plugin. The CLI wrapper observes Hermes through +Hermes shell hooks. It can record LLM request/response payloads from sanitized +Hermes API hook payloads when the Hermes build provides them, or from provider +traffic routed through the NeMo Relay gateway. The upstream Hermes plugin runs +inside Hermes and is configured from Hermes itself. -The MCP process manages gateway lifetime. Shell hooks preserve Hermes's native -session, subagent, tool, and model lifecycle payloads. You do not need a -separate service manager, wrapper process, Node.js runtime, or Python bootstrap -component. +Hermes shell hooks provide session, subagent, tool, and LLM lifecycle signals. +For hook-based LLM lifecycle telemetry, `pre_api_request`, `post_api_request`, +and `api_request_error` are the authoritative Hermes hooks. The legacy +`pre_llm_call` and `post_llm_call` hooks still exist, but only as private +hint-style signals for correlation. When Hermes hook payloads include sanitized +request and response bodies, NeMo Relay records them from the hooks. Gateway +routing is still the direct provider-traffic path, and it remains the best +fallback when a Hermes build emits summary-only hook payloads. -## Install Persistent Capture +## Choose the Right Hermes Path -Install the user-level integration: +Use the `nemo-relay hermes` wrapper when you want NeMo Relay to manage the +local gateway lifetime for a Hermes process and collect hook plus +gateway-routed LLM observability. -```bash -nemo-relay install hermes -``` +Use the upstream Hermes `observability/nemo_relay` plugin when you want Hermes +itself to load the bundled plugin and emit NeMo Relay observability through +Hermes plugin configuration. Observe-only plugin builds keep Hermes in control +of LLM and tool execution. -Installation requires Hermes Agent 0.18.2 or newer. Relay validates the Hermes -CLI before modifying configuration. +Use adaptive execution only with a Hermes build that includes the adaptive +middleware contract and a NeMo Relay runtime that exposes managed +`llm.execute(...)` and `tools.execute(...)` boundaries. Verify the Hermes +release tag before depending on adaptive execution in a released Hermes +environment. -Relay preserves unrelated Hermes settings and updates the Relay-owned portions -of the user configuration. Hermes reads this configuration from -`$HERMES_HOME/config.yaml`, or `~/.hermes/config.yaml` when `HERMES_HOME` is -unset. This location is user-owned even when you choose project-scoped Relay -configuration. +## Transparent Run + +Use the wrapper when you want the gateway lifetime managed for a local Hermes +process: + +```bash +nemo-relay hermes +``` -The MCP server name `nemo-relay` is reserved for the Relay-managed entry. If -that name already belongs to another command, installation stops without -rewriting the config. Rename or remove the conflicting entry, then retry. +Pass Hermes arguments after `--`: -Installation performs the following changes as one verified transaction: +```bash +nemo-relay hermes -- chat --provider custom +``` -- Adds `mcp_servers.nemo-relay` with the canonical absolute `nemo-relay` - executable and the shared `mcp` argument. -- Sets the shared gateway bind to `127.0.0.1:47632` and adds a private MCP - generation fence. -- Installs one canonical Relay handler for every supported Hermes lifecycle - event. -- Approves only those exact `(event, command)` pairs in - `shell-hooks-allowlist.json`. -- Removes legacy Relay hook commands and approvals while preserving unrelated - hooks, MCP servers, approvals, and Hermes settings. +After NeMo Relay config exists, this shortcut is equivalent to +`nemo-relay run --agent hermes`. The wrapper starts a gateway on a dynamic +`127.0.0.1` port and exports `NEMO_RELAY_GATEWAY_URL` for the launched +process. After initial NeMo Relay setup exists, Hermes hook configuration is +temporary in this mode: the launcher merges the NeMo Relay hook-forward +commands into the configured Hermes hook file for the run and restores the +original file afterward. The wrapper also sets `HERMES_ACCEPT_HOOKS=1` so +Hermes can use the injected hook commands without extra manual approval +prompts. -If any write or post-write verification fails, Relay restores the original -Hermes config, allowlist, and generation file. Concurrent setup operations are -serialized so they cannot overwrite one another. +If no NeMo Relay config exists yet, `nemo-relay hermes` triggers the setup flow +first and then launches Hermes through the same wrapped pipeline. -Run integration doctor to verify the Hermes version, MCP entry, all hook -definitions, every trust record, the generation fence, the Relay executable, -and forwarded environment names: +Inspect what would be launched without starting Hermes: ```bash -nemo-relay doctor --plugin hermes +nemo-relay run \ + --dry-run \ + --print \ + -- hermes ``` -Generated config always includes the standard Relay, OpenTelemetry, AWS, -proxy, certificate, and provider credential names. It stores -`${VARIABLE_NAME}` references, never secret values; Relay treats an unresolved -self-reference as unset before parsing its command line. If you later add a -custom `NEMO_RELAY_`, `OTEL_`, or `AWS_` name or change a config-referenced -credential name, run `nemo-relay install hermes --force` to add it to the -Hermes MCP environment. - -## Gateway Lifecycle - -Hermes launches `nemo-relay mcp` as a long-lived stdio MCP -server. The MCP process does not expose tools. It starts or acquires the gateway -immediately when the process launches and completes initialization only after -Relay identity, version, bootstrap protocol, and effective persistent -configuration are verified. - -Hermes starts MCP discovery in the background, so an early lifecycle hook can -arrive before the MCP process finishes acquiring the gateway. The installed -hook waits for that MCP-owned gateway, verifies it, and forwards the original -payload once. It never launches or recovers Relay. - -Concurrent Hermes, Claude Code, and Codex MCP clients can share one compatible -gateway. Every open MCP client heartbeats the gateway. A startup lock and -recovery record permit one coordinated restart across overlapping MCP clients. -A second failure exits those MCP clients with an error. After the last client -closes, the gateway exits after 300 idle seconds by default. Set -`NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS` before setup and launch to choose another -positive timeout. - -Persistent MCP mode reads only system and user Relay configuration. It does not -load a project's `.nemo-relay` layer. Use transparent run mode for -project-specific Relay configuration. - -Relay removes or replaces Hermes MCP entries, hooks, and approvals only when -the canonical executable, generation fence, and complete required hook set form -one coherent installed identity. Manual or partial lookalike entries are -preserved. Rename or remove a conflicting manual `mcp_servers.nemo-relay` entry -before installation. Legacy entries are migrated only when their MCP entry and -complete hook set use the same canonical Relay executable. - -## Route Model Traffic - -Hermes API hooks can provide sanitized model request and response data. Route -provider traffic through Relay when you also need direct gateway observation -of the provider exchange. - -For an OpenAI-compatible provider, point Hermes at the fixed Relay address: +## Shared Config -```bash -export OPENAI_BASE_URL=http://127.0.0.1:47632/v1 +Create `.nemo-relay/config.toml` for project defaults or +`~/.config/nemo-relay/config.toml` for user defaults: + +```toml +[agents.hermes] +command = "hermes" ``` -Configure Relay's upstream separately so it does not route back to itself: +Then configure observability with `nemo-relay plugins edit --project` or +`.nemo-relay/plugins.toml`: ```toml -[upstream] -openai_base_url = "https://api.openai.com/v1" +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = true +output_directory = ".nemo-relay/atif" + +[components.config.atof] +enabled = true +output_directory = ".nemo-relay/atof" + +[components.config.openinference] +enabled = true +endpoint = "http://127.0.0.1:4318/v1/traces" ``` -Use the corresponding Anthropic-compatible base URL when Hermes uses the -Messages API. Relay does not rewrite Hermes provider settings during setup -because those settings are provider-specific and user-owned. +Run `nemo-relay run --agent hermes` to use the configured command and plugin +config. User config takes priority over project and system config. -## Lifecycle Hook Coverage +## Hermes Hook Setup -Relay installs these Hermes hooks: +Unlike the other agents, Hermes reads hooks from `.hermes/config.yaml`. The +setup wizard writes that file for you when you select hermes — running +`nemo-relay config` (or `nemo-relay config hermes` to scope to one agent) merges +NeMo Relay hook commands into the YAML, preserving any existing config, and +records the path under `[agents.hermes].hooks_path` in `.nemo-relay/config.toml`. -- Session: `on_session_start`, `on_session_end`, `on_session_finalize`, and - `on_session_reset`. -- Model: `pre_api_request`, `post_api_request`, `api_request_error`, - `pre_llm_call`, and `post_llm_call`. -- Tool: `pre_tool_call` and `post_tool_call`. -- Subagent: `subagent_start` and `subagent_stop`. +The generated Hermes hooks cover `on_session_start`, `on_session_end`, +`on_session_finalize`, `on_session_reset`, `pre_llm_call`, `post_llm_call`, +`pre_api_request`, `post_api_request`, `api_request_error`, `pre_tool_call`, +`post_tool_call`, `subagent_start`, and `subagent_stop`. -The API-request hooks are authoritative for hook-backed model lifecycle. -`pre_llm_call` and `post_llm_call` remain private correlation hints. When model -traffic also passes through the gateway, Relay correlates the hook and provider -signals instead of creating a second model call. +The API hooks are the main Hermes LLM lifecycle path for NeMo Relay. The legacy +LLM hooks remain installed because they can still provide useful private hints, +but they are not treated as equal peers to the API hooks in the observability +contract. -## Transparent Run +Hermes hook forwarding prefers `NEMO_RELAY_GATEWAY_URL` when set (this is what +`nemo-relay hermes` injects on every run). When launched outside the wrapper — +e.g., bare `hermes` against a long-running gateway — the hook command falls +back to `--gateway-url http://127.0.0.1:4040`. -Use the wrapper for project-specific Relay configuration or an isolated -per-process gateway: +For standalone gateway mode, start the daemon manually: ```bash -nemo-relay hermes -nemo-relay hermes -- chat --provider custom +nemo-relay --bind 127.0.0.1:4040 ``` -This mode starts a gateway on an OS-assigned loopback port and exports -`NEMO_RELAY_GATEWAY_URL` to Hermes. Relay creates a process-private -`HERMES_HOME` overlay containing the dynamic hooks and no fixed MCP entry, while -linking the rest of the active Hermes profile state. The user config is never -rewritten, so concurrent transparent and bare Hermes processes cannot observe -each other's temporary hook configuration. The overlay is removed after the -wrapped process exits. On Windows, profile directories use non-privileged -directory junctions rather than symbolic links, so Developer Mode or elevation -is not required. +Then point Hermes provider traffic at `http://127.0.0.1:4040` for any provider +mode that exposes a local OpenAI-compatible or Anthropic-compatible base URL. +This is the practical routed-provider validation path today. + +Important distinction: + +- Wrapped execution is the authoritative path for Hermes hook-path validation. +- Wrapped execution does not automatically rewrite Hermes provider `base_url`, + `custom_providers`, or `api_mode`. +- Routed `/v1/messages`, `/v1/chat/completions`, or `/v1/responses` validation + therefore requires explicit Hermes provider configuration in addition to the + wrapped or standalone gateway. + +## Smoke Test -Inspect the launch without changing files or starting Hermes: +Use this smoke test to verify the Hermes hook-forward path without launching +Hermes. It sends one synthetic Hermes hook payload through the same +`nemo-relay hook-forward hermes` command that generated hooks use. + +Start a standalone gateway in one terminal: ```bash -nemo-relay run --agent hermes --dry-run --print -- hermes +nemo-relay --bind 127.0.0.1:4040 ``` -## Remove the Integration - -Remove the Relay-owned Hermes MCP, hooks, trust, and generation state with: +Then check Hermes hook forwarding from another terminal: ```bash -nemo-relay uninstall hermes +curl -f http://127.0.0.1:4040/healthz +printf '{"session_id":"smoke-hermes","hook_event_name":"on_session_start"}' \ + | NEMO_RELAY_GATEWAY_URL=http://127.0.0.1:4040 nemo-relay hook-forward hermes --fail-closed ``` -Unrelated Hermes configuration remains unchanged. The interactive -`nemo-relay config hermes` and `nemo-relay config --reset hermes` paths manage -only Relay's transparent-wrapper configuration; persistent Hermes MCP, hook, -and trust state is owned exclusively by `install` and `uninstall`. +The response should be `{}`. If Hermes prompts for hook consent, approve the +NeMo Relay hook command interactively or through Hermes configuration before +relying on unattended capture. -## Validate End-to-End Capture +## Verify Export -When a compatible Hermes CLI is installed, run the opt-in cold-start test: +End a Hermes turn or finalize the session and confirm the configured exporters +received data: ```bash -just test-hermes-mcp-e2e +ls .nemo-relay/atof +ls .nemo-relay/atif ``` -The test uses isolated home directories and a local model provider. It runs 10 -cold one-shot sessions plus two concurrent sessions and verifies provider -authorization, shared gateway lifetime, exactly-once model and turn scopes, -session isolation, balanced ATOF output, hook trust, and final port release. -It is intentionally outside mandatory Rust CI. - -## Troubleshoot +The gateway writes or updates an ATIF snapshot when it receives +`on_session_end`, `on_session_finalize`, or `on_session_reset`. +`on_session_end` is a per-turn snapshot boundary: it does not close the NeMo +Relay session and does not emit a visible trajectory mark. `on_session_finalize` +and `on_session_reset` close the session. When ATOF export is enabled, the raw +event stream is written continuously as lifecycle events arrive. -If Hermes reports that the MCP server failed to start, run -`nemo-relay doctor --plugin hermes` and inspect the reported configuration or -endpoint error. Detached gateways do not create persistent log files. A foreign -listener on `127.0.0.1:47632` is rejected rather than adopted. +## Troubleshoot LLM Lifecycle -If lifecycle events appear but direct model spans do not, check whether the -Hermes provider base URL points at Relay. If model spans attach to the parent -instead of a subagent, preserve Hermes correlation identifiers such as -`task_id`, `turn_id`, `api_request_id`, and `tool_call_id` in the hook payloads. +If hook events appear but LLM spans are missing, Hermes model traffic is not +routed through the gateway. If LLM spans exist but attach to the top-level agent +instead of a subagent, include shared identifiers in Hermes hook payloads and +gateway requests, such as `conversation_id`, `generation_id`, `request_id`, or +`x-nemo-relay-subagent-id`. diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index d3d8538bb..920887fee 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -1,54 +1,35 @@ --- -title: "Coding Agent Installation" -description: "Install and manage persistent NeMo Relay integrations for Claude Code, Codex, and Hermes Agent." +title: "Plugin Installation" +description: "Install and manage NeMo Relay host plugins for Claude Code and Codex." position: 3 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} -Install a persistent integration when you want Claude Code, Codex, or Hermes -Agent to load NeMo Relay without a `nemo-relay` wrapper command. Claude Code -and Codex use their normal plugin systems. Hermes uses the same native MCP -lifecycle through its user configuration without a marketplace plugin. +Install a host plugin when you want Claude Code or Codex to load NeMo Relay +through its normal plugin system instead of through a `nemo-relay` wrapper +command. -The persistent integration emits agent, subagent, tool, prompt, compaction, and -stop lifecycle signals. Model-provider routing sends LLM traffic through the -local NeMo Relay gateway. Hooks alone cannot capture complete LLM request and +The installed plugin emits agent, subagent, tool, prompt, compaction, and stop +lifecycle signals. Model-provider routing sends LLM traffic through the local +NeMo Relay gateway. Hooks alone cannot capture complete LLM request and response spans. -All three integrations use the same runtime shape: - -```text -Codex / Claude Code / Hermes Agent - -> Relay MCP lifecycle client - -> shared Relay Gateway - -> model provider -``` - -Each agent starts its own lightweight MCP stdio process. Those processes share -one compatible gateway. Generated lifecycle hooks use `hook-forward` to deliver -their canonical payloads to that same gateway. - ## Requirements Install `nemo-relay` and ensure that it is available on `PATH` or `%PATH%`. -The installer does not download a second Relay binary, install a daemon, or -require an integration-local executable. +The plugin installer does not download a second Relay binary, install a daemon, +or require a plugin-local executable. -The selected coding-agent CLI must also be available at a supported version: +The selected host CLI must also be available: -- Claude Code 2.1.121 or newer through `claude`. -- `codex-cli` 0.143.0 or newer through `codex`. -- Hermes Agent 0.18.2 or newer through `hermes`. +- `claude` for Claude Code plugin installation. +- `codex` for Codex plugin installation. -Relay checks the selected CLI version before modifying agent configuration. -Prerelease, malformed, and older versions are rejected consistently by install, -doctor, and transparent launch paths. +## Install Host Plugin -## Install a Persistent Integration - -Run the command for the coding-agent integration that you want to install: +Run the command for the host plugin that you want to install: ```bash nemo-relay install claude-code @@ -58,18 +39,14 @@ nemo-relay install claude-code nemo-relay install codex ``` -```bash -nemo-relay install hermes -``` - Install every supported host detected on the machine: ```bash nemo-relay install all ``` -`install all` selects only supported agents whose CLI is present. It fails if -Claude Code, Codex, and Hermes Agent are all absent. +`install all` selects only hosts whose CLI is present. It fails if neither +Claude Code nor Codex is detected. Use `--dry-run` to inspect the generated marketplace paths and host commands without writing files or changing host configuration: @@ -78,9 +55,8 @@ without writing files or changing host configuration: nemo-relay install codex --dry-run ``` -For Claude Code and Codex, use `--install-dir` when you need a non-default -marketplace location. Hermes always updates its user configuration. The default -plugin directory is platform-specific: +Use `--install-dir` when you need a non-default marketplace location. The +default directory is platform-specific: | Platform | Default Plugin Install Directory | | --- | --- | @@ -88,151 +64,38 @@ plugin directory is platform-specific: | Linux | `${XDG_DATA_HOME:-~/.local/share}/nemo-relay/plugins` | | Windows | `%LOCALAPPDATA%\nemo-relay\plugins` | -Install, force-install, rollback, and uninstall operations are serialized per -user and host, including operations that name different install directories. -If another operation is active, Relay waits briefly and then returns an -actionable timeout instead of changing host-global plugin state concurrently. - ## What Install Changes -For Claude Code and Codex, `nemo-relay install` writes a local marketplace named -`nemo-relay-local`, then registers the generated `nemo-relay-plugin` package -with the selected host. +`nemo-relay install` writes a local marketplace named `nemo-relay-local`, then +registers the generated `nemo-relay-plugin` package with the selected host. For Claude Code, install registers the local Claude marketplace, installs `nemo-relay-plugin@nemo-relay-local` at user scope, and enables provider routing through the local NeMo Relay sidecar. During installation, NeMo Relay preserves existing Claude authentication and model settings and backs them up only when -it adds the Relay provider route. The plugin also declares the native -`nemo-relay mcp` lifecycle client. +it adds the Relay provider route. For Codex, install registers the local Codex marketplace, installs -`nemo-relay-plugin@nemo-relay-local`, enables Codex hooks, and configures the -`nemo-relay-openai` provider alias at `http://127.0.0.1:47632`. The plugin's -`hooks/hooks.json` is the sole persistent Relay hook source. The installer uses -the Codex app-server API to select hooks by plugin ID, exact canonical command, -and event definition. It never trusts unrelated user, project, or plugin hooks. -If Codex does not discover exactly one enabled and trusted handler for every -generated event—`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, -`PermissionRequest`, `SubagentStart`, `SubagentStop`, `Stop`, `PreCompact`, and -`PostCompact`—installation restores the previous Codex config, legacy hooks, -Relay backup files, and every original targeted trust entry. These 10 events are -the complete supported Codex 0.143 plugin hook schema; Relay does not generate -undiscoverable `PostToolUseFailure`, `Notification`, or `SessionEnd` handlers. -Upgrade removes legacy Relay groups from -`~/.codex/hooks.json` while preserving unrelated hooks. - -For Hermes, install transactionally merges the Relay MCP server, canonical -hooks, exact hook trust, and environment references into -`$HERMES_HOME/config.yaml`, or `~/.hermes/config.yaml` when `HERMES_HOME` is -unset. It preserves unrelated Hermes configuration. Refer to the -[Hermes Agent guide](/nemo-relay-cli/hermes). - -On Windows, the generated hook string uses the built-in Windows PowerShell -encoded-command envelope. This keeps the outer command free of quoting and -metacharacters across the different Codex, Claude Code, and Hermes command -runners. The encoded payload contains only the canonical `nemo-relay.exe` path -and `hook-forward` arguments. PowerShell launches that Rust binary directly -and preserves its standard input, standard output, standard error, and exit -code; the MCP client and gateway remain Rust-native. Install and doctor verify -the exact generated command and its lifecycle-event ownership. - -Start a new Codex CLI process after installation. Restart the Codex desktop app -if it was already running so it reloads the provider and hook configuration. - -Claude Code, Codex, and configured Hermes processes use the same -`nemo-relay mcp` lifecycle client. It immediately starts or reuses a detached -native sidecar on `127.0.0.1:47632`, before reading MCP protocol frames, -verifies Relay identity, version, protocol readiness, and effective user-level -configuration, and heartbeats it every 30 seconds. Concurrent processes from -any host share the gateway. After the final MCP client closes, the sidecar -exits after 300 idle seconds by default. If the gateway exits while MCP stdio is -open, the client performs one coordinated restart and fails if recovery does -not succeed. A foreign process on the configured port is rejected with an -actionable conflict instead of being accepted because it returned HTTP 200. -Health and readiness include a per-process instance ID. A per-user startup lock -serializes launch and recovery, and a small recovery record ensures overlapping -MCP clients share the same single restart. The gateway server itself owns -activity tracking, idle shutdown, and cleanup of its authenticated ownership -record. - -Codex marks the MCP server as required, so the captured turn waits for verified -gateway readiness. Claude Code 2.1.121 or newer uses `alwaysLoad`, which blocks -session startup until the MCP connection is ready. Hermes starts MCP discovery -asynchronously, so its first lifecycle hook can still run first. Every -installed MCP entry and `hook-forward` command carry both the generation-file -path and the immutable identity expected at that path. A host process that -cached an older plugin configuration therefore cannot adopt a replacement -installation after `--force`. Persistent hooks wait for the MCP-owned gateway, -verify it on the same connection used for delivery, and send the canonical -payload once. They never start or recover the gateway. Relay does not retry -after payload transmission begins. -The MCP server advertises no tools in any host. - -A fixed-endpoint `hook-forward` command without a valid installer-owned -generation fence fails according to the configured hook failure policy before -it contacts or launches the gateway. This prevents a legacy hook retained by a -host process from reviving a retired installation. Run `nemo-relay install - --force` to replace an unfenced persistent hook. Transparent hook -commands embed their process-private dynamic gateway URL and do not recover a -persistent gateway, so they do not require this fence. Source plugins and custom -automations can opt into `--forward-only` to contact an already-running gateway -without a fence. That mode waits for an authenticated, configuration-compatible -Relay gateway and rejects foreign or incompatible listeners before sending the -payload. Authentication and delivery use the same TCP connection, so a process -that replaces the listener after a health probe cannot receive lifecycle data. -The mode never launches or recovers Relay. - -Relay does not claim crash-consistent exactly-once delivery -across a gateway failure after request acceptance: there is no durable hook -outbox or delivery-deduplication journal. The opt-in E2E assertions below verify -exactly-once lifecycle output for the exercised healthy and cold-start paths, -not arbitrary process crashes between request acceptance and response delivery. - -On Windows, Relay requests Job Object breakaway when the host job permits it. -When breakaway is unavailable, the gateway remains scoped to the host job and -can exit before the normal idle reuse window. - -The compatibility fingerprint covers resolved provider, exporter, dynamic -plugin, and relevant environment settings without exposing their values in the -health response. For Claude Code and Codex, -`nemo-relay install --force` rotates the immutable MCP and hook generation -identity, then uses a private ownership token to stop the shared fixed-endpoint -gateway as one serialized operation. -Ownership is keyed by endpoint, not host, so an upgrade -from any installed host can retire a gateway originally started by another. -Custom managed endpoints are left untouched. Relay never sends its shutdown -token to an unrelated listener. Hermes installation transactionally rotates -its MCP generation fence. - -Persistent mode resolves only system and user Relay configuration and starts in -the user configuration directory. Project `.nemo-relay` layers remain available -through transparent `nemo-relay run` invocations. Codex requires an explicit -forwarding allowlist, so its generated MCP manifest stores variable names—not -values—for provider credentials, Relay runtime, OpenTelemetry, AWS, proxies, -certificates, approved prefixes, and credential variables referenced by user -observability configuration. Claude Code supplies its normal environment to -plugin MCP processes. Hermes config stores `${VARIABLE_NAME}` references for -the complete standard allowlist, currently defined custom approved variables, -and explicit credential names referenced by user observability configuration. -Relay treats unresolved self-references as unset before parsing its command -line. Rerun `nemo-relay install hermes --force` after adding a custom approved -name or changing a config-referenced credential name. -The long-lived managed sidecar substitutes a forwarded provider credential only -when a request carries provider authorization or Relay's private per-user -client proof. The Codex installer writes a domain-separated HMAC proof into the -managed provider's `http_headers`; Relay stores the resulting Codex config with -an owner-only mode on Unix or protected owner/System DACL on Windows before the -temporary file becomes visible. Relay validates and removes that header before -middleware, observability, or upstream forwarding. The underlying HMAC key -remains in Relay's owner-only bootstrap state. Claude Code and Hermes send -their normal provider authorization. A foreign loopback caller cannot spend -the sidecar's forwarded credentials. Foreground `nemo-relay --bind` use retains -environment-key injection for explicit local proxy workflows. +`nemo-relay-plugin@nemo-relay-local`, enables Codex hooks, merges generated hook +entries, and configures the `nemo-relay-openai` provider alias at +`http://127.0.0.1:47632`. + +Codex plugin mode is hook-supervised on-demand startup only. It does not install a +Codex wrapper, user-level daemon, launch agent, system user service, +scheduled task, login item, or persistent supervisor. The sidecar starts when an +installed Codex hook runs, reuses an already healthy sidecar when one exists, +and exits after its idle timeout. + + +The plugin captures the first provider request only when Codex fires an +installed hook before that request. If a Codex version calls the provider +before any hook, the plugin cannot guarantee first-request capture under +hook-only lazy startup. + ## Diagnose -Run the command for the installed integration that you want to diagnose: +Run the command for the installed host that you want to diagnose: ```bash nemo-relay doctor --plugin claude-code @@ -242,36 +105,15 @@ nemo-relay doctor --plugin claude-code nemo-relay doctor --plugin codex ``` -```bash -nemo-relay doctor --plugin hermes -``` - ```bash nemo-relay doctor --plugin all ``` -`nemo-relay doctor` includes every persistent integration it finds. It checks -generated marketplace and plugin files where applicable, the Relay binary and -hook support, agent version, registration, provider routing, hooks, and sidecar -readiness assumptions. For Codex, doctor also asks the Codex app-server whether -each exact Relay-generated hook is trusted and enabled. JSON output includes -`checks.codex_hooks_trusted` and a -`codex_hook_trust` object that groups trusted, untrusted, modified, disabled, -missing, or duplicated required hooks. A stopped Codex sidecar is informational; -the required plugin MCP starts it before the captured turn. Doctor also reports -newly required forwarded environment names and recommends -`nemo-relay install codex --force`. - -For Claude Code, doctor also validates version 2.1.121 or newer, the generated -`alwaysLoad` MCP server, and its generation marker. A stopped sidecar remains -informational because the next MCP process start or hook retry starts it. - -For Hermes, doctor validates Hermes Agent 0.18.2 or newer. It reads the Relay -executable from the managed Hermes MCP entry, verifies that exact executable -supports both `mcp` and `hook-forward`, and then checks every hook and trust -pair, the generation marker, and environment references. Top-level -`nemo-relay doctor` discovers this managed state directly from the Hermes user -configuration; an `[agents.hermes]` Relay configuration block is not required. +`nemo-relay doctor` includes every persistent host-plugin installation found in +the default platform install directory. It checks the generated marketplace and +plugin files, Relay binary and hook support, host registration, provider +routing, hooks, and lazy-sidecar assumptions. A stopped Codex lazy sidecar is +informational; hooks start it on first use. Use the focused plugin doctor when diagnosing one host or an installation that uses a custom directory: @@ -280,14 +122,14 @@ uses a custom directory: nemo-relay doctor --plugin codex --install-dir /path/to/plugins ``` -If an installed integration is incomplete, doctor reports the failed check and -suggests `nemo-relay install --force`. Agents without a persistent -installation remain informational, so transparent-run setup does not require -one. +If an installed host plugin is incomplete, doctor reports the failed check and +suggests `nemo-relay install --force`. Hosts without a persistent plugin +installation remain informational, so transparent-run setup does not require a +host plugin. ## Uninstall -Run the command for the installed integration that you want to remove: +Run the command for the installed host plugin that you want to remove: ```bash nemo-relay uninstall claude-code @@ -297,68 +139,14 @@ nemo-relay uninstall claude-code nemo-relay uninstall codex ``` -```bash -nemo-relay uninstall hermes -``` - ```bash nemo-relay uninstall all ``` -Uninstall removes Codex provider configuration and trust for the exact plugin -hooks before unregistering the plugin, while Codex can still report its hook -metadata. It then removes the host registration and marketplace. Claude Code -provider routing is restored from the Relay backup. Unrelated user hooks and -configuration remain unchanged. Hermes uninstall removes only Relay-owned MCP, -hook, trust, and generation state. - -## Compatibility and Migration - -This integration now requires Codex 0.143.0, Claude Code 2.1.121, or Hermes -Agent 0.18.2 at minimum. Prerelease and malformed version output is rejected. -Configured opaque wrappers must identify the selected host through their -canonical `--version` output. Interactive `nemo-relay config hermes` now owns -only transparent-wrapper configuration; use `nemo-relay install hermes` and -`nemo-relay uninstall hermes` for persistent Hermes state. - -The MCP bootstrap is now host-neutral. Generated Codex, Claude Code, and Hermes -configuration invokes exactly `nemo-relay mcp`; the removed `--agent` option no -longer parses. A forced install recognizes and replaces the former -`mcp --agent claude|codex|hermes` entries. Reinstall each persistent integration -with `nemo-relay install --force` before relying on generated config from -this release. - -This release removes the internal `nemo-relay plugin-shim` command. Existing -generated installations should be refreshed with -`nemo-relay install --force`. Use the following public replacements for -custom automation: - -| Removed internal command | Public replacement | -| --- | --- | -| `plugin-shim hook ` | `hook-forward --forward-only` with the canonical lifecycle payload on standard input and an existing gateway | -| `plugin-shim serve` | `mcp` for host-managed lifecycle, or `--bind
` to run a gateway directly | -| `plugin-shim install ` | `install ` | -| `plugin-shim uninstall ` | `uninstall ` | -| `plugin-shim provider claude enable` | `install claude-code` | -| `plugin-shim provider claude restore` | `uninstall claude-code` | -| `plugin-shim provider claude status` | `doctor --plugin claude-code` | -| `plugin-shim doctor ` | `doctor --plugin ` | - -Persistent generated hook commands include the fixed gateway URL. Transparent -wrapper hooks embed their dynamic gateway URL, while the process environment -lets an installed plugin MCP authenticate, borrow, and monitor that exact -gateway. Transparent hook delivery authenticates the wrapper gateway before -writing its lifecycle payload. When a -transparent run uses a recognizable wrapper command, such as `npx codex`, Relay -checks the host version through that wrapper. Opaque custom wrappers remain -supported when invoking the configured command with `--version` emits the -selected host's canonical version output. Install and doctor enforce the same -minimum-version policy. - -Hermes setup uses the same public install, doctor, and uninstall commands as -Claude Code and Codex. `nemo-relay config hermes` configures Relay's transparent -wrapper only; it no longer mutates Hermes user configuration. Use -`nemo-relay install hermes` for the persistent host integration. +Uninstall removes the generated host plugin registration and marketplace entry, +restores Claude Code provider routing from the Relay backup, and removes +generated Codex hook/provider configuration, while preserving unrelated user +configuration. ## Source Marketplace Discovery @@ -369,8 +157,8 @@ validation: - `.agents/plugins/marketplace.json` Those manifests are useful when validating host plugin metadata from a source -checkout. For end-user setup, use `nemo-relay install `. For Claude Code -and Codex, the command generates the local marketplace, registers the host -plugin, and performs the required provider and hook setup. Source manifests do -not provide Codex's complete provider, environment-forwarding, and -verified-trust workflow. +checkout. For end-user setup, use `nemo-relay install `. It generates the +local marketplace, registers the host plugin, and performs the required +provider and hook setup. Avoid keeping both a source-installed +plugin and a generated install active for the same host because both can forward +the same hook payload. diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 522c0261d..9a9c09a0c 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -23,20 +23,17 @@ environment variables, or shared TOML config. ## Packages - `claude-code/` is a Claude Code plugin package. The - `nemo-relay install claude-code` command installs a native MCP lifecycle - client and hook entries targeting `POST /hooks/claude-code` through - `nemo-relay` on `PATH`. + `nemo-relay install claude-code` command installs hook entries targeting + `POST /hooks/claude-code` through `nemo-relay` on `PATH`. - `codex/` is a Codex plugin package. `nemo-relay install codex` creates the marketplace, installs the plugin, enables `features.hooks = true`, and configures a local `nemo-relay-openai` provider alias. Codex plugin delivery - uses required native `nemo-relay mcp` lifecycle clients. Claude Code starts - the same lifecycle client automatically from its plugin. Clients from either - host share one Rust gateway, subject to the Windows Job Object lifetime - caveat below, with no wrapper, login item, launchd agent, systemd user - service, scheduled task, or persistent supervisor. -- Hermes does not require a static marketplace bundle. `nemo-relay install - hermes` transactionally merges a native MCP lifecycle client, canonical - hooks, and exact per-event trust into the user-owned Hermes config. + uses hook-supervised lazy sidecar startup only, with no wrapper, user-level daemon, + login item, launchd agent, systemd user service, scheduled task, or persistent + supervisor. +- Hermes does not require a static bundle in this directory. The setup wizard + (`nemo-relay config`) merges hook commands into `.hermes/config.yaml` when + hermes is selected. ## Transparent Setup @@ -52,10 +49,9 @@ nemo-relay run -- codex nemo-relay run -- hermes ``` -When a wrapper hides the agent command name, configure that wrapper under -`[agents.].command` and select it with -`--agent claude|codex|hermes`. Use `--dry-run --print` to inspect generated -config without launching. +Use `--agent claude|codex|hermes` when a wrapper hides the agent +command name. Use `--dry-run --print` to inspect generated config without +launching. Use `nemo-relay doctor` to inspect environment, config, agent commands, hook readiness, observability outputs, and shell completions. Scope the report to one @@ -70,76 +66,34 @@ nemo-relay doctor hermes --json The command is read-only: it reports missing ATIF directories, hook files, and agent commands instead of creating or patching them. -## Persistent Integration Installation +## Plugin Installation The Claude Code and Codex plugins are installed by the `nemo-relay` CLI. The CLI must already be installed and discoverable on `$PATH` or `%PATH%`; no separate npm installer, release bundle download, or plugin-local Relay binary is required. -Persistent installation and transparent launch require Claude Code 2.1.121 or -newer, `codex-cli` 0.143.0 or newer, or Hermes Agent 0.18.2 or newer for the -selected agent. - -Each plugin MCP entry—and the equivalent Hermes `mcp_servers` entry—starts -`nemo-relay mcp`, a lightweight client that starts or reuses a native -`nemo-relay --bind 127.0.0.1:47632` sidecar. Relay detaches the sidecar when -host policy permits. A restrictive Windows Job Object can limit the sidecar to -the host job; bootstrap fails actionably when nested assignment cannot provide -the required process-tree cleanup guarantee. The MCP -process acquires the gateway immediately, before reading protocol frames, and -returns its initialization response only after Relay identity, version, and -bootstrap-protocol readiness are verified. Concurrent Codex, Claude Code, and -Hermes processes share the gateway and heartbeat it while their MCP stdio -connections remain open; the gateway exits after the final client's idle -timeout. Process-held MCP and hook leases share one endpoint recovery cohort, -which permits only one coordinated restart across all overlapping participants, -including staggered heartbeats. Codex requires -MCP initialization before the captured turn. Claude -Code marks Relay MCP as `alwaysLoad`, so it also waits for the connection before -session startup. Hermes starts MCP discovery asynchronously, so its -generation-fenced command hook temporarily joins the same recovery cohort for -an early hook. Installed MCP entries and hook commands carry both their -generation-file path and the immutable identity expected there, so cached host -configuration cannot adopt a replacement installation at the same path. The -MCP client advertises no tools. - -MCP bootstrap is deliberately host-neutral: all three generated integrations -use the exact `nemo-relay mcp` command. Agent identity remains only in lifecycle -hook commands, where Relay needs it to translate each host's canonical payload. -Legacy generated `mcp --agent ` entries are recognized during forced -upgrade and replaced with the single current contract. - -Persistent mode loads system and user Relay configuration only and starts the -sidecar from the user configuration directory. Relative exporter paths are -therefore stable across projects. Codex's generated MCP manifest forwards -approved provider, Relay, OpenTelemetry, AWS, proxy, certificate, and -config-referenced credential environment names without storing their values; -Claude Code supplies its normal MCP process environment. Use transparent -`nemo-relay run` for project-specific configuration. The managed sidecar -injects a forwarded provider key only for a request with provider authorization -or Relay's private per-user client proof. Codex receives that derived proof in -its managed provider headers; the installer writes that config privately and -Relay consumes the proof before middleware, telemetry, or upstream forwarding. -Claude Code and Hermes send their normal provider -authorization, so an unrelated loopback caller cannot spend forwarded keys. - -Install the persistent integrations with: +Claude Code can start the sidecar from plugin hooks or helper commands and route +model traffic by setting `ANTHROPIC_BASE_URL` to the sidecar URL. + +Codex does not use a daemon in plugin mode. The installed Codex hooks call the +`nemo-relay plugin-shim hook codex` command. The shim then checks `/healthz`, starts +the local `nemo-relay` sidecar, if needed, waits briefly for readiness, and then +forwards the hook payload. Codex model traffic is routed through the stable +provider alias at `http://127.0.0.1:47632`. + +Install the local host marketplaces with: ```bash nemo-relay install claude-code nemo-relay install codex -nemo-relay install hermes nemo-relay install all ``` -For Claude Code and Codex, `nemo-relay install` writes local marketplace files, -registers the selected host plugin, and performs the required provider and hook -setup. For Hermes, `install` is the only command that updates Relay-owned user -MCP, hook, trust, and generation state; interactive `config hermes` manages -only the transparent wrapper. Use -`nemo-relay uninstall ` to roll back and -`nemo-relay doctor --plugin ` to check an installed integration. +`nemo-relay install` writes local marketplace files, registers the selected host +plugin, and performs the required host provider and hook setup. Use +`nemo-relay uninstall ` to roll back and `nemo-relay doctor --plugin +` to check an installed plugin. If you are using Codex, add this repository as a marketplace for source/dev discovery: @@ -149,17 +103,12 @@ codex plugin marketplace add NVIDIA/NeMo-Relay codex plugin add nemo-relay-plugin@nemo-relay ``` -That path relies on `nemo-relay` being available on `PATH`. Source plugin hooks -use `nemo-relay hook-forward codex --forward-only`: they post to the gateway -started by the required MCP entry but cannot launch or recover Relay without an -installer-owned generation fence. Before posting, they authenticate the Relay -identity and verify that its user-level configuration matches. The proof and -payload use one TCP connection, preventing a replacement listener from -receiving the payload after verification. +That path relies on `nemo-relay` being available on `PATH`; source plugin hooks +invoke `nemo-relay plugin-shim hook codex` directly. -Use the source marketplace path for discovery or manifest validation. Use -`nemo-relay install codex` for complete provider routing, environment -forwarding, and verified plugin-hook trust. +Use the source marketplace path for discovery or manifest validation. Remove +the source-installed Codex plugin before running `nemo-relay install codex`; +keeping both active can forward the same Codex hook twice. Claude Code users can add this repository as a marketplace the same way: @@ -170,31 +119,19 @@ claude plugin install nemo-relay-plugin@nemo-relay --scope user ``` That path reads `.claude-plugin/marketplace.json` from the repository. Source -plugin hooks use `nemo-relay hook-forward claude --forward-only`: they post to -the gateway started by the `alwaysLoad` MCP entry but cannot launch or recover -Relay without an installer-owned generation fence. They authenticate that -gateway on the same connection used to send lifecycle data. Use `nemo-relay install -claude-code` for the complete provider-routing setup. - -Hermes persistent installation is user-level: - -```bash -nemo-relay install hermes -``` +plugin hooks invoke `nemo-relay plugin-shim hook claude` directly. Use +`nemo-relay install claude-code` for the complete provider-routing setup. -It writes the MCP server and trusted hooks to `$HERMES_HOME/config.yaml` or -`~/.hermes/config.yaml`. Transparent Hermes runs leave that file untouched and -export the dynamic `NEMO_RELAY_GATEWAY_URL` through a process-private -`HERMES_HOME` overlay with no fixed MCP entry. +Hermes transparent runs export the dynamic `NEMO_RELAY_GATEWAY_URL`, but Hermes +hooks must already be present in `.hermes/config.yaml` before they can call the +gateway. The setup wizard (`nemo-relay config`) writes that file for you when +you select hermes. Shared TOML config is loaded from `/etc/nemo-relay/config.toml`, then nearest project `.nemo-relay/config.toml`, then `$XDG_CONFIG_HOME/nemo-relay/config.toml` or `~/.config/nemo-relay/config.toml`. -That layering applies to transparent runs. Persistent mode skips the -project layer and merges only system and user configuration. - ```toml [agents.codex] command = "codex" @@ -233,42 +170,32 @@ destination records, logs, or reports the failure. ## Hook Forwarding -Transparent Claude Code and Codex hooks call -`nemo-relay hook-forward ` with the canonical hook payload on standard -input. The wrapper-owned command embeds the ephemeral per-run gateway URL and -is marked as transparent so it never starts or recovers the fixed gateway. - -Persistent Claude Code and Codex hooks, and Hermes hooks in both modes, call -`nemo-relay hook-forward `. During a transparent Hermes run, the same -canonical command prefers the wrapper's dynamic gateway URL. Otherwise, it -preflights, starts, or recovers the fixed shared gateway. +The transparent wrapper hooks call `nemo-relay hook-forward ` with the +canonical hook payload on stdin. The wrapper injects `NEMO_RELAY_GATEWAY_URL` so +the same hook command reaches the ephemeral per-run gateway; hermes hooks fall +back to an embedded `--gateway-url` when running outside the wrapper. -For Codex, the installed plugin file is the sole persistent Relay hook source; -installation does not add Relay groups to `~/.codex/hooks.json`. +Claude Code and Codex plugin hooks call `nemo-relay plugin-shim hook `. +The plugin shim ensures the local sidecar is reachable, then forwards the hook +payload to the plugin sidecar endpoint. Since hook forwarding fails open by default, gateway or sidecar outages do not block the coding agent. The hook command exits successfully after logging the forwarding problem, so the host agent can continue even though that hook payload may be missing from telemetry. For wrapper-generated `hook-forward` -commands, add `--fail-closed` when policy requires hook delivery to block the -agent. For generated persistent hooks, set `NEMO_RELAY_FAIL_CLOSED=1` in the hook -execution environment. In that mode, forwarding failures return a non-zero -hook command status to the host. - -Useful `hook-forward` options: - -- `--gateway-url ` selects the Relay gateway that receives the payload. -- `--forward-only` allows a source plugin or custom automation to use an - existing compatible gateway without an installer-owned generation fence. It - verifies the gateway but never launches or recovers Relay. Generated - installed hooks use a private generation fence instead. +commands, add +`--fail-closed` when policy requires hook delivery to block the agent. For +plugin shim hooks, set `NEMO_RELAY_FAIL_CLOSED=1` in the hook execution +environment. In that mode, forwarding failures return a non-zero hook command +status to the host. + +Useful wrapper options: + - `--session-metadata ''` adds structured metadata to the agent begin event. - `--profile ` records a configuration profile in session metadata. - `--gateway-mode hook-only|passthrough|required` records the expected gateway behavior in session metadata. -- `--fail-closed` returns a failure when delivery fails or Relay rejects the - hook instead of allowing the coding agent to continue. ## LLM Gateway @@ -300,17 +227,3 @@ ls .nemo-relay/atif The gateway writes `.atif.json` when it receives a session-end hook for a session with ATIF configured. - -Run the opt-in host E2E targets when the corresponding CLI is installed. These -targets are intentionally outside `test-rust` and mandatory CI: - -```bash -just test-claude-plugin-e2e -just test-codex-plugin-e2e -just test-hermes-mcp-e2e -``` - -Each target uses an isolated home directory and local mock provider. The Claude -and Hermes targets each run 10 cold sessions plus two concurrent sessions and -verify MCP connection, hook delivery, provider routing, session isolation, -balanced ATOF output, and final port release. diff --git a/integrations/coding-agents/claude-code/README.md b/integrations/coding-agents/claude-code/README.md index 9ba64ad42..1fef347de 100644 --- a/integrations/coding-agents/claude-code/README.md +++ b/integrations/coding-agents/claude-code/README.md @@ -5,9 +5,8 @@ SPDX-License-Identifier: Apache-2.0 # NeMo Relay Plugin -This package contains a native MCP lifecycle client and Claude Code hook entries -that forward canonical Claude Code hook JSON to `nemo-relay` at -`/hooks/claude-code`. +This package contains Claude Code hook entries that forward canonical Claude +Code hook JSON to `nemo-relay` at `/hooks/claude-code`. Claude Code is the supported Claude integration target. Claude application, Claude web, and Claude desktop sessions are unsupported unless they expose the @@ -16,9 +15,8 @@ same local hook and gateway controls as Claude Code. ## Files - `.claude-plugin/plugin.json` describes the Claude Code hook package. -- `.mcp.json` starts the native `nemo-relay mcp` lifecycle client. - `hooks/hooks.json` contains hook entries that run - `nemo-relay hook-forward claude --forward-only`. + `nemo-relay plugin-shim hook claude`. ## Captured Events @@ -28,11 +26,10 @@ The bundle forwards `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `PreCompact`, and `PostCompact` as scope, tool, mark, or private LLM correlation events. -The bundle requires Claude Code 2.1.121 or newer. That version provides the -`alwaysLoad` MCP startup barrier used to make Relay ready before session hooks -and accepts the complete generated hook schema. Installation fails before -changing host state when the version is too old, and `nemo-relay doctor` -reports the required upgrade. +The bundle requires Claude Code 2.1.116 or newer. Older versions do not have +`UserPromptExpansion` in their hook-event whitelist and reject the entire +plugin hook configuration, so no relay hooks load. `nemo-relay doctor` reports +this condition. Claude Code observability is turn-oriented. A multi-turn session can produce one root `claude-code-turn` span or ATIF trajectory per user turn. That is expected @@ -53,15 +50,9 @@ nemo-relay run -- claude ``` The wrapper starts a per-invocation gateway on a dynamic localhost port, -creates a temporary Claude plugin directory, and passes it with `--plugin-dir`. -It also creates a private settings overlay that preserves the caller's first -explicit `--settings` object and overrides only `ANTHROPIC_BASE_URL` for the -launched process. Relay removes both temporary artifacts when Claude exits and -does not rewrite the source settings or user-level plugin state. An enabled -Relay plugin MCP authenticates, borrows, and monitors the wrapper-owned dynamic -gateway, and its persistent hooks become no-ops for this process; only the -temporary hook source delivers lifecycle data after authenticating that -gateway. +creates a temporary Claude plugin directory, passes it with `--plugin-dir`, sets +`ANTHROPIC_BASE_URL` for the launched process, and removes the temporary plugin +when Claude exits. Inspect the launch without starting Claude Code: @@ -177,16 +168,7 @@ nemo-relay install claude-code `nemo-relay install claude-code` writes a local Claude Code marketplace, installs `nemo-relay-plugin` at user scope, and enables Claude Code provider -routing through NeMo Relay. Its plugin MCP process immediately starts or reuses -the shared native gateway on `127.0.0.1:47632` and heartbeats it while MCP stdio -remains open. Codex, Claude Code, and configured Hermes MCP clients can share -that gateway. - -The generated MCP entry sets `alwaysLoad: true`. Claude Code therefore waits -for the MCP connection during startup, while `nemo-relay mcp` starts or reuses -the gateway immediately when its process launches. The command hook retains a -same-gateway recovery path for an unexpected runtime outage. The MCP server -advertises no tools and does not add tool definitions to Claude's context. +routing through NeMo Relay. No separate provider-routing command is required when installing through `nemo-relay install`. @@ -199,13 +181,10 @@ Package or unpack the plugin so the plugin root contains: ```text nemo-relay-plugin/ .claude-plugin/plugin.json - .mcp.json hooks/hooks.json ``` -Persistent mode uses system and user Relay configuration only and starts the -gateway in the user configuration directory. Use `nemo-relay run --agent -claude` when project-specific Relay configuration is required. +The hook shim starts the sidecar lazily if no gateway is already reachable. Repo marketplace discovery is also supported: @@ -217,11 +196,8 @@ claude plugin install nemo-relay-plugin@nemo-relay --scope user That path reads `.claude-plugin/marketplace.json` from the repository and installs this Claude Code plugin from `integrations/coding-agents/claude-code`. -The source plugin starts `nemo-relay mcp`; its hooks use the explicit -`--forward-only` mode to post to that existing gateway without an -installer-owned generation fence. They cannot launch or recover Relay. Use -`nemo-relay install claude-code` for complete provider routing and -generation-fenced upgrades. +Source hooks invoke `nemo-relay plugin-shim hook claude` directly. Use +`nemo-relay install claude-code` for the complete provider-routing setup. Create a local Claude Code marketplace and copy the plugin under that marketplace root: @@ -248,7 +224,7 @@ Create `$MARKETPLACE_ROOT/.claude-plugin/marketplace.json`: "plugins": [ { "name": "nemo-relay-plugin", - "description": "Run the shared native Relay gateway and capture Claude Code lifecycle events.", + "description": "Forward Claude Code lifecycle hooks to a local NeMo Relay sidecar.", "source": "./plugins/nemo-relay-plugin", "category": "development" } @@ -272,9 +248,8 @@ claude --plugin-dir "$PLUGIN_ROOT" ``` Hook commands in the source `hooks/hooks.json` template use -`nemo-relay hook-forward claude --forward-only`, so source marketplace installs -rely on the same `nemo-relay` executable available on `PATH` and on their -`alwaysLoad` MCP entry for gateway startup. +`nemo-relay plugin-shim hook claude`, so source marketplace installs rely on +the same `nemo-relay` executable available on `PATH`. If you set up the marketplace manually for development, use the top-level installer commands for provider routing and rollback: @@ -296,10 +271,8 @@ Start a normal Claude Code session: claude ``` -The installed MCP client starts the Relay sidecar before Claude Code proceeds -with session startup. The hook forwarding command retains one coordinated -outage-recovery attempt, and provider traffic is routed through -`ANTHROPIC_BASE_URL=http://127.0.0.1:47632`. +The installed hooks start the Relay sidecar lazily, and provider traffic is +routed through `ANTHROPIC_BASE_URL=http://127.0.0.1:47632`. To upgrade manually, replace the plugin directory contents with the new package, keep the same `MARKETPLACE_ROOT`, update the marketplace, and rerun the @@ -308,7 +281,7 @@ top-level installer: ```bash claude plugin marketplace update nemo-relay-local claude plugin update nemo-relay-plugin -nemo-relay install claude-code --force +nemo-relay install claude-code ``` To uninstall, restore Claude Code provider settings, uninstall the plugin, remove diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index 902f5d711..028608207 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -13,122 +13,40 @@ supported only when they run locally and honor the same hook/plugin config and provider routing. Cloud or remote Codex tasks are partial or unsupported for local gateway LLM capture. -Requires `codex-cli >= 0.143.0`, including the complete required lifecycle-hook -set, plugin app-server metadata, `features.hooks`, and provider alias surfaces -used by the installer. +Requires `codex-cli >= 0.129.0` (introduced the `features.hooks` flag and the +provider alias surface the gateway relies on). ## Files - `.codex-plugin/plugin.json` describes the Codex plugin package. -- `.mcp.json` starts the native `nemo-relay mcp` lifecycle client and requires - successful gateway initialization. - `hooks/hooks.json` contains Codex hook entries that run - `nemo-relay hook-forward codex --forward-only`. + `nemo-relay plugin-shim hook codex`. - `nemo-relay install codex` creates the local marketplace, installs the plugin, - and persists Codex provider and exact plugin-hook trust using `nemo-relay` - from `PATH`. + and persists Codex hook and provider configuration using `nemo-relay` from + `PATH`. ## Captured Events -With `codex-cli >= 0.143.0`, persistent installation requires `SessionStart`, -`UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, -`SubagentStart`, `SubagentStop`, `Stop`, `PreCompact`, and `PostCompact`. Relay -requires exactly one enabled and trusted app-server handler for every generated -event and forwards delivered hooks as scope, tool, mark, or private LLM -correlation events. `PostToolUseFailure`, `Notification`, and `SessionEnd` are not -in the Codex 0.143 plugin hook schema and are not generated. - -Each delivered `Stop` closes the active turn and writes a cumulative ATIF -snapshot. Because the plugin schema does not expose `SessionEnd`, the final -`Stop` is the final session snapshot. - -Transparent setup injects hooks with CLI config overrides. Persistent setup -writes `features.hooks = true` in `.codex/config.toml`, configures the -`nemo-relay-openai` provider alias, and uses this plugin's `hooks/hooks.json` as -the sole persistent Relay hook source. It does not add Relay groups to -`.codex/hooks.json`. +With `codex-cli >= 0.129.0`, the minimum supported installed hooks are +`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, +`PermissionRequest`, `Stop`, `PreCompact`, and `PostCompact`. -Persistent installation opens the stable Codex app-server interface and -selects only hooks whose source is `plugin`, plugin ID is -`nemo-relay-plugin@nemo-relay-local`, and command exactly matches the generated -canonical Relay forwarding command. It requires exactly one handler for each event -in the complete 10-event supported set listed above. - -Unrelated user, project, and plugin hooks are never trusted. If installation -fails after a trust write, it restores every targeted hook's prior trusted, -modified, disabled, or absent state together with Codex config and backups. -Upgrade removes legacy Relay user-hook groups while preserving unrelated hooks. - -Codex plugin mode starts the native `nemo-relay mcp` subcommand through the -plugin MCP configuration. That Rust process is a lightweight lifecycle client: -it starts or reuses a detached native `nemo-relay --bind 127.0.0.1:47632` -sidecar, rejects foreign listeners, and completes the MCP handshake only after -Relay identity, version, bootstrap protocol, and effective persistent -configuration are verified. Compatibility uses a one-way fingerprint of the -resolved settings and relevant environment values without exposing secrets. -The complete dynamic plugin activation snapshot, including adjacent runtime -files and a copied managed Python environment, is limited to 100,000 filesystem -entries and 512 MiB in total, with a maximum directory traversal depth of 128. -If startup reports an activation snapshot budget error, remove unrelated files -from the manifest or load-target directory, flatten deeply nested directories, -or reduce the managed Python environment before retrying. Concurrent Codex, -Claude Code, and configured Hermes processes can share the gateway and -heartbeat it every 30 seconds. The sidecar -remains available for 300 idle seconds after the final client closes. If it dies -while MCP remains open, one endpoint-coordinated restart is attempted across -all overlapping MCP clients and persistent hook deliveries. The MCP server -advertises no tools. - -On Windows, Relay requests Job Object breakaway only when the host job permits -it. Under a restrictive Job Object that permits nested jobs, Relay keeps the -sidecar scoped to the host job and retains a nested cleanup job for the sidecar -process tree. The sidecar cannot outlive the host job, so the 300-second idle -reuse window can end early. If Relay cannot create or configure the cleanup job, -or the host rejects nested assignment, persistent bootstrap fails actionably -instead of running without process-tree cleanup guarantees. - -Persistent mode loads only system and user Relay configuration and starts from -the user configuration directory. Project `.nemo-relay` layers remain specific -to transparent `nemo-relay run` invocations. The MCP manifest forwards approved -provider, Relay, OpenTelemetry, AWS, proxy, certificate, and config-referenced -credential variable names without storing values. - -The installer also derives a per-user HMAC proof from Relay's owner-only -bootstrap key and places the proof in the managed provider headers. It writes -the secret-bearing Codex config with an owner-only mode on Unix or a protected -owner/System DACL on Windows. The shared -sidecar requires that proof before it injects a forwarded provider credential, -then removes the proof before middleware, observability, and upstream -forwarding. This prevents an unrelated loopback caller from spending the -sidecar's credentials. - -Installer-owned hook commands pin `http://127.0.0.1:47632` and their private -install-generation file explicitly. Each delivery temporarily joins the same -recovery cohort as the MCP clients, so it cannot create an unaccounted second -replacement. An ambient `NEMO_RELAY_GATEWAY_URL` cannot split hook traffic from -the required MCP-managed gateway. - -If the Relay version, user configuration, or forwarded credentials change, an -MCP client refuses to reuse the incompatible sidecar. `nemo-relay install codex ---force` retires an installer-owned sidecar through its private shutdown token -before refreshing the plugin. - -An existing install without MCP generation fencing cannot be retired safely, -even when Codex reports that the plugin is no longer registered, because an -already-running MCP process can outlive registration. If upgrade or uninstall -reports a missing MCP generation marker, close every Codex client and -standalone `nemo-relay mcp` process, then run: +The hook template also documents events used by newer or broader host hook +surfaces, including `SessionEnd`, `PostToolUseFailure`, `SubagentStart`, +`SubagentStop`, and `Notification`. Relay forwards any delivered supported hook +as scope, tool, mark, or private LLM correlation events, but the v1 plugin +manifest does not depend on Codex exposing those broader events. -```bash -codex plugin remove nemo-relay-plugin@nemo-relay-local -codex plugin marketplace remove nemo-relay-local -``` +Transparent setup injects these hooks with CLI config overrides. Plugin setup +does not install hooks from the package template directly. It writes +`features.hooks = true` in `.codex/config.toml`, configures the +`nemo-relay-openai` provider alias, and merges hook shim entries into +`.codex/hooks.json`. -Remove `codex-marketplace` and `codex.json` from the install directory named in -the error, then retry `nemo-relay install codex --force` to install a fenced -generation. If removal was the original goal, run `nemo-relay uninstall codex` -immediately afterward; the fenced reinstall lets Relay remove provider and hook -trust state transactionally. +Codex plugin mode uses hook-supervised on-demand startup only. It does not install a +user-level daemon, launchd agent, systemd user service, scheduled task, login +item, wrapper, or persistent supervisor. The sidecar starts only when a Codex +hook invokes `nemo-relay plugin-shim hook codex`. ## Transparent Setup @@ -141,15 +59,10 @@ nemo-relay run -- codex ``` The wrapper starts a per-invocation gateway on a dynamic localhost port, -enables Codex hooks with CLI config overrides, injects hook commands that embed -the gateway URL, and points Codex at a temporary `nemo-relay-openai` provider -alias while preserving Codex's OpenAI auth path. It trusts only the exact -generated session-hook commands and disables the known local and source Relay -plugin hook identities in Codex's process-local CLI layer. It does not replace -or rewrite the selected profile. An enabled Relay plugin MCP authenticates, -borrows, and monitors that exact dynamic gateway, while only the wrapper hooks -remain enabled for that process. Those hooks authenticate the wrapper gateway -before sending lifecycle payloads. +enables Codex hooks with CLI config overrides, injects hook commands that use +`NEMO_RELAY_GATEWAY_URL`, and points Codex at a temporary `nemo-relay-openai` +provider alias that uses the gateway URL while preserving Codex's OpenAI auth +path. Inspect the launch without starting Codex: @@ -160,9 +73,10 @@ nemo-relay run \ -- codex ``` -## Configure Transparent Runs +## Shared Config -Use `.nemo-relay/config.toml` for project defaults: +Use `.nemo-relay/config.toml` for project defaults or +`~/.config/nemo-relay/config.toml` for user defaults: ```toml [agents.codex] @@ -190,32 +104,6 @@ Then run: nemo-relay run --agent codex ``` -This example writes ATIF files under the project at `.nemo-relay/atif`. - -## Configure the Persistent Plugin - -Use `~/.config/nemo-relay/config.toml`, or -`$XDG_CONFIG_HOME/nemo-relay/config.toml` when `XDG_CONFIG_HOME` is set, for -persistent provider defaults. Run `nemo-relay plugins edit` without -`--project` to write user-scoped observability configuration. For example: - -```toml -version = 1 - -[[components]] -kind = "observability" -enabled = true - -[components.config.atif] -enabled = true -output_directory = "atif" -``` - -Persistent mode ignores project layers and starts the sidecar in the user Relay -configuration directory. The relative path above resolves to -`$XDG_CONFIG_HOME/nemo-relay/atif`, or `~/.config/nemo-relay/atif` when -`XDG_CONFIG_HOME` is not set. - ## Standalone Gateway Use the long-running gateway only when you do not want to launch Codex through @@ -253,19 +141,13 @@ for context. ## Verify -Run a Codex session that starts, uses one simple tool, and ends. For a -transparent project run, confirm that ATIF was written: +Run a Codex session that starts, uses one simple tool, and ends. Confirm that +ATIF was written: ```bash ls .nemo-relay/atif ``` -For the persistent user-scoped configuration above, enter: - -```bash -ls "${XDG_CONFIG_HOME:-$HOME/.config}/nemo-relay/atif" -``` - For a direct endpoint smoke test against a manually started gateway: ```bash @@ -292,10 +174,9 @@ nemo-relay install codex `nemo-relay install codex` writes a local Codex marketplace, registers `nemo-relay-plugin`, enables Codex hooks, and configures the -`nemo-relay-openai` provider alias. It writes a required MCP server entry that -invokes the resolved native `nemo-relay` binary. Installation automatically -trusts the exact plugin-owned hook definitions through `codex app-server` and -rolls back files and original trust state if activation cannot be verified. +`nemo-relay-openai` provider alias. Codex sidecar lifecycle remains +hook-supervised on-demand startup only; the installer does not create a wrapper or +daemon. The install command requires `nemo-relay` to be available on `PATH`. It does not require launching Codex through the `nemo-relay` wrapper and does not install a @@ -310,20 +191,18 @@ codex plugin add nemo-relay-plugin@nemo-relay That path reads `.agents/plugins/marketplace.json` from the repository and installs this Codex plugin from `integrations/coding-agents/codex`. Source hooks -use `nemo-relay hook-forward codex --forward-only` to post to the gateway -started by required MCP without an installer-owned generation fence. They -cannot launch or recover Relay. +invoke `nemo-relay plugin-shim hook codex` directly. -Treat the source marketplace path as discovery or manifest validation. Use -`nemo-relay install codex` for the complete provider, environment-forwarding, -and verified-trust setup. +Treat the source marketplace path as discovery or manifest validation. For the +complete provider and generated-hook setup, remove the source-installed plugin +first and then run `nemo-relay install codex`. Keeping both the source plugin +and the generated install active can forward the same Codex hook twice. Package or unpack the plugin so the plugin root contains: ```text nemo-relay-plugin/ .codex-plugin/plugin.json - .mcp.json hooks/hooks.json ``` @@ -370,8 +249,11 @@ codex plugin marketplace add "$MARKETPLACE_ROOT" codex plugin add nemo-relay-plugin@nemo-relay-local ``` -For end-to-end installation, use `nemo-relay install codex`; it performs the -marketplace registration and persistent provider/plugin-hook setup together. +For end-to-end installation, we recommend using`nemo-relay install codex`; it performs the +marketplace registration and the persistent Codex provider/hook setup together. +If you used the manual source marketplace commands above, remove that plugin +before running the full installer so source hook templates and generated +persistent hooks do not both forward the same event. The installer writes a provider alias like: @@ -384,35 +266,24 @@ base_url = "http://127.0.0.1:47632" wire_api = "responses" requires_openai_auth = true supports_websockets = false -http_headers = { "x-nemo-relay-client-token" = "" } ``` -The proof is generated by `nemo-relay install codex`; do not copy the -placeholder value from this example. - Run read-only plugin checks: ```bash nemo-relay doctor --plugin codex ``` -Doctor reports the generated MCP server, native `nemo-relay mcp` support, -plugin hook installation, environment forwarding, and live Codex trust state. -In JSON mode, inspect `checks.codex_hooks_trusted` and `codex_hook_trust` for -untrusted, modified, disabled, or missing required hook entries. - Start a normal Codex session: ```bash codex ``` -Start a new CLI process after install, or restart the Codex desktop app if it -was already open, so the provider selection and hooks are reloaded. - -The required plugin MCP server starts or reuses the shared native Relay gateway -on `http://127.0.0.1:47632` before Codex begins the captured turn, and the -provider alias routes model traffic through it. +The installed hooks start the Relay sidecar lazily on +`http://127.0.0.1:47632`, and the Codex provider alias routes model traffic +through that sidecar. No launchd agent, systemd user service, scheduled task, +login item, wrapper, or persistent supervisor is installed. To upgrade, replace the plugin directory contents with the new package for the same host, keep the same `MARKETPLACE_ROOT`, refresh the local marketplace @@ -426,16 +297,14 @@ codex plugin add nemo-relay-plugin@nemo-relay-local nemo-relay install codex ``` -To uninstall, remove NeMo Relay's Codex config and exact plugin-hook trust, -remove the marketplace registration, and remove the generated marketplace -directory: +To uninstall, remove NeMo Relay's Codex config and hook entries, remove the +marketplace registration, and remove the generated marketplace directory: ```bash nemo-relay uninstall codex ``` -Codex can perform provider discovery before it launches plugin MCP servers. A -cold start can therefore produce transient `/models` connection failures that -Codex retries. Because the MCP server is required, Codex does not begin the -captured turn or send its `/responses` request until the native Relay gateway is -ready; if startup fails, the turn fails instead of silently bypassing Relay. +Full first-request LLM capture depends on Codex firing one of the installed +hooks, especially `SessionStart` or `UserPromptSubmit`, before its first model +provider request. If a Codex version sends the provider request first, the first +request may fail or may not be captured until the next hook starts Relay. From 203d870dd8f81351ef447a18face5419d6f22d63 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Mon, 13 Jul 2026 09:39:15 -0400 Subject: [PATCH 092/102] fix(cli): align Codex gateway metadata Signed-off-by: Will Killian --- crates/cli/src/agents/codex/alignment.rs | 43 ++++++++++---- crates/cli/src/agents/shared/alignment.rs | 15 ++--- crates/cli/src/gateway/request.rs | 2 +- crates/cli/src/gateway/routes.rs | 8 ++- .../coverage/agents/alignment_codex_tests.rs | 56 ++++++++++++++++--- .../tests/coverage/agents/alignment_tests.rs | 5 +- .../tests/coverage/shared/gateway_tests.rs | 21 +++++-- 7 files changed, 115 insertions(+), 35 deletions(-) diff --git a/crates/cli/src/agents/codex/alignment.rs b/crates/cli/src/agents/codex/alignment.rs index 01c05ceda..ecdc84003 100644 --- a/crates/cli/src/agents/codex/alignment.rs +++ b/crates/cli/src/agents/codex/alignment.rs @@ -40,22 +40,43 @@ pub(crate) fn owns_gateway_provider(provider: &str) -> bool { provider == "openai.responses" } -// Codex currently does not forward a stable session header on OpenAI Responses requests. When the -// request carries Codex client metadata, the `prompt_cache_key` is the rollout/thread id. The -// metadata check prevents treating arbitrary application prompt-cache keys as session ids. -pub(crate) fn prompt_cache_session_id(body: &Value, route: GatewayRouteKind) -> Option { +// Codex sends the hook-compatible root session id in Responses client metadata while retaining +// the rollout/thread id as `prompt_cache_key`. Prefer the shared session id so root and subagent +// provider traffic joins the hook session, then keep the prompt-cache fallback for older clients. +// The installation metadata check prevents treating arbitrary application identifiers as Codex +// session ids. +pub(crate) fn responses_session_id(body: &Value, route: GatewayRouteKind) -> Option { if route != GatewayRouteKind::OpenAiResponses { return None; } - let has_codex_metadata = body - .get("client_metadata") - .and_then(|metadata| metadata.get("x-codex-installation-id")) - .and_then(Value::as_str) - .is_some_and(|value| !value.is_empty()); - if !has_codex_metadata { + if !has_codex_client_metadata(body) { return None; } - json_string_at(body, &[&["prompt_cache_key"][..]]) + json_string_at( + body, + &[ + &["client_metadata", "session_id"][..], + &["prompt_cache_key"][..], + ], + ) +} + +// Codex identifies thread-spawned subagent provider calls with `collab_spawn`. The shared +// `session_id` remains the root hook session, while `thread_id` matches the lifecycle `agent_id`. +// Other internal request kinds remain root-session activity rather than gaining unmatched +// subagent ownership. +pub(crate) fn responses_subagent_id(body: &Value, route: GatewayRouteKind) -> Option { + if route != GatewayRouteKind::OpenAiResponses || !has_codex_client_metadata(body) { + return None; + } + let kind = json_string_at(body, &[&["client_metadata", "x-openai-subagent"][..]]); + (kind.as_deref() == Some("collab_spawn")) + .then(|| json_string_at(body, &[&["client_metadata", "thread_id"][..]])) + .flatten() +} + +fn has_codex_client_metadata(body: &Value) -> bool { + json_string_at(body, &[&["client_metadata", "x-codex-installation-id"][..]]).is_some() } // Gives the gateway a Codex-native upstream only when the inbound token is a recognized ChatGPT diff --git a/crates/cli/src/agents/shared/alignment.rs b/crates/cli/src/agents/shared/alignment.rs index cfdeafee1..5561ca495 100644 --- a/crates/cli/src/agents/shared/alignment.rs +++ b/crates/cli/src/agents/shared/alignment.rs @@ -137,7 +137,7 @@ static ANTHROPIC_COUNT_TOKENS_REQUEST_EXTRACTOR: AnthropicCountTokensRequestExtr impl ProviderRequestExtractor for OpenAiResponsesRequestExtractor { fn gateway_session_id(&self, headers: &HeaderMap, body: &Value) -> Option { gateway_header_session_id(headers) - .or_else(|| codex::prompt_cache_session_id(body, GatewayRouteKind::OpenAiResponses)) + .or_else(|| codex::responses_session_id(body, GatewayRouteKind::OpenAiResponses)) .or_else(|| openai_body_session_id(body, GatewayRouteKind::OpenAiResponses)) } @@ -556,13 +556,14 @@ pub(crate) fn gateway_forward_headers( codex::strip_chatgpt_auth_for_openai_route(headers, route, has_openai_replacement_key) } -/// Read the explicit subagent header from a gateway request. -/// -/// Unlike session ids, there is intentionally no body fallback here: subagent -/// body fields are provider-specific and easy to confuse with tool-call payload -/// content. -pub(crate) fn gateway_subagent_id(headers: &HeaderMap) -> Option { +/// Resolve subagent ownership from an explicit Relay header or trusted agent metadata. +pub(crate) fn gateway_subagent_id( + headers: &HeaderMap, + body: &Value, + route: GatewayRouteKind, +) -> Option { header_string(headers, "x-nemo-relay-subagent-id") + .or_else(|| codex::responses_subagent_id(body, route)) } /// Resolve a correlation identifier from a header or known JSON body paths. diff --git a/crates/cli/src/gateway/request.rs b/crates/cli/src/gateway/request.rs index 614812e08..a56fccaa8 100644 --- a/crates/cli/src/gateway/request.rs +++ b/crates/cli/src/gateway/request.rs @@ -98,7 +98,7 @@ pub(super) fn build_llm_gateway_start(request: &PreparedGatewayRequest) -> LlmGa .get("model") .and_then(Value::as_str) .map(ToOwned::to_owned), - subagent_id: gateway_subagent_id(&request.headers), + subagent_id: gateway_subagent_id(&request.headers, &request.request_json, request.provider), conversation_id: gateway_identifier( &request.headers, &request.request_json, diff --git a/crates/cli/src/gateway/routes.rs b/crates/cli/src/gateway/routes.rs index e771e768b..05bde609b 100644 --- a/crates/cli/src/gateway/routes.rs +++ b/crates/cli/src/gateway/routes.rs @@ -190,8 +190,12 @@ pub(super) fn gateway_session_id( alignment::gateway_session_id(headers, body, route.alignment_route()) } -pub(super) fn gateway_subagent_id(headers: &HeaderMap) -> Option { - alignment::gateway_subagent_id(headers) +pub(super) fn gateway_subagent_id( + headers: &HeaderMap, + body: &Value, + route: ProviderRoute, +) -> Option { + alignment::gateway_subagent_id(headers, body, route.alignment_route()) } // Keeps the gateway-facing helper local for tests while the generic extraction pattern lives in diff --git a/crates/cli/tests/coverage/agents/alignment_codex_tests.rs b/crates/cli/tests/coverage/agents/alignment_codex_tests.rs index 4c1e6761d..93183944c 100644 --- a/crates/cli/tests/coverage/agents/alignment_codex_tests.rs +++ b/crates/cli/tests/coverage/agents/alignment_codex_tests.rs @@ -29,37 +29,75 @@ fn thread_spawn(parent_thread_id: &str) -> Value { } #[test] -fn prompt_cache_session_id_requires_codex_responses_metadata() { +fn responses_session_id_prefers_shared_id_and_keeps_prompt_cache_fallback() { let body = json!({ "prompt_cache_key": "thread-1", - "client_metadata": { "x-codex-installation-id": "install-1" } + "client_metadata": { + "x-codex-installation-id": "install-1", + "session_id": "session-1" + } }); assert_eq!( - prompt_cache_session_id(&body, GatewayRouteKind::OpenAiResponses).as_deref(), - Some("thread-1") + responses_session_id(&body, GatewayRouteKind::OpenAiResponses).as_deref(), + Some("session-1") ); assert_eq!( - prompt_cache_session_id(&body, GatewayRouteKind::OpenAiChatCompletions), + responses_session_id(&body, GatewayRouteKind::OpenAiChatCompletions), None ); assert_eq!( - prompt_cache_session_id( + responses_session_id( &json!({ "prompt_cache_key": "plain-cache" }), GatewayRouteKind::OpenAiResponses, ), None ); assert_eq!( - prompt_cache_session_id( + responses_session_id( &json!({ - "prompt_cache_key": "", + "prompt_cache_key": "legacy-thread", "client_metadata": { "x-codex-installation-id": "install-1" } }), GatewayRouteKind::OpenAiResponses, - ), + ) + .as_deref(), + Some("legacy-thread") + ); +} + +#[test] +fn responses_subagent_id_accepts_only_codex_collab_spawn_metadata() { + let body = json!({ + "client_metadata": { + "x-codex-installation-id": "install-1", + "x-openai-subagent": "collab_spawn", + "thread_id": "child-thread" + } + }); + + assert_eq!( + responses_subagent_id(&body, GatewayRouteKind::OpenAiResponses).as_deref(), + Some("child-thread") + ); + assert_eq!( + responses_subagent_id(&body, GatewayRouteKind::OpenAiChatCompletions), None ); + + for kind in ["review", "compact", "memory"] { + let body = json!({ + "client_metadata": { + "x-codex-installation-id": "install-1", + "x-openai-subagent": kind, + "thread_id": "internal-thread" + } + }); + assert_eq!( + responses_subagent_id(&body, GatewayRouteKind::OpenAiResponses), + None + ); + } } #[test] diff --git a/crates/cli/tests/coverage/agents/alignment_tests.rs b/crates/cli/tests/coverage/agents/alignment_tests.rs index d71a05ee9..55a1c9151 100644 --- a/crates/cli/tests/coverage/agents/alignment_tests.rs +++ b/crates/cli/tests/coverage/agents/alignment_tests.rs @@ -250,7 +250,10 @@ fn gateway_subagent_and_identifier_helpers_respect_header_precedence() { "object": { "id": { "nested": true } } }); - assert_eq!(gateway_subagent_id(&headers).as_deref(), Some("worker-1")); + assert_eq!( + gateway_subagent_id(&headers, &body, GatewayRouteKind::OpenAiResponses).as_deref(), + Some("worker-1") + ); assert_eq!( gateway_identifier( &headers, diff --git a/crates/cli/tests/coverage/shared/gateway_tests.rs b/crates/cli/tests/coverage/shared/gateway_tests.rs index c9b8e8383..854c73d84 100644 --- a/crates/cli/tests/coverage/shared/gateway_tests.rs +++ b/crates/cli/tests/coverage/shared/gateway_tests.rs @@ -311,8 +311,11 @@ fn effective_upstream_request_skips_invalid_runtime_headers() { fn gateway_session_id_prefers_headers_and_has_fallbacks() { let mut headers = HeaderMap::new(); let codex_body = json!({ - "prompt_cache_key": "codex-session", - "client_metadata": { "x-codex-installation-id": "install-1" }, + "prompt_cache_key": "codex-thread", + "client_metadata": { + "x-codex-installation-id": "install-1", + "session_id": "codex-session" + }, "session_id": "body-session" }); headers.insert( @@ -450,7 +453,12 @@ fn build_llm_gateway_start_uses_alignment_identifiers_and_metadata() { "model": "gpt-test", "stream": true, "prompt_cache_key": "codex-thread", - "client_metadata": { "x-codex-installation-id": "install-1" }, + "client_metadata": { + "x-codex-installation-id": "install-1", + "x-openai-subagent": "collab_spawn", + "session_id": "codex-session", + "thread_id": "child-thread" + }, "conversation_id": "conversation-1", "generation": { "id": "generation-1" } }); @@ -468,7 +476,7 @@ fn build_llm_gateway_start_uses_alignment_identifiers_and_metadata() { let start = build_llm_gateway_start(&prepared); - assert_eq!(start.session_id.as_deref(), Some("codex-thread")); + assert_eq!(start.session_id.as_deref(), Some("codex-session")); assert_eq!(start.provider, "openai.responses"); assert_eq!(start.model_name.as_deref(), Some("gpt-test")); assert_eq!(start.subagent_id.as_deref(), Some("worker-1")); @@ -482,6 +490,11 @@ fn build_llm_gateway_start_uses_alignment_identifiers_and_metadata() { !start.request.headers.contains_key("authorization"), "observable headers should not leak auth secrets" ); + + let mut metadata_owned = prepared; + metadata_owned.headers.remove("x-nemo-relay-subagent-id"); + let start = build_llm_gateway_start(&metadata_owned); + assert_eq!(start.subagent_id.as_deref(), Some("child-thread")); } #[test] From 727c5ea0afdb16f71083a6b2c49d0fc8c89c5ba5 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Mon, 13 Jul 2026 17:59:15 -0400 Subject: [PATCH 093/102] fix(cli): address bootstrap review findings Signed-off-by: Will Killian --- ATTRIBUTIONS-Rust.md | 1182 ++++++++++++++--- Cargo.lock | 75 ++ crates/cli/Cargo.toml | 6 + crates/cli/src/agents/claude/host.rs | 23 +- crates/cli/src/agents/codex/host.rs | 26 +- crates/cli/src/agents/hermes/config.rs | 21 +- crates/cli/src/agents/hermes/integration.rs | 37 +- crates/cli/src/bootstrap/mod.rs | 18 +- crates/cli/src/bootstrap/state.rs | 19 +- crates/cli/src/commands/configure/mod.rs | 5 +- crates/cli/src/commands/configure/model.rs | 41 +- crates/cli/src/commands/install.rs | 16 +- crates/cli/src/commands/run.rs | 5 +- crates/cli/src/configuration/mod.rs | 4 +- crates/cli/src/diagnostics/environment.rs | 11 +- crates/cli/src/diagnostics/probes.rs | 15 +- crates/cli/src/diagnostics/render.rs | 7 +- crates/cli/src/filesystem/bounded.rs | 32 +- crates/cli/src/filesystem/snapshots.rs | 26 +- crates/cli/src/filesystem/temp.rs | 14 + crates/cli/src/gateway/client.rs | 107 +- crates/cli/src/gateway/mod.rs | 5 +- crates/cli/src/gateway/response.rs | 23 +- crates/cli/src/gateway/tls.rs | 89 ++ crates/cli/src/hooks/delivery.rs | 19 +- crates/cli/src/installation/generation.rs | 2 +- .../src/installation/marketplace/assets.rs | 10 +- .../cli/src/installation/marketplace/host.rs | 4 +- crates/cli/src/mcp/gateway.rs | 92 +- crates/cli/src/mcp/protocol.rs | 24 +- .../cli/src/plugins/lifecycle/environment.rs | 30 +- crates/cli/src/plugins/lifecycle/mod.rs | 4 + crates/cli/src/plugins/mod.rs | 3 + crates/cli/src/plugins/pricing.rs | 12 +- crates/cli/src/plugins/schema/mod.rs | 58 +- crates/cli/src/process/detached.rs | 313 +++-- crates/cli/src/process/launcher.rs | 28 +- crates/cli/src/server/mod.rs | 113 +- crates/cli/src/sessions/correlation.rs | 16 +- crates/cli/src/sessions/idle.rs | 19 +- crates/cli/src/sessions/mod.rs | 7 + crates/cli/tests/architecture_tests.rs | 116 +- crates/cli/tests/cli_tests.rs | 218 ++- .../cli/tests/coverage/agents/hermes_tests.rs | 54 +- .../tests/coverage/agents/launcher_tests.rs | 54 +- .../coverage/agents/plugin_host_tests.rs | 49 +- .../coverage/agents/plugin_install_tests.rs | 8 +- .../coverage/commands/model_pricing_tests.rs | 16 + .../coverage/shared/bootstrap_state_tests.rs | 39 +- .../tests/coverage/shared/bootstrap_tests.rs | 40 +- .../cli/tests/coverage/shared/config_tests.rs | 37 +- .../cli/tests/coverage/shared/doctor_tests.rs | 36 +- .../coverage/shared/gateway_client_tests.rs | 71 + .../tests/coverage/shared/gateway_tests.rs | 81 +- .../coverage/shared/gateway_tls_tests.rs | 57 + .../shared/install_generation_tests.rs | 1 + .../coverage/shared/mcp_gateway_tests.rs | 33 +- crates/cli/tests/coverage/shared/mcp_tests.rs | 12 + .../shared/plugins_lifecycle_tests.rs | 9 + .../coverage/shared/plugins_schema_tests.rs | 34 + .../tests/coverage/shared/plugins_tests.rs | 31 + .../tests/coverage/shared/session_tests.rs | 26 + .../cli/tests/coverage/shared/setup_tests.rs | 48 +- 63 files changed, 2996 insertions(+), 635 deletions(-) create mode 100644 crates/cli/src/gateway/tls.rs create mode 100644 crates/cli/tests/coverage/shared/gateway_tls_tests.rs diff --git a/ATTRIBUTIONS-Rust.md b/ATTRIBUTIONS-Rust.md index f7e2c27a7..6f4095f0d 100644 --- a/ATTRIBUTIONS-Rust.md +++ b/ATTRIBUTIONS-Rust.md @@ -9495,6 +9495,216 @@ SOFTWARE. ``` +## deranged - 0.5.8 +**Repository URL**: https://github.com/jhpratt/deranged +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Jacob Pratt et al. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +``` + ## dialoguer - 0.11.0 **Repository URL**: https://github.com/console-rs/dialoguer **License Type(s)**: MIT @@ -24265,6 +24475,87 @@ limitations under the License. ``` +## num-conv - 0.2.2 +**Repository URL**: https://github.com/jhpratt/num-conv +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + ## num-integer - 0.1.46 **Repository URL**: https://github.com/rust-num/num-integer **License Type(s)**: Apache-2.0 @@ -27115,6 +27406,35 @@ limitations under the License. ``` +## pem - 3.0.6 +**Repository URL**: https://github.com/jcreekmore/pem-rs.git +**License Type(s)**: MIT +### License: https://spdx.org/licenses/MIT.html +``` +The MIT License (MIT) + +Copyright (c) 2016 Jonathan Creekmore + +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. + +``` + ## percent-encoding - 2.3.2 **Repository URL**: https://github.com/servo/rust-url/ **License Type(s)**: Apache-2.0 @@ -27911,6 +28231,216 @@ ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation a ``` +## powerfmt - 0.2.0 +**Repository URL**: https://github.com/jhpratt/powerfmt +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Jacob Pratt et al. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +``` + ## ppv-lite86 - 0.2.21 **Repository URL**: https://github.com/cryptocorrosion/cryptocorrosion **License Type(s)**: Apache-2.0 @@ -31786,6 +32316,87 @@ APPENDIX: How to apply the Apache License to your work. ``` +## rcgen - 0.13.2 +**Repository URL**: https://github.com/rustls/rcgen +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + ## redis - 1.2.0 **Repository URL**: https://github.com/redis-rs/redis-rs **License Type(s)**: BSD-3-Clause @@ -38307,198 +38918,360 @@ limitations under the License. ``` -## tempfile - 3.27.0 -**Repository URL**: https://github.com/Stebalien/tempfile +## tempfile - 3.27.0 +**Repository URL**: https://github.com/Stebalien/tempfile +**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. + +``` + +## thiserror - 1.0.69 +**Repository URL**: https://github.com/dtolnay/thiserror +**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. + +``` + +## thiserror - 2.0.18 +**Repository URL**: https://github.com/dtolnay/thiserror **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/ +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. +"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. +"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. +"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. +"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. +"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. +"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). +"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. +"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." +"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. +"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. +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. +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: +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 + (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 + (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 + (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. + (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. + 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. +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. +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. +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. +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. +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. +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] @@ -38506,7 +39279,7 @@ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 +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, @@ -38516,7 +39289,7 @@ limitations under the License. ``` -## thiserror - 1.0.69 +## thiserror-impl - 1.0.69 **Repository URL**: https://github.com/dtolnay/thiserror **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html @@ -38597,7 +39370,7 @@ limitations under the License. ``` -## thiserror - 2.0.18 +## thiserror-impl - 2.0.18 **Repository URL**: https://github.com/dtolnay/thiserror **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html @@ -38678,8 +39451,8 @@ limitations under the License. ``` -## thiserror-impl - 1.0.69 -**Repository URL**: https://github.com/dtolnay/thiserror +## time - 0.3.53 +**Repository URL**: https://github.com/time-rs/time **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` @@ -38759,8 +39532,8 @@ limitations under the License. ``` -## thiserror-impl - 2.0.18 -**Repository URL**: https://github.com/dtolnay/thiserror +## time-core - 0.1.9 +**Repository URL**: https://github.com/time-rs/time **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` @@ -52144,6 +52917,87 @@ DEALINGS IN THE SOFTWARE. ``` +## yasna - 0.5.2 +**Repository URL**: https://github.com/qnighy/yasna.rs +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + ## yoke - 0.8.2 **Repository URL**: https://github.com/unicode-org/icu4x **License Type(s)**: Unicode-3.0 diff --git a/Cargo.lock b/Cargo.lock index 96e154eb4..c089ca8b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -559,6 +559,12 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "dialoguer" version = "0.11.0" @@ -1555,6 +1561,8 @@ dependencies = [ "futures-util", "http", "http-body-util", + "hyper", + "hyper-util", "jsonschema", "libc", "nemo-relay", @@ -1563,9 +1571,11 @@ dependencies = [ "opentelemetry", "opentelemetry_sdk", "percent-encoding", + "rcgen", "regex", "reqwest", "ring", + "rustls", "semver", "serde", "serde_json", @@ -1574,9 +1584,11 @@ dependencies = [ "shell-words", "strum", "subtle", + "syn", "tempfile", "thiserror 2.0.18", "tokio", + "tokio-rustls", "tokio-tungstenite", "toml", "toml_edit", @@ -1746,6 +1758,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -1970,6 +1988,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2028,6 +2056,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2406,6 +2440,19 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redis" version = "1.2.0" @@ -3041,6 +3088,25 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tinystr" version = "0.8.3" @@ -3951,6 +4017,15 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 26615e19b..b8a1352ab 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -40,12 +40,16 @@ futures-util = "0.3" fs2 = "0.4" http = "1" http-body-util = "0.1" +hyper = "1" +hyper-util = { version = "0.1", features = ["tokio"] } dialoguer = { version = "0.11", default-features = false, features = ["password"] } jsonschema = { version = "0.46.6", default-features = false } percent-encoding = "2" reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "json", "rustls-tls-native-roots", "stream"] } regex = "1" +rcgen = "0.13" ring = "0.17" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -55,6 +59,7 @@ strum = { version = "0.27", features = ["derive"] } subtle = "2" thiserror = "2" tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } tokio-tungstenite = { version = "0.27", default-features = false, features = ["connect", "rustls-tls-native-roots"] } toml = "0.9" toml_edit = "0.23" @@ -70,6 +75,7 @@ windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Securit opentelemetry = { workspace = true, features = ["trace"] } opentelemetry_sdk = { workspace = true, features = ["trace", "testing"] } shell-words = "1" +syn = { version = "2", features = ["full", "visit"] } tempfile = "3" tokio = { version = "1", features = ["test-util"] } tower = { version = "0.5", features = ["util"] } diff --git a/crates/cli/src/agents/claude/host.rs b/crates/cli/src/agents/claude/host.rs index 47792a1c9..2b2ceacc8 100644 --- a/crates/cli/src/agents/claude/host.rs +++ b/crates/cli/src/agents/claude/host.rs @@ -13,6 +13,8 @@ use crate::filesystem::{ FileSnapshot, backup, backup_path, remove_backup, restore_file_snapshot, snapshot_optional_file, }; +const ABSENT_SETTINGS_BACKUP_KEY: &str = "__nemo_relay_original_settings_absent"; + pub(crate) struct ClaudeSetupSnapshot { files: Vec, } @@ -80,7 +82,19 @@ pub(crate) fn restore_claude_provider(gateway_url: &str) -> Result<(), String> { if json_env_string(&settings, "ANTHROPIC_BASE_URL") == Some(gateway_url) { let backup_settings = read_json_object(&backup)?; restore_json_env_value(&mut settings, &backup_settings, "ANTHROPIC_BASE_URL")?; - write_json(&path, &settings)?; + if backup_settings.get(ABSENT_SETTINGS_BACKUP_KEY) == Some(&Value::Bool(true)) + && settings.as_object().is_some_and(serde_json::Map::is_empty) + { + match fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!("failed to remove {}: {error}", path.display())); + } + } + } else { + write_json(&path, &settings)?; + } remove_backup(&path)?; println!( "restored managed ANTHROPIC_BASE_URL in {} from {}", @@ -166,8 +180,11 @@ pub(crate) fn backup_claude_settings(path: &Path, replace_existing: bool) -> Res fs::create_dir_all(parent) .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; } - fs::write(&backup_file, b"{}\n") - .map_err(|error| format!("failed to write {}: {error}", backup_file.display())) + fs::write( + &backup_file, + format!("{{\"{ABSENT_SETTINGS_BACKUP_KEY}\":true}}\n"), + ) + .map_err(|error| format!("failed to write {}: {error}", backup_file.display())) } } diff --git a/crates/cli/src/agents/codex/host.rs b/crates/cli/src/agents/codex/host.rs index 8231c4f15..1e8fb6f96 100644 --- a/crates/cli/src/agents/codex/host.rs +++ b/crates/cli/src/agents/codex/host.rs @@ -1097,12 +1097,19 @@ pub(crate) fn codex_provider_header<'a>(doc: &'a DocumentMut, name: &str) -> Opt .and_then(|provider| provider.get("http_headers"))?; headers .as_inline_table() - .and_then(|headers| headers.get(name)) - .or_else(|| { + .and_then(|headers| { headers - .as_table() - .and_then(|headers| headers.get(name)) - .and_then(Item::as_value) + .iter() + .find_map(|(key, value)| key.eq_ignore_ascii_case(name).then_some(value)) + }) + .or_else(|| { + headers.as_table().and_then(|headers| { + headers.iter().find_map(|(key, item)| { + key.eq_ignore_ascii_case(name) + .then(|| item.as_value()) + .flatten() + }) + }) }) } @@ -1543,14 +1550,7 @@ pub(crate) fn codex_provider_installed(gateway_url: &str) -> bool { } pub(crate) fn codex_provider_client_token(doc: &DocumentMut) -> Option<&str> { - doc.get("model_providers") - .and_then(Item::as_table) - .and_then(|providers| providers.get("nemo-relay-openai")) - .and_then(Item::as_table) - .and_then(|provider| provider.get("http_headers")) - .and_then(Item::as_inline_table) - .and_then(|headers| headers.get(BOOTSTRAP_CLIENT_TOKEN_HEADER)) - .and_then(TomlValue::as_str) + codex_provider_header(doc, BOOTSTRAP_CLIENT_TOKEN_HEADER).and_then(TomlValue::as_str) } pub(crate) fn codex_hooks_installed(path: &Path) -> Result { diff --git a/crates/cli/src/agents/hermes/config.rs b/crates/cli/src/agents/hermes/config.rs index 1108fb3bd..a4734718e 100644 --- a/crates/cli/src/agents/hermes/config.rs +++ b/crates/cli/src/agents/hermes/config.rs @@ -217,31 +217,12 @@ pub(super) fn owned_install_command( { let command = persistent_hook_command(relay, Path::new(generation), token) .map_err(CliError::Install)?; - return Ok(has_complete_hook_set(root, &command).then_some(command)); + return Ok(Some(command)); } } legacy_owned_command(root, relay) } -fn has_complete_hook_set(root: &Value, command: &str) -> bool { - crate::agents::CodingAgent::Hermes - .hook_events() - .iter() - .all(|event| { - root.pointer(&format!("/hooks/{event}")) - .and_then(Value::as_array) - .is_some_and(|groups| { - groups - .iter() - .filter(|entry| { - entry.get("command").and_then(Value::as_str) == Some(command) - }) - .count() - == 1 - }) - }) -} - fn legacy_owned_command(root: &Value, relay: &Path) -> Result, CliError> { let server = &root["mcp_servers"][MCP_SERVER_NAME]; if server.get("args") != Some(&json!(["mcp", "--agent", "hermes"])) { diff --git a/crates/cli/src/agents/hermes/integration.rs b/crates/cli/src/agents/hermes/integration.rs index d35b4c9f7..88afb18d4 100644 --- a/crates/cli/src/agents/hermes/integration.rs +++ b/crates/cli/src/agents/hermes/integration.rs @@ -167,14 +167,25 @@ fn config_has_managed_state(config: &Value) -> bool { } fn allowlist_has_owned_command(allowlist: &Value, command: Option<&str>) -> bool { - command.is_some_and(|command| { - allowlist - .get("approvals") - .and_then(Value::as_array) - .into_iter() - .flatten() - .any(|entry| entry.get("command").and_then(Value::as_str) == Some(command)) - }) + allowlist + .get("approvals") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|entry| entry.get("command").and_then(Value::as_str)) + .any(|candidate| { + command == Some(candidate) + || (command.is_none() && is_persistent_relay_hook_command(candidate)) + }) +} + +fn is_persistent_relay_hook_command(command: &str) -> bool { + command.contains("hook-forward") + && command.contains("hermes") + && command.contains("--gateway-url") + && command.contains(crate::bootstrap::DEFAULT_URL) + && command.contains("--generation-file") + && command.contains("--generation-token") } fn owned_command_from_config(config: &Value, generation: Option<&Path>) -> Option { @@ -234,9 +245,15 @@ pub(crate) fn diagnose_persistent(config_path: &Path) -> Result let environment = env::vars_os() .filter_map(|(name, _)| name.into_string().ok()) .collect::>(); - let missing = forwarded_environment_names(&environment, plugin_config.as_ref()) + let environment = forwarded_environment_names(&environment, plugin_config.as_ref()); + let expected = expected_mcp_server(&relay, &paths.generation, generation.token(), &environment); + let expected_env = expected + .get("env") + .and_then(Value::as_object) + .expect("expected MCP environment is an object"); + let missing = environment .into_iter() - .filter(|name| !mcp_env.contains_key(name)) + .filter(|name| mcp_env.get(name) != expected_env.get(name)) .collect::>(); if !missing.is_empty() { return Err(format!( diff --git a/crates/cli/src/bootstrap/mod.rs b/crates/cli/src/bootstrap/mod.rs index b7b5a541e..3acad6305 100644 --- a/crates/cli/src/bootstrap/mod.rs +++ b/crates/cli/src/bootstrap/mod.rs @@ -10,7 +10,7 @@ use std::ffi::OsString; use std::fs; use std::net::SocketAddr; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -341,7 +341,7 @@ fn start_gateway(spec: &GatewaySpec, state: &Path) -> Result Result<(), String> { +fn hand_off_to_reaper(child: detached::DetachedChild) -> Result<(), String> { hand_off_to_reaper_with( child, |slot| { @@ -359,10 +359,10 @@ fn hand_off_to_reaper(child: Child) -> Result<(), String> { ) } -fn hand_off_to_reaper_with( - child: Child, - spawn: impl FnOnce(Arc>>) -> std::io::Result<()>, - terminate: impl FnOnce(&mut Child), +fn hand_off_to_reaper_with( + child: T, + spawn: impl FnOnce(Arc>>) -> std::io::Result<()>, + terminate: impl FnOnce(&mut T), ) -> Result<(), String> { let slot = Arc::new(Mutex::new(Some(child))); if let Err(error) = spawn(Arc::clone(&slot)) { @@ -375,10 +375,10 @@ fn hand_off_to_reaper_with( Ok(()) } -struct ArmedChild(Option); +struct ArmedChild(Option); impl ArmedChild { - fn new(child: Child) -> Self { + fn new(child: detached::DetachedChild) -> Self { Self(Some(child)) } @@ -389,7 +389,7 @@ impl ArmedChild { .try_wait() } - fn disarm(mut self) -> Child { + fn disarm(mut self) -> detached::DetachedChild { self.0.take().expect("armed gateway child is present") } } diff --git a/crates/cli/src/bootstrap/state.rs b/crates/cli/src/bootstrap/state.rs index 3df4922fd..ce620f816 100644 --- a/crates/cli/src/bootstrap/state.rs +++ b/crates/cli/src/bootstrap/state.rs @@ -54,7 +54,6 @@ impl OwnerRecord { fn valid_for(&self, url: &str) -> bool { self.service == "nemo-relay" - && self.version == env!("CARGO_PKG_VERSION") && self.bootstrap_protocol == BOOTSTRAP_PROTOCOL_VERSION && self.url == url && !self.shutdown_token.is_empty() @@ -177,10 +176,12 @@ pub(crate) fn lock_endpoint_for( } } -pub(crate) fn publish_owner_from_env(address: SocketAddr) -> Result, String> { +pub(crate) fn publish_owner_from_env( + address: SocketAddr, + shutdown_token: Option<&str>, +) -> Result, String> { let state = env::var_os(BOOTSTRAP_STATE_DIR_ENV); - let token = env::var("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN").ok(); - if state.is_none() && token.is_none() { + if state.is_none() && shutdown_token.is_none() { return Ok(None); } let state = state @@ -192,9 +193,11 @@ pub(crate) fn publish_owner_from_env(address: SocketAddr) -> Result Result, } pub(super) async fn execute(command: ConfigCommand) -> Result { let agent = command.agent.map(Into::into); if command.reset { - model::reset(agent)?; + model::reset(command.scope.unwrap_or(model::ConfigScope::Project), agent)?; } else { wizard::run(agent).await?; } diff --git a/crates/cli/src/commands/configure/model.rs b/crates/cli/src/commands/configure/model.rs index 03eeac344..ef8bdd697 100644 --- a/crates/cli/src/commands/configure/model.rs +++ b/crates/cli/src/commands/configure/model.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; +use clap::ValueEnum; use toml_edit::{DocumentMut, Item, Table, value}; use crate::agents::CodingAgent; @@ -12,7 +13,7 @@ use crate::error::CliError; use crate::plugins::{ConfigurationScope, PluginsEditRequest}; /// Where the setup saves its output. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub(crate) enum ConfigScope { /// `./.nemo-relay/config.toml` (walked-up workspace dir). Project, @@ -217,26 +218,46 @@ pub(crate) fn merge_agents_entry(dst: &mut DocumentMut, src: &DocumentMut, agent /// the existing file and removes only `[agents.]`, leaving every other section intact. /// In both cases this targets the *project* layer; global and system layers are left to direct /// editing because they typically aren't owned by the wizard. -pub(crate) fn reset(agent_hint: Option) -> Result<(), CliError> { - reset_project_config(agent_hint) +pub(crate) fn reset(scope: ConfigScope, agent_hint: Option) -> Result<(), CliError> { + if matches!(scope, ConfigScope::Project | ConfigScope::Both) { + let cwd = std::env::current_dir()?; + reset_config_path( + &cwd.join(".nemo-relay").join("config.toml"), + "project", + agent_hint, + )?; + } + if matches!(scope, ConfigScope::Global | ConfigScope::Both) { + let home = home_dir().ok_or_else(|| { + CliError::Config("cannot resolve the home directory for global reset".into()) + })?; + reset_config_path( + &global_config_dir(&home).join("config.toml"), + "global", + agent_hint, + )?; + } + Ok(()) } -fn reset_project_config(agent_hint: Option) -> Result<(), CliError> { - let cwd = std::env::current_dir()?; - let path = cwd.join(".nemo-relay").join("config.toml"); +fn reset_config_path( + path: &Path, + scope: &str, + agent_hint: Option, +) -> Result<(), CliError> { if !path.exists() { - println!(" No project config to reset at {}", path.display()); + println!(" No {scope} config to reset at {}", path.display()); return Ok(()); } match agent_hint { None => { - std::fs::remove_file(&path)?; + std::fs::remove_file(path)?; println!(" ✓ Removed {}", path.display()); println!(" Run `nemo-relay config` to set up again."); } Some(agent) => { let agent_key = agent_key_and_command(agent).0; - let raw = std::fs::read_to_string(&path)?; + let raw = std::fs::read_to_string(path)?; let mut doc: DocumentMut = raw.parse().map_err(|err| { CliError::Config(format!("could not parse existing config: {err}")) })?; @@ -263,7 +284,7 @@ fn reset_project_config(agent_hint: Option) -> Result<(), CliError> if agents.is_empty() { doc.remove("agents"); } - std::fs::write(&path, doc.to_string())?; + std::fs::write(path, doc.to_string())?; println!(" ✓ Removed `[agents.{agent_key}]` from {}", path.display()); } } diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index 38c997eae..b30ea474f 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -96,10 +96,14 @@ pub(super) fn install(command: InstallCommand) -> Result { "no supported Claude Code, Codex, or Hermes host CLI was detected".into(), )); } + let mut result = ExitCode::SUCCESS; for agent in agents { - crate::agents::install_integration(agent, request.clone())?; + let status = crate::agents::install_integration(agent, request.clone())?; + if status != ExitCode::SUCCESS { + result = status; + } } - Ok(ExitCode::SUCCESS) + Ok(result) } pub(super) fn uninstall(command: UninstallCommand) -> Result { @@ -116,8 +120,12 @@ pub(super) fn uninstall(command: UninstallCommand) -> Result "no installed Claude Code, Codex, or Hermes integration state was found".into(), )); } + let mut result = ExitCode::SUCCESS; for agent in agents { - crate::agents::uninstall_integration(agent, request.clone())?; + let status = crate::agents::uninstall_integration(agent, request.clone())?; + if status != ExitCode::SUCCESS { + result = status; + } } - Ok(ExitCode::SUCCESS) + Ok(result) } diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index 9cf40b580..8edbca27b 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -74,10 +74,7 @@ pub(super) async fn easy_path( // none of the normal discovery layers exists. Keep this interactive decision in the command // layer so process supervision receives a complete, agent-neutral run request. let explicit_config = inherited.config.as_deref(); - let needs_setup = explicit_config.map_or_else( - || !crate::configuration::any_config_file_exists(), - |path| !path.exists(), - ); + let needs_setup = explicit_config.is_none() && !crate::configuration::any_config_file_exists(); if needs_setup { super::configure::run(Some(agent)).await?; } diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs index 9c442bceb..0aebdaa2c 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -155,7 +155,9 @@ pub(crate) fn managed_bootstrap_identity( .ok() .filter(|fingerprint| !fingerprint.is_empty()) else { - return Ok(None); + return Err(CliError::Config(format!( + "{BOOTSTRAP_FINGERPRINT_ENV} must be set and non-empty when a managed readiness file is requested" + ))); }; let actual = persistent_bootstrap_fingerprint(resolved, active_dynamic_plugins)?; verify_managed_bootstrap_fingerprint(&expected, &actual)?; diff --git a/crates/cli/src/diagnostics/environment.rs b/crates/cli/src/diagnostics/environment.rs index 0699fd091..69efeb178 100644 --- a/crates/cli/src/diagnostics/environment.rs +++ b/crates/cli/src/diagnostics/environment.rs @@ -6,10 +6,17 @@ use super::EnvironmentInfo; pub(super) fn collect_environment() -> EnvironmentInfo { + let version = os_version(); + let os = if version.is_empty() { + std::env::consts::OS.to_string() + } else { + format!("{} {version}", std::env::consts::OS) + }; + let shell_variable = if cfg!(windows) { "COMSPEC" } else { "SHELL" }; EnvironmentInfo { - os: format!("{} {}", std::env::consts::OS, os_version()), + os, arch: std::env::consts::ARCH, - shell: std::env::var("SHELL").ok().and_then(|path| { + shell: std::env::var(shell_variable).ok().and_then(|path| { std::path::Path::new(&path) .file_name() .map(|name| name.to_string_lossy().into_owned()) diff --git a/crates/cli/src/diagnostics/probes.rs b/crates/cli/src/diagnostics/probes.rs index ed5bcccc7..cbfe5df6a 100644 --- a/crates/cli/src/diagnostics/probes.rs +++ b/crates/cli/src/diagnostics/probes.rs @@ -4,6 +4,7 @@ //! Bounded filesystem and network diagnostic probes. use super::{Check, NETWORK_TIMEOUT, Status}; +use std::fs::OpenOptions; use std::path::Path; pub(super) fn check_directory(name: &'static str, path: &Path) -> Check { @@ -34,13 +35,13 @@ pub(super) fn check_dir_writable(directory: &Path) -> Result<(), std::io::Error> "path is not a directory", )); } - if metadata.permissions().readonly() { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "directory is read-only", - )); - } - Ok(()) + let probe = directory.join(format!(".nemo-relay-write-probe-{}", uuid::Uuid::now_v7())); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe)?; + drop(file); + std::fs::remove_file(probe) } pub(super) async fn probe_http_named(name: &'static str, url: &str) -> Check { diff --git a/crates/cli/src/diagnostics/render.rs b/crates/cli/src/diagnostics/render.rs index 8b2a50c0b..928728286 100644 --- a/crates/cli/src/diagnostics/render.rs +++ b/crates/cli/src/diagnostics/render.rs @@ -214,7 +214,12 @@ pub(super) fn format_human_host_plugins(out: &mut String, report: &DoctorReport) pub(super) fn format_human_checks(out: &mut String, title: &str, checks: &[Check]) { out.push_str(&format!(" {title}\n")); for check in checks { - out.push_str(&format!(" {:<22} {}\n", check.name, check.details)); + out.push_str(&format!( + " {} {:<22} {}\n", + format_status(check.status), + check.name, + check.details + )); } out.push('\n'); } diff --git a/crates/cli/src/filesystem/bounded.rs b/crates/cli/src/filesystem/bounded.rs index 6e653680c..6be0ae646 100644 --- a/crates/cli/src/filesystem/bounded.rs +++ b/crates/cli/src/filesystem/bounded.rs @@ -21,19 +21,19 @@ pub(crate) fn stream_bounded_regular_file( const BUFFER_BYTES: usize = 64 * 1024; let metadata = std::fs::symlink_metadata(path).map_err(|error| { format!( - "failed to inspect {description} {} for persistent gateway identity: {error}", + "failed to inspect {description} {}: {error}", path.display() ) })?; if !metadata.file_type().is_file() { return Err(format!( - "{description} {} must be a regular file for persistent gateway identity", + "{description} {} must be a regular file", path.display() )); } if metadata.len() > MAX_BOUNDED_FILE_BYTES { return Err(format!( - "{description} {} exceeds the {MAX_BOUNDED_FILE_BYTES}-byte persistent gateway identity budget", + "{description} {} exceeds the {MAX_BOUNDED_FILE_BYTES}-byte limit", path.display() )); } @@ -44,46 +44,40 @@ pub(crate) fn stream_bounded_regular_file( use std::os::unix::fs::OpenOptionsExt; options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); } - let mut file = options.open(path).map_err(|error| { - format!( - "failed to read {description} {} for persistent gateway identity: {error}", - path.display() - ) - })?; + let mut file = options + .open(path) + .map_err(|error| format!("failed to read {description} {}: {error}", path.display()))?; let opened_metadata = file.metadata().map_err(|error| { format!( - "failed to inspect {description} {} for persistent gateway identity: {error}", + "failed to inspect {description} {}: {error}", path.display() ) })?; if !opened_metadata.file_type().is_file() { return Err(format!( - "{description} {} must be a regular file for persistent gateway identity", + "{description} {} must be a regular file", path.display() )); } if opened_metadata.len() > MAX_BOUNDED_FILE_BYTES { return Err(format!( - "{description} {} exceeds the {MAX_BOUNDED_FILE_BYTES}-byte persistent gateway identity budget", + "{description} {} exceeds the {MAX_BOUNDED_FILE_BYTES}-byte limit", path.display() )); } let mut buffer = [0_u8; BUFFER_BYTES]; let mut total = 0_u64; loop { - let read = file.read(&mut buffer).map_err(|error| { - format!( - "failed to read {description} {} for persistent gateway identity: {error}", - path.display() - ) - })?; + let read = file + .read(&mut buffer) + .map_err(|error| format!("failed to read {description} {}: {error}", path.display()))?; if read == 0 { break; } total = total.saturating_add(read as u64); if total > MAX_BOUNDED_FILE_BYTES { return Err(format!( - "{description} {} exceeds the {MAX_BOUNDED_FILE_BYTES}-byte persistent gateway identity budget", + "{description} {} exceeds the {MAX_BOUNDED_FILE_BYTES}-byte limit", path.display() )); } diff --git a/crates/cli/src/filesystem/snapshots.rs b/crates/cli/src/filesystem/snapshots.rs index 31fee3ece..4ae16056d 100644 --- a/crates/cli/src/filesystem/snapshots.rs +++ b/crates/cli/src/filesystem/snapshots.rs @@ -16,13 +16,25 @@ pub(crate) fn backup(path: &Path) -> Result<(), String> { return Ok(()); } if path.exists() { - fs::copy(path, &backup).map_err(|error| { - format!( - "failed to back up {} to {}: {error}", - path.display(), - backup.display() - ) - })?; + let bytes = fs::read(path) + .map_err(|error| format!("failed to read {} for backup: {error}", path.display()))?; + #[cfg(windows)] + { + let dacl = read_windows_dacl(path).map_err(|error| { + format!( + "failed to read access control for {}: {error}", + path.display() + ) + })?; + atomic_write_with_windows_dacl(&backup, &bytes, &dacl)?; + } + #[cfg(not(windows))] + { + let permissions = fs::metadata(path) + .map_err(|error| format!("failed to inspect {}: {error}", path.display()))? + .permissions(); + atomic_write_with_permissions(&backup, &bytes, Some(&permissions))?; + } } Ok(()) } diff --git a/crates/cli/src/filesystem/temp.rs b/crates/cli/src/filesystem/temp.rs index e3c9bf23e..f0f69ed93 100644 --- a/crates/cli/src/filesystem/temp.rs +++ b/crates/cli/src/filesystem/temp.rs @@ -17,6 +17,20 @@ pub(crate) fn private_temp_dir(parent: &Path, prefix: &str) -> Result error, + Err(cleanup_error) => std::io::Error::new( + cleanup_error.kind(), + format!( + "{error}; additionally failed to remove {}: {cleanup_error}", + path.display() + ), + ), + })); + } Ok(path) } diff --git a/crates/cli/src/gateway/client.rs b/crates/cli/src/gateway/client.rs index 749abd99c..ade1d77ad 100644 --- a/crates/cli/src/gateway/client.rs +++ b/crates/cli/src/gateway/client.rs @@ -74,20 +74,12 @@ pub(crate) fn post_verified( max_response_bytes: usize, ) -> Result { let (host, port) = parse_loopback_url(url).map_err(VerifiedHttpError::after_payload)?; - let address = (host.as_str(), port) - .to_socket_addrs() - .map_err(|error| { - VerifiedHttpError::before_payload(format!( - "failed to resolve verified gateway {url}: {error}" - )) - })? - .next() - .ok_or_else(|| { - VerifiedHttpError::before_payload(format!( - "verified gateway {url} has no socket address" - )) - })?; - let mut stream = TcpStream::connect_timeout(&address, timeout).map_err(|error| { + let addresses = (host.as_str(), port).to_socket_addrs().map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to resolve verified gateway {url}: {error}" + )) + })?; + let mut stream = connect_loopback(addresses, timeout).map_err(|error| { VerifiedHttpError::before_payload(format!( "failed to connect to verified gateway {url}: {error}" )) @@ -104,10 +96,15 @@ pub(crate) fn post_verified( })?; let key = BootstrapChallengeKey::load().map_err(|error| { - VerifiedHttpError::after_payload(format!( + VerifiedHttpError::before_payload(format!( "failed to load the Relay bootstrap challenge key: {error}" )) })?; + let tls_identity = crate::gateway::tls::RelayTlsIdentity::load().map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to load pinned Relay TLS identity: {error}" + )) + })?; let mut nonce = [0_u8; 32]; SystemRandom::new().fill(&mut nonce).map_err(|_| { VerifiedHttpError::after_payload("failed to generate a Relay bootstrap challenge") @@ -117,18 +114,53 @@ pub(crate) fn post_verified( .map(|byte| format!("{byte:02x}")) .collect::(); let authority = loopback_authority(&host, port); + let tunnel = format!( + "GET /bootstrap/tunnel HTTP/1.1\r\nHost: {authority}\r\nX-NeMo-Relay-Bootstrap-Fingerprint: {bootstrap_fingerprint}\r\nX-NeMo-Relay-Bootstrap-Nonce: {nonce}\r\nConnection: upgrade\r\nUpgrade: nemo-relay-tls\r\n\r\n" + ); + stream.write_all(tunnel.as_bytes()).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to request the Relay TLS tunnel: {error}" + )) + })?; + let (tunnel_headers, _) = read_http_message(&mut stream, 0).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to read the Relay TLS tunnel response: {error}" + )) + })?; + let proof_valid = http_header(&tunnel_headers, "x-nemo-relay-bootstrap-proof") + .is_some_and(|proof| key.verify(bootstrap_fingerprint, &nonce, proof)); + if http_status(&tunnel_headers) != Some(101) + || !proof_valid + || http_header(&tunnel_headers, "upgrade") != Some("nemo-relay-tls") + { + return Err(VerifiedHttpError::before_payload( + "gateway did not establish an authenticated Relay TLS tunnel", + )); + } + let client_config = tls_identity + .client_config() + .map_err(VerifiedHttpError::before_payload)?; + let server_name = rustls::pki_types::ServerName::try_from("localhost").map_err(|error| { + VerifiedHttpError::before_payload(format!("invalid Relay TLS server name: {error}")) + })?; + let connection = + rustls::ClientConnection::new(client_config, server_name).map_err(|error| { + VerifiedHttpError::before_payload(format!("failed to create Relay TLS client: {error}")) + })?; + let mut stream = rustls::StreamOwned::new(connection, stream); + let challenge = format!( "GET /healthz HTTP/1.1\r\nHost: {authority}\r\nX-NeMo-Relay-Bootstrap-Fingerprint: {bootstrap_fingerprint}\r\nX-NeMo-Relay-Bootstrap-Nonce: {nonce}\r\nConnection: keep-alive\r\n\r\n" ); stream.write_all(challenge.as_bytes()).map_err(|error| { VerifiedHttpError::before_payload(format!( - "failed to send the Relay bootstrap challenge: {error}" + "Relay TLS handshake or health request failed: {error}" )) })?; let (health_headers, health_body) = read_http_message(&mut stream, 16 * 1024).map_err(|error| { VerifiedHttpError::before_payload(format!( - "failed to read the Relay bootstrap challenge response: {error}" + "failed to read health response through Relay TLS: {error}" )) })?; let (health, _) = classify_health_response( @@ -230,7 +262,7 @@ pub(crate) fn probe_with_instance( return (RelayHealth::Unavailable, None); }; let mut stream = None; - for addr in addrs { + for addr in addrs.filter(|addr| addr.ip().is_loopback()) { match TcpStream::connect_timeout(&addr, HEALTHZ_TIMEOUT) { Ok(candidate) => { stream = Some(candidate); @@ -290,12 +322,10 @@ pub(crate) fn probe_with_instance( pub(crate) fn request_shutdown(url: &str, token: &str) -> Result<(), String> { let (host, port) = parse_loopback_url(url)?; - let address = (host.as_str(), port) + let addresses = (host.as_str(), port) .to_socket_addrs() - .map_err(|error| format!("failed to resolve managed sidecar {url}: {error}"))? - .next() - .ok_or_else(|| format!("managed sidecar {url} has no socket address"))?; - let mut stream = TcpStream::connect_timeout(&address, HEALTHZ_TIMEOUT) + .map_err(|error| format!("failed to resolve managed sidecar {url}: {error}"))?; + let mut stream = connect_loopback(addresses, HEALTHZ_TIMEOUT) .map_err(|error| format!("failed to connect to managed sidecar {url}: {error}"))?; stream .set_read_timeout(Some(HEALTHZ_TIMEOUT)) @@ -362,9 +392,7 @@ fn classify_health_response( { return (RelayHealth::Foreign, None); } - if body.get("version").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION")) - || http_status(headers) == Some(409) - { + if http_status(headers) == Some(409) { return (RelayHealth::Incompatible, None); } if http_status(headers) != Some(200) || body.get("status").and_then(Value::as_str) != Some("ok") @@ -389,8 +417,30 @@ fn classify_health_response( (RelayHealth::Compatible, Some(instance_id.to_owned())) } +fn connect_loopback( + addresses: impl IntoIterator, + timeout: Duration, +) -> std::io::Result { + let mut last_error = None; + for address in addresses + .into_iter() + .filter(|address| address.ip().is_loopback()) + { + match TcpStream::connect_timeout(&address, timeout) { + Ok(stream) => return Ok(stream), + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::AddrNotAvailable, + "gateway URL resolved to no loopback socket addresses", + ) + })) +} + fn read_http_message( - stream: &mut TcpStream, + stream: &mut impl Read, max_body_bytes: usize, ) -> std::io::Result<(Vec, Vec)> { const MAX_HEADER_BYTES: usize = 16 * 1024; @@ -423,6 +473,9 @@ fn read_http_message( "chunked HTTP responses are not supported by the verified Relay transport", )); } + if http_status(&headers) == Some(101) { + return Ok((headers, Vec::new())); + } let content_length = http_header(&headers, "content-length") .ok_or_else(|| { std::io::Error::new( diff --git a/crates/cli/src/gateway/mod.rs b/crates/cli/src/gateway/mod.rs index 449a7303d..a7831f363 100644 --- a/crates/cli/src/gateway/mod.rs +++ b/crates/cli/src/gateway/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod client; mod request; mod response; mod routes; +pub(crate) mod tls; use request::*; use response::*; @@ -653,7 +654,7 @@ async fn forward_upstream_request( ); let mut upstream = http.request(method.clone(), url).body(body_bytes.clone()); for (name, value) in &sanitized { - if should_forward_request_header(name) { + if should_forward_request_header(name, &sanitized) { upstream = upstream.header(name, value); } } @@ -861,7 +862,7 @@ pub(crate) async fn models( ); let mut upstream = state.http.get(upstream_url); for (name, value) in &sanitized { - if should_forward_request_header(name) { + if should_forward_request_header(name, &sanitized) { upstream = upstream.header(name, value); } } diff --git a/crates/cli/src/gateway/response.rs b/crates/cli/src/gateway/response.rs index 1ffe234a9..29f1e11e3 100644 --- a/crates/cli/src/gateway/response.rs +++ b/crates/cli/src/gateway/response.rs @@ -13,7 +13,7 @@ use crate::error::CliError; pub(super) fn observable_headers(headers: &HeaderMap) -> Map { let mut output = Map::new(); for (name, value) in headers { - if should_record_header(name) + if should_record_header(name, headers) && let Ok(value) = value.to_str() { output.insert(name.as_str().to_string(), json!(value)); @@ -29,7 +29,10 @@ pub(super) fn observable_headers(headers: &HeaderMap) -> Map { pub(super) fn response_headers(headers: &HeaderMap) -> HeaderMap { let mut output = HeaderMap::new(); for (name, value) in headers { - if !is_hop_by_hop(name) && name != http::header::CONTENT_LENGTH { + if !is_hop_by_hop(name) + && !named_by_connection_header(name, headers) + && name != http::header::CONTENT_LENGTH + { output.append(name.clone(), value.clone()); } } @@ -53,8 +56,9 @@ pub(super) fn build_response( // Allows provider request headers through unless they are transport-owned or must be recalculated // for the forwarded body. Host and content length are intentionally excluded because reqwest sets // them for the upstream connection. -pub(super) fn should_forward_request_header(name: &HeaderName) -> bool { +pub(super) fn should_forward_request_header(name: &HeaderName, headers: &HeaderMap) -> bool { !is_hop_by_hop(name) + && !named_by_connection_header(name, headers) && name != http::header::HOST && name != http::header::CONTENT_LENGTH && name.as_str() != BOOTSTRAP_CLIENT_TOKEN_HEADER @@ -73,8 +77,8 @@ pub(super) fn should_forward_request_header(name: &HeaderName) -> bool { // SDK and similar), `anthropic-api-key` (Anthropic), and the generic `api-key` alias used by some // providers/proxies (e.g., Azure OpenAI). `HeaderName::as_str()` already returns the canonical // lowercase form so string comparisons are case-insensitive by construction. -pub(super) fn should_record_header(name: &HeaderName) -> bool { - should_forward_request_header(name) +pub(super) fn should_record_header(name: &HeaderName, headers: &HeaderMap) -> bool { + should_forward_request_header(name, headers) && name != http::header::AUTHORIZATION && name != http::header::COOKIE && name.as_str() != "x-api-key" @@ -82,6 +86,15 @@ pub(super) fn should_record_header(name: &HeaderName) -> bool { && name.as_str() != "anthropic-api-key" } +fn named_by_connection_header(name: &HeaderName, headers: &HeaderMap) -> bool { + headers + .get_all(http::header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|token| token.trim().eq_ignore_ascii_case(name.as_str())) +} + // Identifies headers that describe a single transport hop and therefore must not be proxied across // the client-gateway-upstream boundary. pub(super) fn is_hop_by_hop(name: &HeaderName) -> bool { diff --git a/crates/cli/src/gateway/tls.rs b/crates/cli/src/gateway/tls.rs new file mode 100644 index 000000000..850fad57a --- /dev/null +++ b/crates/cli/src/gateway/tls.rs @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Pinned per-user TLS identity for authenticated bootstrap hook delivery. + +use std::sync::Arc; + +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use serde::{Deserialize, Serialize}; + +const IDENTITY_FILE: &str = "hook-tls-identity.json"; + +#[derive(Deserialize, Serialize)] +struct IdentityRecord { + certificate_der: Vec, + private_key_der: Vec, +} + +pub(crate) struct RelayTlsIdentity { + record: IdentityRecord, +} + +impl RelayTlsIdentity { + pub(crate) fn load_or_create() -> Result { + let path = identity_path()?; + if path.exists() { + return Self::load(); + } + let certified = rcgen::generate_simple_self_signed(vec!["localhost".into()]) + .map_err(|error| format!("failed to generate Relay TLS identity: {error}"))?; + let record = IdentityRecord { + certificate_der: certified.cert.der().to_vec(), + private_key_der: certified.key_pair.serialize_der(), + }; + let bytes = serde_json::to_vec(&record) + .map_err(|error| format!("failed to encode Relay TLS identity: {error}"))?; + crate::filesystem::atomic_write_private(&path, &bytes)?; + Ok(Self { record }) + } + + pub(crate) fn load() -> Result { + let path = identity_path()?; + let bytes = crate::filesystem::bounded::read_bounded_regular_file( + &path, + "Relay hook TLS identity", + )?; + let record = serde_json::from_slice::(&bytes) + .map_err(|error| format!("invalid Relay TLS identity {}: {error}", path.display()))?; + if record.certificate_der.is_empty() || record.private_key_der.is_empty() { + return Err(format!( + "Relay TLS identity {} is incomplete", + path.display() + )); + } + Ok(Self { record }) + } + + pub(crate) fn server_config(&self) -> Result, String> { + let certificate = CertificateDer::from(self.record.certificate_der.clone()); + let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from( + self.record.private_key_der.clone(), + )); + rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![certificate], key) + .map(Arc::new) + .map_err(|error| format!("invalid Relay TLS server identity: {error}")) + } + + pub(crate) fn client_config(&self) -> Result, String> { + let mut roots = rustls::RootCertStore::empty(); + roots + .add(CertificateDer::from(self.record.certificate_der.clone())) + .map_err(|error| format!("invalid pinned Relay TLS certificate: {error}"))?; + Ok(Arc::new( + rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(), + )) + } +} + +fn identity_path() -> Result { + Ok(crate::bootstrap::state::state_dir()?.join(IDENTITY_FILE)) +} + +#[cfg(test)] +#[path = "../../tests/coverage/shared/gateway_tls_tests.rs"] +mod tests; diff --git a/crates/cli/src/hooks/delivery.rs b/crates/cli/src/hooks/delivery.rs index 1207601ee..d74939fb3 100644 --- a/crates/cli/src/hooks/delivery.rs +++ b/crates/cli/src/hooks/delivery.rs @@ -100,9 +100,17 @@ pub(crate) async fn hook_forward(command: HookForwardRequest) -> Result<(), CliE .map(|launch| &launch.gateway) .or(transparent_gateway.as_ref()); if let Some(gateway) = verified_gateway { - let response = - send_verified_hook_forward_request(&command, gateway, &destination.gateway_url, input) - .await?; + let response = match send_verified_hook_forward_request( + &command, + gateway, + &destination.gateway_url, + input, + ) + .await + { + Ok(response) => response, + Err(error) => return handle_hook_error(error, fail_closed), + }; return handle_verified_hook_forward_response(response, fail_closed); } @@ -111,7 +119,10 @@ pub(crate) async fn hook_forward(command: HookForwardRequest) -> Result<(), CliE destination.gateway_url.trim_end_matches('/'), command.agent.hook_path() ); - let response = send_hook_forward_request(&command, &url, input).await?; + let response = match send_hook_forward_request(&command, &url, input).await { + Ok(response) => response, + Err(error) => return handle_hook_error(error, fail_closed), + }; handle_hook_forward_response(response, fail_closed).await } diff --git a/crates/cli/src/installation/generation.rs b/crates/cli/src/installation/generation.rs index 7a096c135..822d548e9 100644 --- a/crates/cli/src/installation/generation.rs +++ b/crates/cli/src/installation/generation.rs @@ -856,7 +856,7 @@ fn open_generation_lock_path(lock_path: &Path) -> Result { #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); } options.open(lock_path).map_err(|error| { format!( diff --git a/crates/cli/src/installation/marketplace/assets.rs b/crates/cli/src/installation/marketplace/assets.rs index c93e8eed2..cf624014b 100644 --- a/crates/cli/src/installation/marketplace/assets.rs +++ b/crates/cli/src/installation/marketplace/assets.rs @@ -92,18 +92,18 @@ pub(super) fn plugin_mcp_config( generation_fence: &Path, generation_token: &str, ) -> Result { - let generation_fence = absolute_or_self(generation_fence); + let generation_fence = absolute_or_self(generation_fence)?; let server = crate::mcp::persistent_server(relay, &generation_fence, generation_token); host.plugin_mcp_config(server) } -fn absolute_or_self(path: &Path) -> std::path::PathBuf { +fn absolute_or_self(path: &Path) -> Result { if path.is_absolute() { - return path.to_owned(); + return Ok(path.to_owned()); } env::current_dir() .map(|current| current.join(path)) - .unwrap_or_else(|_| path.to_owned()) + .map_err(|error| format!("failed to resolve relative generation fence: {error}")) } pub(super) fn plugin_hooks( @@ -112,6 +112,6 @@ pub(super) fn plugin_hooks( generation_fence: &Path, generation_token: &str, ) -> Result { - let generation_fence = absolute_or_self(generation_fence); + let generation_fence = absolute_or_self(generation_fence)?; host.plugin_hooks(relay, &generation_fence, generation_token) } diff --git a/crates/cli/src/installation/marketplace/host.rs b/crates/cli/src/installation/marketplace/host.rs index 8ce2595f8..a49ff9182 100644 --- a/crates/cli/src/installation/marketplace/host.rs +++ b/crates/cli/src/installation/marketplace/host.rs @@ -266,7 +266,9 @@ pub(crate) fn require_relay( return Ok(PathBuf::from(RELAY_COMMAND)); } runner - .current_executable() + .resolve_executable(RELAY_COMMAND)? + .map(Ok) + .unwrap_or_else(|| runner.current_executable()) .map(|path| path.canonicalize().unwrap_or(path)) .map(crate::process::portable_executable_path) } diff --git a/crates/cli/src/mcp/gateway.rs b/crates/cli/src/mcp/gateway.rs index 16050f77f..2e35860bc 100644 --- a/crates/cli/src/mcp/gateway.rs +++ b/crates/cli/src/mcp/gateway.rs @@ -4,6 +4,8 @@ //! Acquisition and liveness lease for a shared coding-agent gateway. use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; use std::time::Duration; use crate::bootstrap::{GatewayEndpoint, GatewaySpec}; @@ -47,11 +49,17 @@ impl GatewayPlan { pub(super) async fn acquire(mut self) -> Result { let endpoint = acquire_gateway(self.spec.clone(), self.generation_guard.take()).await?; - let monitor = tokio::spawn(async move { self.monitor(endpoint).await }); - Ok(GatewayLease { monitor }) + let shutdown = Arc::new(LeaseShutdown::default()); + let monitor_shutdown = shutdown.clone(); + let monitor = tokio::spawn(async move { self.monitor(endpoint, monitor_shutdown).await }); + Ok(GatewayLease { monitor, shutdown }) } - async fn monitor(self, endpoint: crate::bootstrap::GatewayEndpoint) -> Result<(), CliError> { + async fn monitor( + self, + endpoint: crate::bootstrap::GatewayEndpoint, + shutdown: Arc, + ) -> Result<(), CliError> { let health_spec = self.spec.clone(); let restart_spec = self.spec.clone(); let restart_generation = self.generation.clone(); @@ -75,6 +83,7 @@ impl GatewayPlan { restart_spec.clone(), restart_generation.clone(), expected_instance, + shutdown.clone(), ) }, move || { @@ -89,13 +98,17 @@ impl GatewayPlan { /// An active liveness lease. Dropping it stops heartbeats immediately. pub(super) struct GatewayLease { monitor: tokio::task::JoinHandle>, + shutdown: Arc, } impl GatewayLease { #[cfg(test)] pub(super) fn test_pending() -> Self { let monitor = tokio::spawn(std::future::pending::>()); - Self { monitor } + Self { + monitor, + shutdown: Arc::new(LeaseShutdown::default()), + } } pub(super) async fn borrow( @@ -147,7 +160,10 @@ impl GatewayLease { } } }); - Ok(Self { monitor }) + Ok(Self { + monitor, + shutdown: Arc::new(LeaseShutdown::default()), + }) } pub(super) async fn wait(&mut self) -> Result<(), CliError> { @@ -174,7 +190,61 @@ async fn authenticated_instance_id( impl Drop for GatewayLease { fn drop(&mut self) { + self.shutdown.stop(); self.monitor.abort(); + self.shutdown.wait_for_recovery(); + } +} + +#[derive(Default)] +struct LeaseShutdown { + stopped: AtomicBool, + recovery_count: Mutex, + recovery_finished: Condvar, +} + +impl LeaseShutdown { + fn start_recovery(self: &Arc) -> Result { + let mut count = self + .recovery_count + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.stopped.load(Ordering::Acquire) { + return Err(CliError::Launch("gateway lease is shutting down".into())); + } + *count += 1; + Ok(RecoveryGuard(self.clone())) + } + + fn stop(&self) { + self.stopped.store(true, Ordering::Release); + } + + fn wait_for_recovery(&self) { + let mut count = self + .recovery_count + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + while *count != 0 { + count = self + .recovery_finished + .wait(count) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + } +} + +struct RecoveryGuard(Arc); + +impl Drop for RecoveryGuard { + fn drop(&mut self) { + let mut count = self + .0 + .recovery_count + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *count = count.saturating_sub(1); + self.0.recovery_finished.notify_all(); } } @@ -195,8 +265,11 @@ async fn recover_gateway( spec: GatewaySpec, generation: Option, expected_instance: String, + shutdown: Arc, ) -> Result { + let recovery_guard = shutdown.start_recovery()?; tokio::task::spawn_blocking(move || { + let _recovery_guard = recovery_guard; let _generation_guard = generation .as_ref() .map(InstallGeneration::guard_current) @@ -206,6 +279,15 @@ async fn recover_gateway( .await .map_err(|error| CliError::Launch(format!("gateway recovery task failed: {error}")))? .map_err(CliError::Launch) + .and_then(|endpoint| { + if shutdown.stopped.load(Ordering::Acquire) { + Err(CliError::Launch( + "gateway lease closed during recovery".into(), + )) + } else { + Ok(endpoint) + } + }) } async fn verify_lifecycle_async(generation: Option) -> Result<(), CliError> { diff --git a/crates/cli/src/mcp/protocol.rs b/crates/cli/src/mcp/protocol.rs index 1c39aff20..ef23f881a 100644 --- a/crates/cli/src/mcp/protocol.rs +++ b/crates/cli/src/mcp/protocol.rs @@ -25,16 +25,22 @@ pub(super) fn evaluate_frame(frame: &str) -> FrameAction { } pub(super) fn response_for(message: &Value) -> Option { - let id = message.get("id").cloned(); + let raw_id = message.get("id"); + let response_id = raw_id + .filter(|id| valid_request_id(id)) + .cloned() + .unwrap_or(Value::Null); if !message.is_object() || message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { - return Some(jsonrpc_error( - id.unwrap_or(Value::Null), - -32600, - "Invalid Request", - )); + return Some(jsonrpc_error(response_id, -32600, "Invalid Request")); } - let id = id?; let method = message.get("method").and_then(Value::as_str); + if raw_id.is_some_and(|id| !valid_request_id(id)) { + return Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")); + } + if method.is_none() { + return Some(jsonrpc_error(response_id, -32600, "Invalid Request")); + } + let id = raw_id?.clone(); match method { Some("initialize") => { let Some(requested_protocol) = message @@ -76,6 +82,10 @@ pub(super) fn response_for(message: &Value) -> Option { } } +fn valid_request_id(id: &Value) -> bool { + id.is_string() || id.as_i64().is_some() || id.as_u64().is_some() +} + pub(super) fn jsonrpc_error(id: Value, code: i64, message: &str) -> Value { json!({ "jsonrpc": "2.0", diff --git a/crates/cli/src/plugins/lifecycle/environment.rs b/crates/cli/src/plugins/lifecycle/environment.rs index c7bdcf73e..c8a0d572e 100644 --- a/crates/cli/src/plugins/lifecycle/environment.rs +++ b/crates/cli/src/plugins/lifecycle/environment.rs @@ -99,6 +99,7 @@ pub(super) fn validate_python_entrypoint_artifact( ) })?; if callable.is_empty() + || callable.contains(':') || module.is_empty() || module .split('.') @@ -447,6 +448,7 @@ fn digest_environment_directory( let source_metadata = std::fs::metadata(&source) .map_err(|error| format!("failed to inspect {}: {error}", source.display()))?; if source_metadata.is_dir() { + update_tree_digest(digest, b'd', &relative, &[]); digest_environment_directory( &source, &relative, @@ -475,14 +477,36 @@ fn digest_environment_directory( crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES )); } - digest.update(relative.to_string_lossy().as_bytes()); - digest.update([0]); - digest.update(&bytes); + update_tree_digest(digest, b'f', &relative, &bytes); } ancestors.pop(); Ok(()) } +fn update_tree_digest(digest: &mut Sha256, entry_type: u8, path: &Path, payload: &[u8]) { + let path = raw_path_bytes(path); + digest.update([entry_type]); + digest.update((path.len() as u64).to_le_bytes()); + digest.update(&path); + digest.update((payload.len() as u64).to_le_bytes()); + digest.update(payload); +} + +#[cfg(unix)] +fn raw_path_bytes(path: &Path) -> Vec { + use std::os::unix::ffi::OsStrExt; + path.as_os_str().as_bytes().to_vec() +} + +#[cfg(windows)] +fn raw_path_bytes(path: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + path.as_os_str() + .encode_wide() + .flat_map(u16::to_le_bytes) + .collect() +} + pub(super) fn remove_managed_environment( state_path: &Path, plugin_id: &str, diff --git a/crates/cli/src/plugins/lifecycle/mod.rs b/crates/cli/src/plugins/lifecycle/mod.rs index 8fcef7e89..af539db76 100644 --- a/crates/cli/src/plugins/lifecycle/mod.rs +++ b/crates/cli/src/plugins/lifecycle/mod.rs @@ -269,6 +269,8 @@ pub(crate) fn validate( } let resolved = resolve_plugins_config(server.config.as_ref())?; let (manifest, manifest_ref) = load_manifest_for_action("validate", &path)?; + validate_python_entrypoint_artifact(&manifest, &manifest_ref) + .map_err(CliError::Config)?; load_config_schema_for_manifest(&manifest, &manifest_ref)?; let policy = evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); @@ -308,6 +310,8 @@ pub(crate) fn validate( let entry = find_registered_entry(&scopes, "plugins validate", &plugin_id)?; let manifest_ref = manifest_ref_from_record(&entry.record)?; let (manifest, manifest_ref) = load_manifest_for_action("validate", &manifest_ref)?; + validate_python_entrypoint_artifact(&manifest, &manifest_ref) + .map_err(CliError::Config)?; if let Some(schema) = load_config_schema_for_manifest(&manifest, &manifest_ref)? { let config = host_config_by_id .get(&plugin_id) diff --git a/crates/cli/src/plugins/mod.rs b/crates/cli/src/plugins/mod.rs index 86d1467fc..7ef9ec827 100644 --- a/crates/cli/src/plugins/mod.rs +++ b/crates/cli/src/plugins/mod.rs @@ -1190,6 +1190,9 @@ fn clear_value_field( let Some(field) = schema.fields.get(selected) else { return false; }; + if !field.optional { + return false; + } remove_value_field(value, field.name); true } diff --git a/crates/cli/src/plugins/pricing.rs b/crates/cli/src/plugins/pricing.rs index b6ec7112b..5112ee00d 100644 --- a/crates/cli/src/plugins/pricing.rs +++ b/crates/cli/src/plugins/pricing.rs @@ -141,9 +141,17 @@ pub(crate) fn resolve(command: PricingResolveRequest) -> Result<(), CliError> { } fn read_pricing_catalog(path: &Path) -> Result { - let raw = std::fs::read_to_string(path).map_err(|source| { + let bytes = + crate::filesystem::bounded::read_bounded_regular_file(path, "model pricing catalog") + .map_err(|source| { + CliError::Config(format!( + "could not read model pricing catalog '{}': {source}", + path.display() + )) + })?; + let raw = String::from_utf8(bytes).map_err(|source| { CliError::Config(format!( - "could not read model pricing catalog '{}': {source}", + "model pricing catalog '{}' is not valid UTF-8: {source}", path.display() )) })?; diff --git a/crates/cli/src/plugins/schema/mod.rs b/crates/cli/src/plugins/schema/mod.rs index 16cbc3e49..4d505fad9 100644 --- a/crates/cli/src/plugins/schema/mod.rs +++ b/crates/cli/src/plugins/schema/mod.rs @@ -1233,12 +1233,13 @@ fn resolve_schema_chain<'a>( return Ok(schema); }; let fragment = decode_reference_fragment(reference)?; - let canonical_reference = format!("#{fragment}"); + let resource = active_schema_resource(root, schema).unwrap_or(root); + let target = resolve_fragment(resource, &fragment) + .ok_or_else(|| ResolveError::Missing(reference.to_owned()))?; + let canonical_reference = format!("{:p}#{fragment}", resource); if !references.insert(canonical_reference.clone()) { - return Err(ResolveError::Cycle(canonical_reference)); + return Err(ResolveError::Cycle(reference.to_owned())); } - let target = resolve_fragment(root, &fragment) - .ok_or_else(|| ResolveError::Missing(reference.to_owned()))?; resolve_schema_chain(root, target, references, reference_chain) } @@ -1291,24 +1292,65 @@ fn resolve_fragment<'a>(root: &'a Value, fragment: &str) -> Option<&'a Value> { if fragment.starts_with('/') { return root.pointer(fragment); } - find_anchor(root, fragment) + find_anchor(root, fragment, true) } -fn find_anchor<'a>(schema: &'a Value, anchor: &str) -> Option<&'a Value> { +fn find_anchor<'a>(schema: &'a Value, anchor: &str, resource_root: bool) -> Option<&'a Value> { match schema { Value::Object(object) => { + if !resource_root && establishes_schema_resource(object) { + return None; + } if object.get("$anchor").and_then(Value::as_str) == Some(anchor) || object.get("$id").and_then(Value::as_str) == Some(&format!("#{anchor}")) { return Some(schema); } - object.values().find_map(|child| find_anchor(child, anchor)) + object + .values() + .find_map(|child| find_anchor(child, anchor, false)) } - Value::Array(values) => values.iter().find_map(|child| find_anchor(child, anchor)), + Value::Array(values) => values + .iter() + .find_map(|child| find_anchor(child, anchor, false)), _ => None, } } +fn active_schema_resource<'a>(root: &'a Value, target: &Value) -> Option<&'a Value> { + fn locate<'a>( + current: &'a Value, + target: *const Value, + active: &'a Value, + ) -> Option<&'a Value> { + let active = match current { + Value::Object(object) if establishes_schema_resource(object) => current, + _ => active, + }; + if std::ptr::eq(current, target) { + return Some(active); + } + match current { + Value::Object(object) => object + .values() + .find_map(|child| locate(child, target, active)), + Value::Array(values) => values + .iter() + .find_map(|child| locate(child, target, active)), + _ => None, + } + } + + locate(root, std::ptr::from_ref(target), root) +} + +fn establishes_schema_resource(object: &Map) -> bool { + object + .get("$id") + .and_then(Value::as_str) + .is_some_and(|id| !id.starts_with('#')) +} + #[cfg(test)] #[path = "../../../tests/coverage/shared/plugins_schema_tests.rs"] mod tests; diff --git a/crates/cli/src/process/detached.rs b/crates/cli/src/process/detached.rs index ea488769e..9b2cc2ca3 100644 --- a/crates/cli/src/process/detached.rs +++ b/crates/cli/src/process/detached.rs @@ -3,7 +3,10 @@ //! Minimal cross-platform process detachment for the shared gateway. -use std::process::{Child, Command}; +use std::process::Command; + +#[cfg(not(windows))] +use std::process::Child; #[cfg(windows)] use std::sync::Mutex; @@ -11,103 +14,256 @@ use std::sync::Mutex; #[cfg(windows)] static SIDECAR_SPAWN_LOCK: Mutex<()> = Mutex::new(()); +#[cfg(not(windows))] +pub(crate) type DetachedChild = Child; + #[cfg(windows)] -struct HandleInheritanceGuard { - handles: Vec, +pub(crate) struct DetachedChild { + process: windows_sys::Win32::Foundation::HANDLE, + thread: windows_sys::Win32::Foundation::HANDLE, + id: u32, } #[cfg(windows)] -impl HandleInheritanceGuard { - fn suppress( - handles: impl IntoIterator, - ) -> std::io::Result { - use windows_sys::Win32::Foundation::{ - GetHandleInformation, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, SetHandleInformation, - }; - - let mut guard = Self { - handles: Vec::new(), - }; - for handle in handles { - if handle.is_null() || handle == INVALID_HANDLE_VALUE || guard.handles.contains(&handle) - { - continue; - } - let mut flags = 0; - // SAFETY: handle is live and flags is writable storage. - if unsafe { GetHandleInformation(handle, &mut flags) } == 0 { - return Err(std::io::Error::last_os_error()); - } - if flags & HANDLE_FLAG_INHERIT == 0 { - continue; - } - // SAFETY: The mask changes only the inheritance bit on this live handle. - if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) } == 0 { - return Err(std::io::Error::last_os_error()); +unsafe impl Send for DetachedChild {} + +#[cfg(windows)] +impl DetachedChild { + pub(crate) fn id(&self) -> u32 { + self.id + } + + pub(crate) fn try_wait(&mut self) -> std::io::Result> { + use std::os::windows::process::ExitStatusExt; + use windows_sys::Win32::Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}; + use windows_sys::Win32::System::Threading::{GetExitCodeProcess, WaitForSingleObject}; + + // SAFETY: `process` is owned by this value until Drop. + match unsafe { WaitForSingleObject(self.process, 0) } { + WAIT_TIMEOUT => Ok(None), + WAIT_OBJECT_0 => { + let mut code = 0; + // SAFETY: The process handle and output pointer are valid. + if unsafe { GetExitCodeProcess(self.process, &mut code) } == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(Some(std::process::ExitStatus::from_raw(code))) + } } - guard.handles.push(handle); + _ => Err(std::io::Error::last_os_error()), } - Ok(guard) } - fn restore(&mut self) -> std::io::Result<()> { - use windows_sys::Win32::Foundation::{HANDLE_FLAG_INHERIT, SetHandleInformation}; + pub(crate) fn wait(&mut self) -> std::io::Result { + use windows_sys::Win32::Foundation::WAIT_OBJECT_0; + use windows_sys::Win32::System::Threading::{INFINITE, WaitForSingleObject}; - let mut failed = Vec::new(); - let mut first_error = None; - for handle in self.handles.drain(..).rev() { - // SAFETY: Each handle was live and inheritable when this guard suppressed it. - if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) } - == 0 - { - failed.push(handle); - first_error.get_or_insert_with(std::io::Error::last_os_error); - } + // SAFETY: `process` is owned by this value until Drop. + if unsafe { WaitForSingleObject(self.process, INFINITE) } != WAIT_OBJECT_0 { + return Err(std::io::Error::last_os_error()); } - self.handles = failed; - first_error.map_or(Ok(()), Err) + self.try_wait()?.ok_or_else(|| { + std::io::Error::other("detached gateway was still running after a completed wait") + }) } } #[cfg(windows)] -impl Drop for HandleInheritanceGuard { +impl Drop for DetachedChild { fn drop(&mut self) { - let _ = self.restore(); + use windows_sys::Win32::Foundation::CloseHandle; + + // SAFETY: Both handles were returned by CreateProcessW and are owned here. + unsafe { + CloseHandle(self.thread); + CloseHandle(self.process); + } } } #[cfg(windows)] -pub(crate) fn spawn_detached(command: &mut Command) -> std::io::Result { +fn spawn_detached_with_handle_list(command: &Command) -> std::io::Result { + use std::collections::BTreeMap; + use std::ffi::{OsStr, OsString, c_void}; + use std::fs::OpenOptions; + use std::os::windows::ffi::OsStrExt; use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::{HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation}; + use windows_sys::Win32::System::Threading::{ + CREATE_UNICODE_ENVIRONMENT, CreateProcessW, DeleteProcThreadAttributeList, + EXTENDED_STARTUPINFO_PRESENT, InitializeProcThreadAttributeList, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROCESS_INFORMATION, STARTF_USESTDHANDLES, + STARTUPINFOEXW, UpdateProcThreadAttribute, + }; - let _spawn_guard = SIDECAR_SPAWN_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let standard_handles = [ - std::io::stdin().as_raw_handle().cast(), - std::io::stdout().as_raw_handle().cast(), - std::io::stderr().as_raw_handle().cast(), + fn wide_nul(value: &OsStr) -> Vec { + value.encode_wide().chain(std::iter::once(0)).collect() + } + + fn append_quoted(command_line: &mut Vec, value: &OsStr) { + const BACKSLASH: u16 = b'\\' as u16; + const QUOTE: u16 = b'"' as u16; + command_line.push(QUOTE); + let mut slashes = 0; + for unit in value.encode_wide() { + if unit == BACKSLASH { + slashes += 1; + continue; + } + if unit == QUOTE { + command_line.extend(std::iter::repeat_n(BACKSLASH, slashes * 2 + 1)); + } else { + command_line.extend(std::iter::repeat_n(BACKSLASH, slashes)); + } + slashes = 0; + command_line.push(unit); + } + command_line.extend(std::iter::repeat_n(BACKSLASH, slashes * 2)); + command_line.push(QUOTE); + } + + fn environment_block(command: &Command) -> Vec { + let mut environment = BTreeMap::::new(); + for (name, value) in std::env::vars_os() { + environment.insert(name.to_string_lossy().to_uppercase(), (name, value)); + } + for (name, value) in command.get_envs() { + let key = name.to_string_lossy().to_uppercase(); + if let Some(value) = value { + environment.insert(key, (name.to_owned(), value.to_owned())); + } else { + environment.remove(&key); + } + } + let mut block = Vec::new(); + for (_, (name, value)) in environment { + block.extend(name.encode_wide()); + block.push(b'=' as u16); + block.extend(value.encode_wide()); + block.push(0); + } + block.push(0); + block + } + + struct AttributeList(*mut c_void); + impl Drop for AttributeList { + fn drop(&mut self) { + // SAFETY: The list was initialized successfully and is still live. + unsafe { DeleteProcThreadAttributeList(self.0) }; + } + } + + let stdin = OpenOptions::new().read(true).open(r"\\.\NUL")?; + let stdout = OpenOptions::new().write(true).open(r"\\.\NUL")?; + let handles = [ + stdin.as_raw_handle().cast::(), + stdout.as_raw_handle().cast::(), ]; - let mut inheritance = HandleInheritanceGuard::suppress(standard_handles)?; - let spawned = command.spawn(); - let restored = inheritance.restore(); - match (spawned, restored) { - (Ok(child), Ok(())) => Ok(child), - (Err(error), Ok(())) => Err(error), - (Ok(mut child), Err(error)) => { - let _ = child.kill(); - let _ = child.wait(); - Err(error) + for handle in handles { + // SAFETY: These are live handles owned by `stdin` and `stdout`. + if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) } == 0 { + return Err(std::io::Error::last_os_error()); } - (Err(spawn_error), Err(restore_error)) => Err(std::io::Error::new( - spawn_error.kind(), - format!("{spawn_error}; additionally, {restore_error}"), - )), } + + let mut attribute_bytes = 0; + // SAFETY: A null first call obtains the required allocation size. + unsafe { InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut attribute_bytes) }; + if attribute_bytes == 0 { + return Err(std::io::Error::last_os_error()); + } + let words = attribute_bytes.div_ceil(std::mem::size_of::()); + let mut attribute_storage = vec![0_usize; words]; + let attribute_pointer = attribute_storage.as_mut_ptr().cast::(); + // SAFETY: The aligned allocation has the size returned by the sizing call. + if unsafe { InitializeProcThreadAttributeList(attribute_pointer, 1, 0, &mut attribute_bytes) } + == 0 + { + return Err(std::io::Error::last_os_error()); + } + let attribute_list = AttributeList(attribute_pointer); + // SAFETY: `handles` remains live through CreateProcessW and contains only the intended stdio. + if unsafe { + UpdateProcThreadAttribute( + attribute_list.0, + 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, + handles.as_ptr().cast(), + std::mem::size_of_val(&handles), + std::ptr::null_mut(), + std::ptr::null(), + ) + } == 0 + { + return Err(std::io::Error::last_os_error()); + } + + let program = wide_nul(command.get_program()); + let mut command_line = Vec::new(); + append_quoted(&mut command_line, command.get_program()); + for argument in command.get_args() { + command_line.push(b' ' as u16); + append_quoted(&mut command_line, argument); + } + command_line.push(0); + let environment = environment_block(command); + let current_dir = command + .get_current_dir() + .map(|path| wide_nul(path.as_os_str())); + + let mut startup = STARTUPINFOEXW::default(); + startup.StartupInfo.cb = std::mem::size_of::() as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = handles[0] as HANDLE; + startup.StartupInfo.hStdOutput = handles[1] as HANDLE; + startup.StartupInfo.hStdError = handles[1] as HANDLE; + startup.lpAttributeList = attribute_list.0; + let mut process = PROCESS_INFORMATION::default(); + let (in_job, limits) = current_windows_job_limits(); + let (creation_flags, _) = windows_creation_flags(in_job, limits); + // SAFETY: Every pointer references initialized storage that remains live for this call. + let created = unsafe { + CreateProcessW( + program.as_ptr(), + command_line.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 1, + creation_flags | CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT, + environment.as_ptr().cast(), + current_dir + .as_ref() + .map_or(std::ptr::null(), |path| path.as_ptr()), + (&raw const startup).cast(), + &mut process, + ) + }; + let create_error = (created == 0).then(std::io::Error::last_os_error); + for handle in handles { + // SAFETY: The handles remain live; clear inheritance before releasing the spawn lock. + unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) }; + } + if let Some(error) = create_error { + return Err(error); + } + Ok(DetachedChild { + process: process.hProcess, + thread: process.hThread, + id: process.dwProcessId, + }) +} + +#[cfg(windows)] +pub(crate) fn spawn_detached(command: &mut Command) -> std::io::Result { + let _spawn_guard = SIDECAR_SPAWN_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + spawn_detached_with_handle_list(command) } #[cfg(not(windows))] -pub(crate) fn spawn_detached(command: &mut Command) -> std::io::Result { +pub(crate) fn spawn_detached(command: &mut Command) -> std::io::Result { command.spawn() } @@ -188,23 +344,20 @@ fn current_windows_job_limits() -> (bool, Option) { } #[cfg(windows)] -pub(crate) fn configure_detached(command: &mut Command) { - use std::os::windows::process::CommandExt; - +pub(crate) fn configure_detached(_command: &mut Command) { let (in_job, limits) = current_windows_job_limits(); - let (flags, limited_lifetime) = windows_creation_flags(in_job, limits); + let (_, limited_lifetime) = windows_creation_flags(in_job, limits); if limited_lifetime { eprintln!( "warning: the current Windows Job Object does not permit process breakaway; the shared Relay gateway lifetime is limited to the host job" ); } - command.creation_flags(flags); } #[cfg(not(any(unix, windows)))] pub(crate) fn configure_detached(_command: &mut Command) {} -pub(crate) fn terminate_tree(child: &mut Child) { +pub(crate) fn terminate_tree(child: &mut DetachedChild) { #[cfg(unix)] { let process_group = -(child.id() as i32); @@ -219,7 +372,11 @@ pub(crate) fn terminate_tree(child: &mut Child) { .args(["/PID", &child.id().to_string(), "/T", "/F"]) .status(); if !status.is_ok_and(|status| status.success()) { - let _ = child.kill(); + eprintln!( + "failed to terminate detached gateway process tree {} with taskkill", + child.id() + ); + return; } } #[cfg(not(any(unix, windows)))] diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index fdb3203ea..fdcf44e0c 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -637,6 +637,8 @@ fn observability_exporter_destinations(config: &ObservabilityConfig) -> Vec Vec Vec String { match storage { - AtifStorageConfig::Http(http) => http.endpoint.clone(), + AtifStorageConfig::Http(http) => sanitized_url(&http.endpoint), AtifStorageConfig::S3(s3) => { let prefix = s3.key_prefix.as_deref().unwrap_or("").trim_matches('/'); if prefix.is_empty() { @@ -673,6 +677,28 @@ fn atif_storage_destination(storage: &AtifStorageConfig) -> String { } } +fn sanitized_url(value: &str) -> String { + let Ok(mut url) = reqwest::Url::parse(value) else { + return "configured endpoint".into(); + }; + let _ = url.set_username(""); + let _ = url.set_password(None); + if url.query().is_some() { + let keys = url + .query_pairs() + .map(|(key, _)| key.into_owned()) + .collect::>(); + url.set_query(None); + if !keys.is_empty() { + let mut query = url.query_pairs_mut(); + for key in keys { + query.append_pair(&key, "[REDACTED]"); + } + } + } + url.to_string() +} + fn current_output_directory() -> PathBuf { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) } diff --git a/crates/cli/src/server/mod.rs b/crates/cli/src/server/mod.rs index e6c616516..5eab67aac 100644 --- a/crates/cli/src/server/mod.rs +++ b/crates/cli/src/server/mod.rs @@ -12,9 +12,10 @@ use std::pin::Pin; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use axum::body::Body; use axum::extract::rejection::JsonRejection; use axum::extract::{DefaultBodyLimit, State}; -use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::http::{HeaderMap, HeaderValue, Request, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; @@ -57,6 +58,8 @@ pub(crate) struct AppState { pub(crate) last_activity: Arc>, pub(crate) bootstrap_shutdown: Option, pub(crate) instance_id: String, + pub(crate) bootstrap_tls: Option>, + pub(crate) local_address: Option, } #[derive(Clone)] @@ -99,8 +102,8 @@ pub(crate) async fn bind_listener(bind: SocketAddr) -> Result, ready_file: Option<&Path>, ) -> Result<(), CliError> { + let bootstrap_shutdown_token = std::env::var("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN") + .ok() + .filter(|token| !token.is_empty()); + // The token belongs only to this server. Remove it before worker plugin processes are spawned. + unsafe { + std::env::remove_var("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN"); + } let bootstrap_challenge_key = bootstrap_fingerprint .as_ref() .map(|_| BootstrapChallengeKey::load()) .transpose()?; + let bootstrap_tls = bootstrap_fingerprint + .as_ref() + .map(|_| crate::gateway::tls::RelayTlsIdentity::load_or_create()) + .transpose() + .map_err(CliError::Launch)? + .map(|identity| identity.server_config()) + .transpose() + .map_err(CliError::Launch)?; let require_provider_client_token = managed_bootstrap.is_some(); let plugin_activation = PluginActivation::initialize(config.plugin_config.clone(), dynamic_plugins).await?; - let (bootstrap_shutdown, bootstrap_shutdown_rx) = bootstrap_shutdown_channel(); - let state = AppState::new_with_bootstrap( + let (bootstrap_shutdown, bootstrap_shutdown_rx) = + bootstrap_shutdown_channel(bootstrap_shutdown_token.clone()); + let mut state = AppState::new_with_bootstrap( config, bootstrap_fingerprint, bootstrap_challenge_key, require_provider_client_token, bootstrap_shutdown, ); + state.bootstrap_tls = bootstrap_tls; + state.local_address = Some(listener.local_addr()?); let instance_id = state.instance_id.clone(); let sessions = state.sessions.clone(); let last_activity = state.last_activity.clone(); @@ -254,8 +275,11 @@ async fn serve_listener_with_dynamic_inner( if let Some(identity) = managed_bootstrap.as_ref() { identity.verify_current()?; } - let _owner = - crate::bootstrap::state::publish_owner_from_env(local_address).map_err(CliError::Launch)?; + let _owner = crate::bootstrap::state::publish_owner_from_env( + local_address, + bootstrap_shutdown_token.as_deref(), + ) + .map_err(CliError::Launch)?; if let Some(path) = ready_file { write_ready_file(path, local_address, &instance_id)?; } @@ -389,6 +413,8 @@ impl AppState { last_activity: Arc::new(Mutex::new(Instant::now())), bootstrap_shutdown, instance_id: uuid::Uuid::now_v7().to_string(), + bootstrap_tls: None, + local_address: None, } } @@ -420,6 +446,7 @@ fn router_with_state(state: AppState) -> Router { let max_hook_payload_bytes = state.config.max_hook_payload_bytes; Router::new() .route("/healthz", get(healthz)) + .route("/bootstrap/tunnel", get(bootstrap_tls_tunnel)) .route("/bootstrap/shutdown", post(shutdown_bootstrap_sidecar)) .route("/hooks/codex", post(codex_hook)) .route("/hooks/claude-code", post(claude_code_hook)) @@ -436,11 +463,75 @@ fn router_with_state(state: AppState) -> Router { .with_state(state) } -fn bootstrap_shutdown_channel() -> (Option, Option>) { - let Some(token) = std::env::var("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN") - .ok() - .filter(|token| !token.is_empty()) +async fn bootstrap_tls_tunnel( + State(state): State, + mut request: Request, +) -> Response { + let headers = request.headers(); + let Some(fingerprint) = headers + .get("x-nemo-relay-bootstrap-fingerprint") + .and_then(|value| value.to_str().ok()) + else { + return StatusCode::FORBIDDEN.into_response(); + }; + let Some(nonce) = headers + .get("x-nemo-relay-bootstrap-nonce") + .and_then(|value| value.to_str().ok()) + .filter(|nonce| nonce.len() == 64 && nonce.bytes().all(|byte| byte.is_ascii_hexdigit())) else { + return StatusCode::FORBIDDEN.into_response(); + }; + let fingerprint_matches = state + .bootstrap_fingerprint + .as_deref() + .is_some_and(|actual| bool::from(actual.as_bytes().ct_eq(fingerprint.as_bytes()))); + let (Some(key), Some(tls), Some(local_address)) = ( + state.bootstrap_challenge_key.as_ref(), + state.bootstrap_tls.clone(), + state.local_address, + ) else { + return StatusCode::NOT_FOUND.into_response(); + }; + if !fingerprint_matches + || headers + .get(http::header::UPGRADE) + .and_then(|value| value.to_str().ok()) + != Some("nemo-relay-tls") + { + return StatusCode::FORBIDDEN.into_response(); + } + let proof = key.proof(fingerprint, nonce); + let upgrade = hyper::upgrade::on(&mut request); + tokio::spawn(async move { + let Ok(upgraded) = upgrade.await else { + return; + }; + let acceptor = tokio_rustls::TlsAcceptor::from(tls); + let Ok(mut encrypted) = acceptor + .accept(hyper_util::rt::TokioIo::new(upgraded)) + .await + else { + return; + }; + let Ok(mut local) = tokio::net::TcpStream::connect(local_address).await else { + return; + }; + let _ = tokio::io::copy_bidirectional(&mut encrypted, &mut local).await; + }); + Response::builder() + .status(StatusCode::SWITCHING_PROTOCOLS) + .header(http::header::CONNECTION, "upgrade") + .header(http::header::UPGRADE, "nemo-relay-tls") + .header("x-nemo-relay-bootstrap-proof", proof) + .header(http::header::CONTENT_LENGTH, "0") + .body(Body::empty()) + .expect("bootstrap TLS upgrade response is valid") +} + +fn bootstrap_shutdown_channel( + token: Option, +) -> (Option, Option>) { + let Some(token) = token else { return (None, None); }; let (sender, receiver) = oneshot::channel(); diff --git a/crates/cli/src/sessions/correlation.rs b/crates/cli/src/sessions/correlation.rs index 210b71d91..30929e57e 100644 --- a/crates/cli/src/sessions/correlation.rs +++ b/crates/cli/src/sessions/correlation.rs @@ -201,12 +201,7 @@ pub(super) fn same_optional(left: Option<&str>, right: Option<&str>) -> bool { pub(super) fn owner_status_teaches_request_affinity(status: &str) -> bool { matches!( status, - "explicit" - | "single_hint" - | "matched_hint" - | "active_subagent" - | "subagent_start" - | "request_affinity" + "explicit" | "single_hint" | "matched_hint" | "active_subagent" | "request_affinity" ) } @@ -320,9 +315,12 @@ pub(super) fn event_agent_kind(event: &NormalizedEvent) -> AgentKind { // Returns a session id only when exactly one session is active. Gateway requests without explicit // session headers use this narrow fallback to avoid cross-correlating concurrent agents. pub(super) fn single_active_session_id(sessions: &HashMap) -> Option { - (sessions.len() == 1) - .then(|| sessions.keys().next().cloned()) - .flatten() + let now = std::time::Instant::now(); + let mut active = sessions + .iter() + .filter(|(_, session)| session.is_active_or_recent(now)); + let (session_id, _) = active.next()?; + active.next().is_none().then(|| session_id.clone()) } // Selects a gateway session without guessing between concurrent agents. An explicit session id or diff --git a/crates/cli/src/sessions/idle.rs b/crates/cli/src/sessions/idle.rs index e540c79ca..fc96b8ae1 100644 --- a/crates/cli/src/sessions/idle.rs +++ b/crates/cli/src/sessions/idle.rs @@ -3,7 +3,7 @@ //! Idle-session sweeping and shutdown closure. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet, hash_map::Entry}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -83,14 +83,27 @@ pub(super) async fn close_idle_sessions_from_parts( retained_sessions.push((session_id, session)); } } + let mut alignment_cleanup_sessions = HashSet::new(); { let mut sessions = inner.lock().await; - sessions.extend(retained_sessions); + for (session_id, session) in retained_sessions { + if let Entry::Vacant(entry) = sessions.entry(session_id.clone()) { + entry.insert(session); + alignment_cleanup_sessions.insert(session_id); + } + } + for (session_id, _) in &closed_subagents { + if !sessions.contains_key(session_id) { + alignment_cleanup_sessions.insert(session_id.clone()); + } + } } if !closed_subagents.is_empty() { let mut alignment_state = alignment.lock().await; for (session_id, subagent_id) in closed_subagents { - alignment_state.clear_for_ended_subagent(&session_id, &subagent_id); + if alignment_cleanup_sessions.contains(&session_id) { + alignment_state.clear_for_ended_subagent(&session_id, &subagent_id); + } } } first_error.map_or(Ok(closed_turns), Err) diff --git a/crates/cli/src/sessions/mod.rs b/crates/cli/src/sessions/mod.rs index aa712281c..398afd6ad 100644 --- a/crates/cli/src/sessions/mod.rs +++ b/crates/cli/src/sessions/mod.rs @@ -652,6 +652,13 @@ impl Session { && now.duration_since(self.last_activity) >= timeout } + fn is_active_or_recent(&self, now: Instant) -> bool { + self.blocks_plugin_idle_shutdown() + || now + .checked_duration_since(self.last_activity) + .is_none_or(|elapsed| elapsed < AGENT_IDLE_TIMEOUT) + } + // Runs one normalized hook event inside this session's scope stack. Dispatch stays synchronous // inside the scoped closure so lifecycle ordering from each hook request is preserved exactly. async fn apply(&mut self, event: NormalizedEvent) -> Result<(), CliError> { diff --git a/crates/cli/tests/architecture_tests.rs b/crates/cli/tests/architecture_tests.rs index 86328be68..bc4633127 100644 --- a/crates/cli/tests/architecture_tests.rs +++ b/crates/cli/tests/architecture_tests.rs @@ -6,6 +6,8 @@ use std::fs; use std::path::{Path, PathBuf}; +use syn::visit::Visit; + fn source_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("src") } @@ -26,6 +28,90 @@ fn rust_files(root: &Path) -> Vec { files } +fn syntax_paths(source: &str) -> Vec { + let file = syn::parse_file(source).expect("architecture fixture must parse as Rust"); + let mut visitor = PathVisitor::default(); + visitor.visit_file(&file); + for item in &file.items { + if let syn::Item::Use(item) = item { + expand_use_tree(Vec::new(), &item.tree, &mut visitor.paths); + } + } + visitor.paths +} + +#[derive(Default)] +struct PathVisitor { + paths: Vec, + command_attributes: Vec, +} + +impl<'ast> Visit<'ast> for PathVisitor { + fn visit_path(&mut self, path: &'ast syn::Path) { + self.paths.push( + path.segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>() + .join("::"), + ); + syn::visit::visit_path(self, path); + } + + fn visit_attribute(&mut self, attribute: &'ast syn::Attribute) { + let name = attribute + .path() + .segments + .last() + .map(|segment| segment.ident.to_string()) + .unwrap_or_default(); + if matches!(name.as_str(), "arg" | "command" | "value") { + self.command_attributes.push(name); + } + syn::visit::visit_attribute(self, attribute); + } +} + +fn expand_use_tree(prefix: Vec, tree: &syn::UseTree, output: &mut Vec) { + match tree { + syn::UseTree::Path(path) => { + let mut prefix = prefix; + prefix.push(path.ident.to_string()); + expand_use_tree(prefix, &path.tree, output); + } + syn::UseTree::Name(name) => { + let mut path = prefix; + path.push(name.ident.to_string()); + output.push(path.join("::")); + } + syn::UseTree::Rename(rename) => { + let mut path = prefix; + path.push(rename.ident.to_string()); + output.push(path.join("::")); + } + syn::UseTree::Glob(_) => output.push(format!("{}::*", prefix.join("::"))), + syn::UseTree::Group(group) => { + for item in &group.items { + expand_use_tree(prefix.clone(), item, output); + } + } + } +} + +#[test] +fn syntax_analysis_expands_grouped_imports_and_ignores_comments() { + let paths = syntax_paths( + r#" + // use crate::commands::ignored; + use crate::{commands::install, agents::{codex, hermes as other}}; + "#, + ); + assert!(paths.contains(&"crate::commands::install".to_string())); + assert!(paths.contains(&"crate::agents::codex".to_string())); + assert!(paths.contains(&"crate::agents::hermes".to_string())); + assert!(!paths.iter().any(|path| path.contains("ignored"))); +} + #[test] fn retired_top_level_agent_modules_do_not_return() { let src = source_root(); @@ -51,8 +137,9 @@ fn shared_services_do_not_depend_on_commands() { continue; } let source = fs::read_to_string(&path).unwrap(); + let paths = syntax_paths(&source); assert!( - !source.contains("crate::commands"), + !paths.iter().any(|path| path.starts_with("crate::commands")), "shared module depends on command layer: {}", path.display() ); @@ -67,19 +154,15 @@ fn clap_syntax_is_owned_exclusively_by_commands() { continue; } let source = fs::read_to_string(&path).unwrap(); - for marker in [ - "use clap::", - "clap::Parser", - "#[arg(", - "#[command(", - "#[value(", - ] { - assert!( - !source.contains(marker), - "{} contains command syntax marker {marker}", - path.display() - ); - } + let file = syn::parse_file(&source).unwrap(); + let mut visitor = PathVisitor::default(); + visitor.visit_file(&file); + assert!( + !visitor.paths.iter().any(|path| path.starts_with("clap")) + && visitor.command_attributes.is_empty(), + "{} contains command syntax", + path.display() + ); } } @@ -107,14 +190,15 @@ fn agent_directories_do_not_import_one_another_or_commands() { ] { for path in rust_files(&agents.join(agent)) { let source = fs::read_to_string(&path).unwrap(); + let paths = syntax_paths(&source); assert!( - !source.contains("crate::commands"), + !paths.iter().any(|path| path.starts_with("crate::commands")), "{} imports commands", path.display() ); for module in forbidden { assert!( - !source.contains(module), + !paths.iter().any(|path| path.contains(module)), "{} imports {module}", path.display() ); diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index bc5c01554..7a83ef1fd 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -5,6 +5,7 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::Path; use std::process::{Child, ChildStdin, Command, ExitStatus, Output, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; @@ -398,6 +399,7 @@ fn cli_internal_hermes_install_writes_mcp_hooks_trust_and_doctor_ready_state() { let hermes = bin.join("hermes"); std::fs::write(&hermes, "#!/bin/sh\necho 'Hermes Agent v0.18.2 (test)'\n").unwrap(); std::fs::set_permissions(&hermes, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::os::unix::fs::symlink(gateway_bin(), bin.join("nemo-relay")).unwrap(); let path = std::env::join_paths(std::iter::once(bin.clone()).chain(std::env::split_paths( &std::env::var_os("PATH").unwrap_or_default(), ))) @@ -485,6 +487,7 @@ fn cli_internal_hermes_install_writes_mcp_hooks_trust_and_doctor_ready_state() { .env("HOME", &home) .env("HERMES_HOME", &hermes_home) .env("XDG_CONFIG_HOME", &xdg) + .env("PATH", &path) .output() .unwrap(); assert!( @@ -642,6 +645,31 @@ fn fake_bootstrap_proof(key: &[u8], fingerprint: &str, nonce: &str) -> String { ) } +fn write_test_tls_identity(bootstrap_dir: &Path) -> Arc { + let certified = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + std::fs::create_dir_all(bootstrap_dir).unwrap(); + std::fs::write( + bootstrap_dir.join("hook-tls-identity.json"), + serde_json::to_vec(&serde_json::json!({ + "certificate_der": certified.cert.der().to_vec(), + "private_key_der": certified.key_pair.serialize_der(), + })) + .unwrap(), + ) + .unwrap(); + Arc::new( + rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![certified.cert.der().clone()], + rustls::pki_types::PrivateKeyDer::Pkcs8( + rustls::pki_types::PrivatePkcs8KeyDer::from(certified.key_pair.serialize_der()), + ), + ) + .unwrap(), + ) +} + fn run_fake_bootstrap_listener(proof: FakeBootstrapProof) -> (Output, Vec) { run_fake_bootstrap_listener_with_options(proof, None, false) } @@ -677,6 +705,7 @@ fn run_fake_bootstrap_listener_with_options( .join("nemo-relay") .join("bootstrap") .join("fingerprint-hmac.key"); + let tls = write_test_tls_identity(key_path.parent().unwrap()); let server = thread::spawn(move || { while !server_stopped.load(Ordering::Relaxed) { let (mut stream, _) = match listener.accept() { @@ -694,6 +723,41 @@ fn run_fake_bootstrap_listener_with_options( let request = read_http_request(&mut stream); server_requests.lock().unwrap().push(request.clone()); if request.starts_with("GET /healthz ") { + let fingerprint = + bootstrap_request_header(&request, "x-nemo-relay-bootstrap-fingerprint") + .unwrap(); + let nonce = + bootstrap_request_header(&request, "x-nemo-relay-bootstrap-nonce").unwrap(); + let proof_header = match proof { + FakeBootstrapProof::Missing => String::new(), + FakeBootstrapProof::Wrong => { + "X-NeMo-Relay-Bootstrap-Proof: hmac-sha256:0000000000000000000000000000000000000000000000000000000000000000\r\n".into() + } + FakeBootstrapProof::Valid => format!( + "X-NeMo-Relay-Bootstrap-Proof: {}\r\n", + fake_bootstrap_proof( + &std::fs::read(&key_path).unwrap(), + fingerprint, + nonce + ) + ), + }; + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":2,"instance_id":"test-instance"}}"#, + env!("CARGO_PKG_VERSION") + ); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\n{proof_header}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + continue; + } + if request.starts_with("GET /bootstrap/tunnel ") { let fingerprint = bootstrap_request_header(&request, "x-nemo-relay-bootstrap-fingerprint") .unwrap(); @@ -712,26 +776,39 @@ fn run_fake_bootstrap_listener_with_options( ) } }; - let body = format!( - r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":2,"instance_id":"test-instance"}}"#, - env!("CARGO_PKG_VERSION") - ); - let keep_alive = matches!(proof, FakeBootstrapProof::Valid) - && bootstrap_request_header(&request, "connection") - .is_some_and(|value| value.eq_ignore_ascii_case("keep-alive")); stream .write_all( format!( - "HTTP/1.1 200 OK\r\n{proof_header}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: {}\r\n\r\n{body}", - body.len(), - if keep_alive { "keep-alive" } else { "close" } + "HTTP/1.1 101 Switching Protocols\r\n{proof_header}Connection: upgrade\r\nUpgrade: nemo-relay-tls\r\nContent-Length: 0\r\n\r\n" ) .as_bytes(), ) .unwrap(); - if keep_alive { - let request = read_http_request(&mut stream); - server_requests.lock().unwrap().push(request); + if matches!(proof, FakeBootstrapProof::Valid) { + let connection = rustls::ServerConnection::new(tls.clone()).unwrap(); + let mut stream = rustls::StreamOwned::new(connection, stream); + let health = read_http_request(&mut stream); + server_requests.lock().unwrap().push(health); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":2,"instance_id":"test-instance"}}"#, + env!("CARGO_PKG_VERSION") + ); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n{body}", + fake_bootstrap_proof( + &std::fs::read(&key_path).unwrap(), + fingerprint, + nonce + ), + body.len() + ) + .as_bytes(), + ) + .unwrap(); + let hook = read_http_request(&mut stream); + server_requests.lock().unwrap().push(hook); if let Some(delay) = hook_delay { thread::sleep(delay); } @@ -3199,7 +3276,9 @@ while True: let deadline = Instant::now() + Duration::from_secs(10); while !pids.is_file() { if Instant::now() >= deadline { - let _ = relay.kill(); + // SAFETY: Relay's PID is live and owned by this test; SIGTERM exercises its + // registered cleanup path so any partially started descendants are reaped. + let _ = unsafe { libc::kill(relay.id() as i32, libc::SIGTERM) }; let output = relay.wait_with_output().unwrap(); panic!( "agent PID file was not created for {signal_name}; stderr: {}", @@ -3319,72 +3398,95 @@ fn cli_forward_only_never_reconnects_payload_after_authenticated_connection_clos std::fs::create_dir_all(key_path.parent().unwrap()).unwrap(); let key = [0x5a_u8; 32]; std::fs::write(&key_path, key).unwrap(); + write_test_tls_identity(key_path.parent().unwrap()); let stopped = Arc::new(AtomicBool::new(false)); let requests = Arc::new(Mutex::new(Vec::new())); let server_stopped = stopped.clone(); let server_requests = requests.clone(); let server = thread::spawn(move || -> Result { listener.set_nonblocking(true).unwrap(); - for phase in 0..2 { - let deadline = Instant::now() + Duration::from_secs(4); - let (mut stream, _) = loop { - if server_stopped.load(Ordering::Relaxed) { - return Err(format!( - "hook-forward exited before authenticated health phase {phase}" - )); - } - match listener.accept() { - Ok(connection) => break connection, - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - if Instant::now() >= deadline { - return Err(format!( - "timed out waiting for authenticated health phase {phase}" - )); - } - thread::sleep(Duration::from_millis(5)); + let deadline = Instant::now() + Duration::from_secs(4); + let (mut stream, _) = loop { + if server_stopped.load(Ordering::Relaxed) { + return Err("hook-forward exited before authenticated tunnel".into()); + } + match listener.accept() { + Ok(connection) => break connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return Err("timed out waiting for authenticated tunnel".into()); } - Err(error) => return Err(format!("bootstrap listener failed: {error}")), + thread::sleep(Duration::from_millis(5)); } - }; - stream.set_nonblocking(false).unwrap(); - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - let request = read_http_request(&mut stream); - server_requests.lock().unwrap().push(request.clone()); - if request.starts_with("POST ") { - return Err(format!( - "received lifecycle payload before authenticated health phase {phase}" - )); - } - if !request.starts_with("GET /healthz ") { - return Err(format!( - "unexpected request during authenticated health phase {phase}: {request}" - )); + Err(error) => return Err(format!("bootstrap listener failed: {error}")), } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = read_http_request(&mut stream); + server_requests.lock().unwrap().push(request.clone()); + if request.starts_with("GET /healthz ") { let fingerprint = bootstrap_request_header(&request, "x-nemo-relay-bootstrap-fingerprint") - .ok_or_else(|| format!("health phase {phase} omitted its fingerprint"))?; + .ok_or_else(|| "health probe omitted its fingerprint".to_string())?; let nonce = bootstrap_request_header(&request, "x-nemo-relay-bootstrap-nonce") - .ok_or_else(|| format!("health phase {phase} omitted its nonce"))?; + .ok_or_else(|| "health probe omitted its nonce".to_string())?; let proof = fake_bootstrap_proof(&key, fingerprint, nonce); let body = format!( - r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":2,"instance_id":"phase-{phase}"}}"#, + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":2,"instance_id":"phase-health"}}"#, env!("CARGO_PKG_VERSION") ); - let connection = if phase == 0 { "close" } else { "keep-alive" }; stream .write_all( format!( - "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: {connection}\r\n\r\n{body}", + "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ) .as_bytes(), ) - .map_err(|error| format!("health phase {phase} response failed: {error}"))?; - // After proving identity on the second connection, close it before reading a body. - // The same listening port now behaves as the replacement foreign process. + .map_err(|error| format!("health response failed: {error}"))?; + drop(stream); + let next_deadline = Instant::now() + Duration::from_secs(4); + stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= next_deadline { + return Err("timed out waiting for authenticated tunnel".into()); + } + thread::sleep(Duration::from_millis(5)); + } + Err(error) => return Err(format!("bootstrap listener failed: {error}")), + } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + request = read_http_request(&mut stream); + server_requests.lock().unwrap().push(request.clone()); + } + if !request.starts_with("GET /bootstrap/tunnel ") { + return Err(format!("unexpected tunnel request: {request}")); } + let fingerprint = bootstrap_request_header(&request, "x-nemo-relay-bootstrap-fingerprint") + .ok_or_else(|| "tunnel omitted its fingerprint".to_string())?; + let nonce = bootstrap_request_header(&request, "x-nemo-relay-bootstrap-nonce") + .ok_or_else(|| "tunnel omitted its nonce".to_string())?; + let proof = fake_bootstrap_proof(&key, fingerprint, nonce); + stream + .write_all( + format!( + "HTTP/1.1 101 Switching Protocols\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nConnection: upgrade\r\nUpgrade: nemo-relay-tls\r\nContent-Length: 0\r\n\r\n" + ) + .as_bytes(), + ) + .map_err(|error| format!("tunnel response failed: {error}"))?; + // Close after proving Relay identity but before completing TLS. A replacement listener on + // the same port must never receive the lifecycle payload on a fresh connection. + drop(stream); let replacement_deadline = Instant::now() + Duration::from_secs(12); while !server_stopped.load(Ordering::Relaxed) && Instant::now() < replacement_deadline { match listener.accept() { @@ -3505,7 +3607,7 @@ fn cli_transparent_run_suppresses_persistent_hooks_and_rejects_a_foreign_gateway String::from_utf8_lossy(&owned.stderr) ); let request = received.recv_timeout(Duration::from_secs(2)).unwrap(); - assert!(request.starts_with("GET /healthz ")); + assert!(request.starts_with("GET /bootstrap/tunnel ")); assert!(!request.contains(r#"{"session_id":"owned"}"#)); } @@ -3675,7 +3777,7 @@ fn spawn_single_request_server( (format!("http://{address}"), receiver) } -fn read_http_request(stream: &mut std::net::TcpStream) -> String { +fn read_http_request(stream: &mut impl Read) -> String { let mut buffer = Vec::new(); let mut scratch = [0; 1024]; loop { diff --git a/crates/cli/tests/coverage/agents/hermes_tests.rs b/crates/cli/tests/coverage/agents/hermes_tests.rs index 86568a08f..bcd44a9ee 100644 --- a/crates/cli/tests/coverage/agents/hermes_tests.rs +++ b/crates/cli/tests/coverage/agents/hermes_tests.rs @@ -364,7 +364,7 @@ fn manual_same_named_mcp_and_hooks_are_never_claimed() { } #[test] -fn mismatched_generation_hook_does_not_prove_ownership() { +fn modern_mcp_generation_proves_ownership_independently_of_hook_completeness() { let temp = tempfile::tempdir().unwrap(); let relay = relay_binary(temp.path()); let generation = temp.path().join(GENERATION_FILE_NAME); @@ -377,10 +377,15 @@ fn mismatched_generation_hook_does_not_prove_ownership() { &[], ) .unwrap(); - assert!( + assert_eq!( owned_install_command(&root, &relay, Some(&generation)) .unwrap() - .is_none() + .as_deref(), + Some( + persistent_hook_command(&relay, &generation, "mcp-token") + .unwrap() + .as_str() + ) ); root["mcp_servers"][MCP_SERVER_NAME]["command"] = json!(temp.path().join("other/nemo-relay")); @@ -620,6 +625,15 @@ fn install_is_verified_idempotent_and_rotates_the_generation() { .count(), 1 ); + assert_eq!( + config["hooks"]["on_session_start"] + .as_array() + .unwrap() + .iter() + .filter(|group| group["command"] == json!(first_command)) + .count(), + 0 + ); assert_eq!( config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_FILE_ENV], json!(paths.generation.display().to_string()) @@ -699,7 +713,10 @@ fn diagnosis_rejects_a_stale_mcp_generation_identity() { let error = diagnose_persistent(&paths.config).unwrap_err(); - assert!(error.contains("not a managed Relay MCP client"), "{error}"); + assert!( + error.contains("expected generation identity is stale"), + "{error}" + ); } #[test] @@ -1091,20 +1108,14 @@ fn persistent_state_detection_recognizes_each_relay_owned_surface() { ) .unwrap(); - for paths in &roots[..1] { + for paths in [&roots[0], &roots[1], &roots[3]] { assert!( persistent_state_exists(&paths.config), "managed state at {} was not detected", paths.config.display() ); } - for paths in &roots[1..] { - assert!( - !persistent_state_exists(&paths.config), - "ambiguous state at {} was claimed as managed", - paths.config.display() - ); - } + assert!(!persistent_state_exists(&roots[2].config)); } #[test] @@ -1256,7 +1267,10 @@ fn hermes_diagnosis_validates_binary_bind_generation_and_environment() { ) .unwrap(); let error = diagnose_persistent(&paths.config).unwrap_err(); - assert!(error.contains("not a managed Relay MCP client"), "{error}"); + assert!( + error.contains("generation fence points at the wrong file"), + "{error}" + ); let mut missing_environment = original; assert!( @@ -1359,6 +1373,20 @@ fn hermes_uninstall_and_verification_reject_malformed_or_residual_state() { ) .unwrap(); + let mut mismatched_environment = config.clone(); + let environment_name = expected_environment + .first() + .expect("persistent MCP environment is non-empty"); + mismatched_environment["mcp_servers"][MCP_SERVER_NAME]["env"][environment_name] = + json!("unexpected-value"); + std::fs::write( + &hermes_paths.config, + serde_yaml::to_string(&mismatched_environment).unwrap(), + ) + .unwrap(); + let error = diagnose_persistent(&hermes_paths.config).unwrap_err(); + assert!(error.contains(environment_name), "{error}"); + install_persistent_with( hermes_paths.clone(), &relay, diff --git a/crates/cli/tests/coverage/agents/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs index c3c2eb4cb..32fca7ea7 100644 --- a/crates/cli/tests/coverage/agents/launcher_tests.rs +++ b/crates/cli/tests/coverage/agents/launcher_tests.rs @@ -559,6 +559,18 @@ fn exporter_destinations_describe_atif_remote_storage_instead_of_local_path() { ); } +#[test] +fn exporter_destinations_redact_url_credentials_and_query_values() { + assert_eq!( + sanitized_url("https://user:secret@example.test/ingest?token=secret&tenant=acme"), + "https://example.test/ingest?token=%5BREDACTED%5D&tenant=%5BREDACTED%5D" + ); + assert_eq!( + sanitized_url("not a url with secret"), + "configured endpoint" + ); +} + #[test] fn exporter_destinations_cover_invalid_disabled_and_missing_plugin_configs() { let invalid_plugin = GatewayConfig { @@ -865,22 +877,28 @@ fn concurrent_hermes_runs_use_independent_overlays_without_mutating_user_config( ..ResolvedConfig::default() }; - let first = PreparedAgentLaunch::new( - CodingAgent::Hermes, - vec!["hermes".into()], - "http://127.0.0.1:4001", - &resolved, - false, - ) - .unwrap(); - let second = PreparedAgentLaunch::new( - CodingAgent::Hermes, - vec!["hermes".into()], - "http://127.0.0.1:4002", - &resolved, - false, - ) - .unwrap(); + let resolved = std::sync::Arc::new(resolved); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(3)); + let spawn = |url: &'static str| { + let resolved = resolved.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + PreparedAgentLaunch::new( + CodingAgent::Hermes, + vec!["hermes".into()], + url, + &resolved, + false, + ) + .unwrap() + }) + }; + let first = spawn("http://127.0.0.1:4001"); + let second = spawn("http://127.0.0.1:4002"); + barrier.wait(); + let first = first.join().unwrap(); + let second = second.join().unwrap(); let overlay = |run: &PreparedAgentLaunch| { run.env .iter() @@ -1512,7 +1530,7 @@ fn fake_agent_command(temp: &Path, output: &Path) -> Vec { #[tokio::test] async fn dry_run_does_not_spawn_agent() { let command = RunOverrides { - agent: Some(CodingAgent::Codex), + agent: None, config: None, openai_base_url: None, anthropic_base_url: None, @@ -1520,7 +1538,7 @@ async fn dry_run_does_not_spawn_agent() { plugin_config_path: None, dry_run: true, print: false, - command: vec!["/path/that/does/not/exist".into()], + command: vec!["/path/that/does/not/exist/codex".into()], }; let code = run(command, None).await.unwrap(); diff --git a/crates/cli/tests/coverage/agents/plugin_host_tests.rs b/crates/cli/tests/coverage/agents/plugin_host_tests.rs index 8c67f8404..802fc700f 100644 --- a/crates/cli/tests/coverage/agents/plugin_host_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_host_tests.rs @@ -382,6 +382,7 @@ struct HomeScope<'a> { prev_home: Option, prev_userprofile: Option, prev_codex_home: Option, + prev_xdg_config_home: Option, } impl<'a> HomeScope<'a> { @@ -392,17 +393,20 @@ impl<'a> HomeScope<'a> { let prev_home = std::env::var_os("HOME"); let prev_userprofile = std::env::var_os("USERPROFILE"); let prev_codex_home = std::env::var_os("CODEX_HOME"); + let prev_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME"); // SAFETY: This test holds a process-wide mutex for the lifetime of the env override. unsafe { std::env::set_var("HOME", path); std::env::remove_var("USERPROFILE"); std::env::remove_var("CODEX_HOME"); + std::env::set_var("XDG_CONFIG_HOME", path.join(".config")); } Self { _guard: guard, prev_home, prev_userprofile, prev_codex_home, + prev_xdg_config_home, } } } @@ -423,6 +427,10 @@ impl<'a> Drop for HomeScope<'a> { Some(value) => std::env::set_var("CODEX_HOME", value), None => std::env::remove_var("CODEX_HOME"), } + match self.prev_xdg_config_home.take() { + Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } } } } @@ -1161,6 +1169,24 @@ fn repeated_codex_install_does_not_overwrite_original_backup() { ); } +#[test] +fn codex_client_token_supports_regular_header_tables() { + let document = r#" +[model_providers.nemo-relay-openai] +name = "NeMo Relay" + +[model_providers.nemo-relay-openai.http_headers] +X-NeMo-Relay-Client-Token = "regular-table-token" +"# + .parse::() + .unwrap(); + + assert_eq!( + codex_provider_client_token(&document), + Some("regular-table-token") + ); +} + #[cfg(unix)] #[test] fn codex_install_tightens_the_secret_bearing_config_to_owner_only() { @@ -3593,10 +3619,16 @@ fn claude_backup_bootstraps_missing_settings_and_replaces_stale_backup() { let settings_path = dir.path().join(".claude").join("settings.json"); let backup = backup_path(&settings_path); backup_claude_settings(&settings_path, false).unwrap(); - assert_eq!(fs::read_to_string(&backup).unwrap(), "{}\n"); + assert_eq!( + serde_json::from_str::(&fs::read_to_string(&backup).unwrap()).unwrap(), + json!({"__nemo_relay_original_settings_absent": true}) + ); fs::write(&settings_path, r#"{"env":{"ANTHROPIC_BASE_URL":"new"}}"#).unwrap(); backup_claude_settings(&settings_path, false).unwrap(); - assert_eq!(fs::read_to_string(&backup).unwrap(), "{}\n"); + assert_eq!( + serde_json::from_str::(&fs::read_to_string(&backup).unwrap()).unwrap(), + json!({"__nemo_relay_original_settings_absent": true}) + ); backup_claude_settings(&settings_path, true).unwrap(); assert!( fs::read_to_string(&backup) @@ -3605,6 +3637,19 @@ fn claude_backup_bootstraps_missing_settings_and_replaces_stale_backup() { ); } +#[test] +fn claude_restore_removes_settings_created_from_an_absent_original() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings_path = dir.path().join(".claude/settings.json"); + + enable_claude_provider(DEFAULT_URL).unwrap(); + assert!(settings_path.exists()); + restore_claude_provider(DEFAULT_URL).unwrap(); + + assert!(!settings_path.exists()); +} + #[test] fn plugin_host_entrypoints_reject_unsupported_agents_and_report_json() { let dir = tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index 24a34e2b5..91c2e3a09 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -1375,7 +1375,7 @@ fn plugin_manifests_and_hooks_use_path_based_relay_command() { } #[test] -fn relay_identity_uses_running_executable_when_path_points_elsewhere() { +fn relay_identity_prefers_the_path_resolved_executable() { let dir = tempdir().unwrap(); let runner = MockRunner::default() .with_current_executable("/opt/nemo-relay/current/nemo-relay") @@ -1386,7 +1386,7 @@ fn relay_identity_uses_running_executable_when_path_points_elsewhere() { .path() .join("plugins/nemo-relay-plugin/.nemo-relay-generation"); - assert_eq!(relay, PathBuf::from("/opt/nemo-relay/current/nemo-relay")); + assert_eq!(relay, PathBuf::from("/opt/nemo-relay/stale/nemo-relay")); assert_eq!( plugin_hooks( CodingAgent::Codex, @@ -3685,7 +3685,7 @@ fn failed_staging_preserves_a_preexisting_dangling_generation_lock_symlink() { Err(error) => error, }; - assert!(error.contains("injected test failure"), "{error}"); + assert!(error.contains("generation lock"), "{error}"); assert!(!stage_parent.exists()); assert!( std::fs::symlink_metadata(&target.generation_lock) @@ -3693,7 +3693,7 @@ fn failed_staging_preserves_a_preexisting_dangling_generation_lock_symlink() { .file_type() .is_symlink() ); - assert!(symlink_target.exists()); + assert!(!symlink_target.exists()); } #[test] diff --git a/crates/cli/tests/coverage/commands/model_pricing_tests.rs b/crates/cli/tests/coverage/commands/model_pricing_tests.rs index 2dba37f31..70174d5e0 100644 --- a/crates/cli/tests/coverage/commands/model_pricing_tests.rs +++ b/crates/cli/tests/coverage/commands/model_pricing_tests.rs @@ -103,6 +103,22 @@ fn pricing_component_rejects_malformed_component_config() { assert!(error.contains("invalid model pricing config")); } +#[test] +fn pricing_catalog_reads_are_bounded_and_require_utf8() { + let temp = tempfile::tempdir().unwrap(); + let invalid_utf8 = temp.path().join("invalid.json"); + std::fs::write(&invalid_utf8, [0xff]).unwrap(); + let error = read_pricing_catalog(&invalid_utf8).unwrap_err().to_string(); + assert!(error.contains("not valid UTF-8"), "{error}"); + + let oversized = temp.path().join("oversized.json"); + let file = std::fs::File::create(&oversized).unwrap(); + file.set_len(crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES + 1) + .unwrap(); + let error = read_pricing_catalog(&oversized).unwrap_err().to_string(); + assert!(error.contains("exceeds"), "{error}"); +} + #[test] fn pricing_document_update_preserves_dynamic_and_host_sections() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs b/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs index cec544257..c10efc548 100644 --- a/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs +++ b/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs @@ -51,6 +51,9 @@ impl Drop for EnvScope { } fn read_headers(stream: &mut std::net::TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); let mut bytes = Vec::new(); let mut byte = [0_u8; 1]; while !bytes.ends_with(b"\r\n\r\n") { @@ -60,6 +63,27 @@ fn read_headers(stream: &mut std::net::TcpStream) -> String { String::from_utf8(bytes).unwrap() } +fn accept_bounded(listener: &TcpListener) -> std::net::TcpStream { + listener.set_nonblocking(true).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(false).unwrap(); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "timed out accepting test connection" + ); + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("failed to accept test connection: {error}"), + } + } +} + fn header(request: &str, name: &str) -> String { request .lines() @@ -136,7 +160,7 @@ fn managed_owner_environment_is_validated_before_writing() { Some(OsStr::new("token")), ), ]); - let error = publish_owner_from_env(address).unwrap_err(); + let error = publish_owner_from_env(address, Some("token")).unwrap_err(); assert!(error.contains("absolute path"), "{error}"); drop(_scope); @@ -144,7 +168,7 @@ fn managed_owner_environment_is_validated_before_writing() { (BOOTSTRAP_STATE_DIR_ENV, Some(absolute)), ("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", None), ]); - let error = publish_owner_from_env(address).unwrap_err(); + let error = publish_owner_from_env(address, None).unwrap_err(); assert!(error.contains("SHUTDOWN_TOKEN"), "{error}"); drop(_scope); @@ -155,7 +179,8 @@ fn managed_owner_environment_is_validated_before_writing() { Some(OsStr::new("token")), ), ]); - let error = publish_owner_from_env("0.0.0.0:47632".parse().unwrap()).unwrap_err(); + let error = + publish_owner_from_env("0.0.0.0:47632".parse().unwrap(), Some("token")).unwrap_err(); assert!(error.contains("loopback"), "{error}"); } @@ -174,7 +199,9 @@ fn server_owner_guard_cleans_only_its_own_record() { Some(OsStr::new("fingerprint")), ), ]); - let guard = publish_owner_from_env(address).unwrap().unwrap(); + let guard = publish_owner_from_env(address, Some("first-token")) + .unwrap() + .unwrap(); let path = owner_path(dir.path(), "http://127.0.0.1:47632"); assert!(path.exists()); @@ -231,7 +258,7 @@ fn authenticated_owned_gateway_is_shut_down_and_cleaned_up() { write_owner_record(&path, &owner).unwrap(); let server = std::thread::spawn(move || { - let (mut health, _) = listener.accept().unwrap(); + let mut health = accept_bounded(&listener); let request = read_headers(&mut health); let nonce = header(&request, "x-nemo-relay-bootstrap-nonce"); let proof = key.proof("fingerprint", &nonce); @@ -250,7 +277,7 @@ fn authenticated_owned_gateway_is_shut_down_and_cleaned_up() { ) .unwrap(); - let (mut shutdown, _) = listener.accept().unwrap(); + let mut shutdown = accept_bounded(&listener); let request = read_headers(&mut shutdown); assert!(request.starts_with("POST /bootstrap/shutdown HTTP/1.1")); assert_eq!( diff --git a/crates/cli/tests/coverage/shared/bootstrap_tests.rs b/crates/cli/tests/coverage/shared/bootstrap_tests.rs index c5383f2ed..59541284b 100644 --- a/crates/cli/tests/coverage/shared/bootstrap_tests.rs +++ b/crates/cli/tests/coverage/shared/bootstrap_tests.rs @@ -76,6 +76,9 @@ impl Drop for EnvScope { } fn read_headers(stream: &mut std::net::TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); let mut bytes = Vec::new(); let mut byte = [0_u8; 1]; while !bytes.ends_with(b"\r\n\r\n") { @@ -85,6 +88,27 @@ fn read_headers(stream: &mut std::net::TcpStream) -> String { String::from_utf8(bytes).unwrap() } +fn accept_bounded(listener: &TcpListener) -> std::net::TcpStream { + listener.set_nonblocking(true).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(false).unwrap(); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "timed out accepting test connection" + ); + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("failed to accept test connection: {error}"), + } + } +} + fn header(request: &str, name: &str) -> String { request .lines() @@ -118,14 +142,13 @@ fn compatible_gateway_is_reused_without_starting_another_process() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); + let mut stream = accept_bounded(&listener); let request = read_headers(&mut stream); let nonce = header(&request, "x-nemo-relay-bootstrap-nonce"); let proof = key.proof("fingerprint", &nonce); let body = format!( "{{\"status\":\"ok\",\"service\":\"nemo-relay\",\"version\":\"{}\",\"bootstrap_protocol\":{},\"instance_id\":\"existing-instance\"}}", - env!("CARGO_PKG_VERSION"), - BOOTSTRAP_PROTOCOL_VERSION + "compatible-other-version", BOOTSTRAP_PROTOCOL_VERSION ); stream .write_all( @@ -150,10 +173,11 @@ fn compatible_gateway_is_reused_without_starting_another_process() { #[test] fn foreign_and_incompatible_listeners_are_never_adopted() { - for (body, expected) in [ - ("{}", "not a compatible"), + for (status, body, expected) in [ + ("200 OK", "{}", "not a compatible"), ( - "{\"status\":\"ok\",\"service\":\"nemo-relay\",\"version\":\"other\",\"bootstrap_protocol\":2,\"instance_id\":\"other\"}", + "409 Conflict", + "{\"status\":\"incompatible\",\"service\":\"nemo-relay\",\"version\":\"other\",\"bootstrap_protocol\":2,\"instance_id\":\"other\"}", "different version", ), ] { @@ -163,12 +187,12 @@ fn foreign_and_incompatible_listeners_are_never_adopted() { let connections = if expected == "not a compatible" { 2 } else { 1 }; let server = std::thread::spawn(move || { for _ in 0..connections { - let (mut stream, _) = listener.accept().unwrap(); + let mut stream = accept_bounded(&listener); let _ = read_headers(&mut stream); stream .write_all( format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ) .as_bytes(), diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index ce9d28453..fbcd51429 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -482,6 +482,10 @@ fn absent_optional_plugin_config_is_ignored() { fn unreadable_config_errors_include_the_source_path() { use std::os::unix::fs::PermissionsExt; + if unsafe { libc::geteuid() } == 0 { + return; + } + let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("config.toml"); std::fs::write(&config_path, "").unwrap(); @@ -1595,11 +1599,8 @@ fn ordinary_server_ignores_managed_bootstrap_fingerprint_environment() { ready_file: Some(temp.path().join("managed.ready.json")), ..args }; - assert!( - managed_bootstrap_identity(&managed_args, &resolved, &[]) - .unwrap() - .is_none() - ); + let error = managed_bootstrap_identity(&managed_args, &resolved, &[]).unwrap_err(); + assert!(error.to_string().contains("must be set and non-empty")); } #[test] @@ -1776,15 +1777,17 @@ fn bounded_identity_reader_reports_missing_unreadable_and_invalid_utf8_inputs() ); let unreadable = temp.path().join("unreadable"); - std::fs::write(&unreadable, b"contents").unwrap(); - std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap(); - let unreadable_result = read_bounded_regular_file(&unreadable, "fixture"); - std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o600)).unwrap(); - let unreadable_error = unreadable_result.unwrap_err(); - assert!( - unreadable_error.contains("failed to read"), - "{unreadable_error}" - ); + if unsafe { libc::geteuid() } != 0 { + std::fs::write(&unreadable, b"contents").unwrap(); + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap(); + let unreadable_result = read_bounded_regular_file(&unreadable, "fixture"); + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o600)).unwrap(); + let unreadable_error = unreadable_result.unwrap_err(); + assert!( + unreadable_error.contains("failed to read"), + "{unreadable_error}" + ); + } let manifest = temp.path().join("invalid-utf8.toml"); std::fs::write(&manifest, [0xff_u8]).unwrap(); @@ -2202,7 +2205,7 @@ fn bootstrap_file_digest_rejects_non_regular_and_oversized_inputs() { let file = std::fs::File::create(&oversized).unwrap(); file.set_len(MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1).unwrap(); let oversized = bootstrap_file_digest(&oversized, "test artifact").unwrap_err(); - assert!(oversized.to_string().contains("identity budget")); + assert!(oversized.to_string().contains("exceeds")); } #[test] @@ -2234,7 +2237,7 @@ manifest = "plugins/acme/relay-plugin.toml" .to_string(); assert!(error.contains("dynamic plugin manifest")); - assert!(error.contains("identity budget")); + assert!(error.contains("byte limit")); } #[test] @@ -2271,7 +2274,7 @@ startup = "required" .to_string(); assert!(error.contains("dynamic plugin artifact")); - assert!(error.contains("identity budget")); + assert!(error.contains("byte limit")); } #[test] diff --git a/crates/cli/tests/coverage/shared/doctor_tests.rs b/crates/cli/tests/coverage/shared/doctor_tests.rs index 595d6a87f..28adc4f52 100644 --- a/crates/cli/tests/coverage/shared/doctor_tests.rs +++ b/crates/cli/tests/coverage/shared/doctor_tests.rs @@ -10,13 +10,37 @@ use std::sync::{Arc, Mutex}; use crate::configuration::ResolvedDynamicPluginConfig; +fn accept_bounded(listener: &TcpListener) -> std::net::TcpStream { + listener.set_nonblocking(true).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "timed out accepting test connection" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(error) => panic!("failed to accept test connection: {error}"), + } + } +} + fn start_doctor_http_capture_server() -> (String, Arc>, std::thread::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let body = Arc::new(Mutex::new(String::new())); let thread_body = Arc::clone(&body); let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); + let mut stream = accept_bounded(&listener); let mut data = Vec::new(); let mut buf = [0_u8; 1]; while !data.ends_with(b"\r\n\r\n") { @@ -1296,7 +1320,7 @@ async fn atof_http_and_websocket_probes_report_failure_branches() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); + let mut stream = accept_bounded(&listener); let mut buf = [0_u8; 1024]; let _ = stream.read(&mut buf).unwrap(); stream @@ -1352,7 +1376,7 @@ async fn atof_http_and_websocket_timeout_errors_are_reported() { let http_listener = TcpListener::bind("127.0.0.1:0").unwrap(); let http_url = format!("http://{}", http_listener.local_addr().unwrap()); let http_handle = std::thread::spawn(move || { - let (_stream, _) = http_listener.accept().unwrap(); + let _stream = accept_bounded(&http_listener); std::thread::sleep(std::time::Duration::from_millis(75)); }); @@ -1384,7 +1408,7 @@ async fn atof_http_and_websocket_timeout_errors_are_reported() { let ws_listener = TcpListener::bind("127.0.0.1:0").unwrap(); let ws_url = format!("ws://{}", ws_listener.local_addr().unwrap()); let ws_handle = std::thread::spawn(move || { - let (_stream, _) = ws_listener.accept().unwrap(); + let _stream = accept_bounded(&ws_listener); std::thread::sleep(std::time::Duration::from_millis(75)); }); let websocket_timeout = probe_atof_websocket( @@ -1405,7 +1429,7 @@ async fn probe_http_named_warns_on_http_errors() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); + let mut stream = accept_bounded(&listener); let mut buf = [0_u8; 1024]; let _ = stream.read(&mut buf).unwrap(); stream @@ -1424,7 +1448,7 @@ async fn http_probe_passes_success_and_ndjson_upload_success() { let success_listener = TcpListener::bind("127.0.0.1:0").unwrap(); let success_url = format!("http://{}", success_listener.local_addr().unwrap()); let success_handle = std::thread::spawn(move || { - let (mut stream, _) = success_listener.accept().unwrap(); + let mut stream = accept_bounded(&success_listener); let mut buf = [0_u8; 1024]; let _ = stream.read(&mut buf).unwrap(); stream diff --git a/crates/cli/tests/coverage/shared/gateway_client_tests.rs b/crates/cli/tests/coverage/shared/gateway_client_tests.rs index 97583fec1..f7a17bd80 100644 --- a/crates/cli/tests/coverage/shared/gateway_client_tests.rs +++ b/crates/cli/tests/coverage/shared/gateway_client_tests.rs @@ -9,6 +9,46 @@ use std::time::Duration; use super::*; +struct TestEnvironment { + _guard: std::sync::MutexGuard<'static, ()>, + previous: Vec<(&'static str, Option)>, +} + +impl TestEnvironment { + fn isolated_home(path: &std::path::Path) -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = ["XDG_CONFIG_HOME", "HOME"] + .into_iter() + .map(|name| (name, std::env::var_os(name))) + .collect(); + // SAFETY: ENV_TEST_LOCK serializes process-wide environment changes. + unsafe { + std::env::set_var("XDG_CONFIG_HOME", path); + std::env::set_var("HOME", path); + } + Self { + _guard: guard, + previous, + } + } +} + +impl Drop for TestEnvironment { + fn drop(&mut self) { + for (name, value) in self.previous.drain(..) { + // SAFETY: ENV_TEST_LOCK remains held during restoration. + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + } +} + fn serve_once(response: &[u8]) -> (String, mpsc::Receiver>, thread::JoinHandle<()>) { let response = response.to_vec(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -124,3 +164,34 @@ fn loopback_helpers_normalize_localhost_and_ipv6_authorities() { ); assert_eq!(loopback_authority("::1", 47632), "[::1]:47632"); } + +#[test] +fn verified_hook_payload_is_not_sent_before_the_tls_tunnel_is_authenticated() { + let temp = tempfile::tempdir().unwrap(); + let _environment = TestEnvironment::isolated_home(temp.path()); + crate::configuration::BootstrapChallengeKey::load().unwrap(); + crate::gateway::tls::RelayTlsIdentity::load_or_create().unwrap(); + let (url, request, server) = serve_once( + b"HTTP/1.1 101 Switching Protocols\r\nConnection: upgrade\r\nUpgrade: nemo-relay-tls\r\nContent-Length: 0\r\n\r\n", + ); + + let error = post_verified( + &url, + "fingerprint", + "/hooks/codex", + &[], + b"secret-hook-payload", + Duration::from_secs(2), + 1024, + ) + .unwrap_err(); + + let request = request.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!( + !request + .windows(19) + .any(|window| window == b"secret-hook-payload") + ); + assert!(error.to_string().contains("authenticated Relay TLS tunnel")); + server.join().unwrap(); +} diff --git a/crates/cli/tests/coverage/shared/gateway_tests.rs b/crates/cli/tests/coverage/shared/gateway_tests.rs index 854c73d84..0d7ca3fa2 100644 --- a/crates/cli/tests/coverage/shared/gateway_tests.rs +++ b/crates/cli/tests/coverage/shared/gateway_tests.rs @@ -19,34 +19,63 @@ fn test_http_client() -> Client { #[test] fn removes_hop_by_hop_headers() { - assert!(!should_forward_request_header(&HeaderName::from_static( - "connection" - ))); - assert!(!should_forward_request_header(&HeaderName::from_static( - "host" - ))); - assert!(!should_forward_request_header(&HeaderName::from_static( - crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER - ))); - assert!(should_forward_request_header(&HeaderName::from_static( - "authorization" - ))); - assert!(!should_record_header(&HeaderName::from_static( - "authorization" - ))); - assert!(!should_record_header(&HeaderName::from_static("x-api-key"))); - assert!(!should_record_header(&HeaderName::from_static( - "anthropic-api-key" - ))); + let headers = HeaderMap::new(); + assert!(!should_forward_request_header( + &HeaderName::from_static("connection"), + &headers + )); + assert!(!should_forward_request_header( + &HeaderName::from_static("host"), + &headers + )); + assert!(!should_forward_request_header( + &HeaderName::from_static(crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER), + &headers + )); + assert!(should_forward_request_header( + &HeaderName::from_static("authorization"), + &headers + )); + assert!(!should_record_header( + &HeaderName::from_static("authorization"), + &headers + )); + assert!(!should_record_header( + &HeaderName::from_static("x-api-key"), + &headers + )); + assert!(!should_record_header( + &HeaderName::from_static("anthropic-api-key"), + &headers + )); // Additional credential aliases must not appear in observability metadata: // `cookie` carries session credentials; `api-key` is the generic alias used by some providers // (e.g., Azure OpenAI). Without these, secrets would leak into `LlmRequest.headers` and any // downstream exporter that mirrors them (ATIF, OpenInference span attributes). - assert!(!should_record_header(&HeaderName::from_static("cookie"))); - assert!(!should_record_header(&HeaderName::from_static("api-key"))); - assert!(should_record_header(&HeaderName::from_static( - "x-request-id" - ))); + assert!(!should_record_header( + &HeaderName::from_static("cookie"), + &headers + )); + assert!(!should_record_header( + &HeaderName::from_static("api-key"), + &headers + )); + assert!(should_record_header( + &HeaderName::from_static("x-request-id"), + &headers + )); + + let mut connection_headers = HeaderMap::new(); + connection_headers.insert( + header::CONNECTION, + HeaderValue::from_static("x-private, upgrade"), + ); + connection_headers.insert("x-private", HeaderValue::from_static("secret")); + assert!(!should_forward_request_header( + &HeaderName::from_static("x-private"), + &connection_headers + )); + assert!(!response_headers(&connection_headers).contains_key("x-private")); } #[tokio::test] @@ -818,6 +847,8 @@ async fn passthrough_rejects_unsupported_provider_path_directly() { last_activity: std::sync::Arc::new(std::sync::Mutex::new(std::time::Instant::now())), bootstrap_shutdown: None, instance_id: "test-instance".into(), + bootstrap_tls: None, + local_address: None, }; let request = Request::builder() .method(Method::POST) @@ -852,6 +883,8 @@ async fn models_rejects_non_get_requests_directly() { last_activity: std::sync::Arc::new(std::sync::Mutex::new(std::time::Instant::now())), bootstrap_shutdown: None, instance_id: "test-instance".into(), + bootstrap_tls: None, + local_address: None, }; let request = Request::builder() .method(Method::POST) diff --git a/crates/cli/tests/coverage/shared/gateway_tls_tests.rs b/crates/cli/tests/coverage/shared/gateway_tls_tests.rs new file mode 100644 index 000000000..19c67c507 --- /dev/null +++ b/crates/cli/tests/coverage/shared/gateway_tls_tests.rs @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +struct Environment { + _guard: std::sync::MutexGuard<'static, ()>, + previous: Vec<(&'static str, Option)>, +} + +impl Environment { + fn set(values: &[(&'static str, &std::ffi::OsStr)]) -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = values + .iter() + .map(|(name, _)| (*name, std::env::var_os(name))) + .collect(); + for (name, value) in values { + unsafe { std::env::set_var(name, value) }; + } + Self { + _guard: guard, + previous, + } + } +} + +impl Drop for Environment { + fn drop(&mut self) { + for (name, value) in self.previous.drain(..) { + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + } +} + +#[test] +fn per_user_tls_identity_round_trips_as_server_and_pinned_client_configs() { + let temp = tempfile::tempdir().unwrap(); + let _environment = Environment::set(&[ + ("XDG_CONFIG_HOME", temp.path().as_os_str()), + ("HOME", temp.path().as_os_str()), + ]); + + let identity = RelayTlsIdentity::load_or_create().unwrap(); + identity.server_config().unwrap(); + identity.client_config().unwrap(); + let reloaded = RelayTlsIdentity::load().unwrap(); + reloaded.server_config().unwrap(); + reloaded.client_config().unwrap(); +} diff --git a/crates/cli/tests/coverage/shared/install_generation_tests.rs b/crates/cli/tests/coverage/shared/install_generation_tests.rs index 0a6069503..db2ff48d6 100644 --- a/crates/cli/tests/coverage/shared/install_generation_tests.rs +++ b/crates/cli/tests/coverage/shared/install_generation_tests.rs @@ -392,6 +392,7 @@ fn legacy_sibling_lock_can_be_released_for_tree_move_and_reacquired_for_rollback #[test] fn relative_legacy_marker_reencodes_an_absolute_lock_for_rollback() { + let _cwd = crate::test_support::CwdTestScope::locked(); let current_dir = std::env::current_dir().unwrap(); let dir = tempfile::Builder::new() .prefix(".relay-relative-generation-") diff --git a/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs b/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs index a749eaef6..77850fd0f 100644 --- a/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs +++ b/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs @@ -48,6 +48,7 @@ async fn production_heartbeat_recovers_after_one_thirty_second_interval() { }, )); + tokio::task::yield_now().await; tokio::time::advance(Duration::from_secs(30)).await; restarted_rx.await.unwrap(); assert!(!monitor.is_finished()); @@ -256,10 +257,40 @@ async fn dropping_gateway_lease_aborts_its_monitor() { }); started_rx.await.unwrap(); - drop(GatewayLease { monitor }); + drop(GatewayLease { + monitor, + shutdown: Arc::new(LeaseShutdown::default()), + }); tokio::time::timeout(Duration::from_secs(1), dropped_rx) .await .expect("gateway monitor was not aborted when its lease dropped") .unwrap(); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dropping_gateway_lease_waits_for_inflight_recovery() { + let shutdown = Arc::new(LeaseShutdown::default()); + let recovery = shutdown.start_recovery().unwrap(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + release_rx.recv().unwrap(); + drop(recovery); + }); + let monitor = tokio::spawn(std::future::pending::>()); + let lease = GatewayLease { monitor, shutdown }; + let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel(); + let dropper = std::thread::spawn(move || { + drop(lease); + let _ = dropped_tx.send(()); + }); + + assert!( + tokio::time::timeout(Duration::from_millis(50), dropped_rx) + .await + .is_err() + ); + release_tx.send(()).unwrap(); + dropper.join().unwrap(); + worker.join().unwrap(); +} diff --git a/crates/cli/tests/coverage/shared/mcp_tests.rs b/crates/cli/tests/coverage/shared/mcp_tests.rs index fbe63333b..7c4db75e0 100644 --- a/crates/cli/tests/coverage/shared/mcp_tests.rs +++ b/crates/cli/tests/coverage/shared/mcp_tests.rs @@ -197,6 +197,18 @@ fn invalid_and_unknown_requests_return_jsonrpc_errors() { response_for(&json!({"jsonrpc":"2.0", "id":5, "method":"initialize", "params":{}})), Some(jsonrpc_error(json!(5), -32602, "Missing protocolVersion")) ); + assert_eq!( + response_for(&json!({"jsonrpc":"2.0"})), + Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")) + ); + assert_eq!( + response_for(&json!({"jsonrpc":"2.0", "id":true, "method":"ping"})), + Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")) + ); + assert_eq!( + response_for(&json!({"jsonrpc":"2.0", "method":7})), + Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")) + ); } #[test] diff --git a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs index 78b6eef2c..d0ec546cb 100644 --- a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs @@ -1682,6 +1682,15 @@ fn python_entrypoint_validation_reports_each_authored_contract_error() { .unwrap_err(); assert!(error.contains("module:function form"), "{error}"); + let mut extra_separator = manifest.clone(); + let DynamicPluginManifestLoad::Worker(load) = &mut extra_separator.load else { + panic!("fixture must be a worker plugin"); + }; + load.entrypoint = Some("plugin:main:extra".into()); + let error = environment::validate_python_entrypoint_artifact(&extra_separator, &manifest_ref) + .unwrap_err(); + assert!(error.contains("module:function form"), "{error}"); + let mut empty_module = manifest.clone(); let DynamicPluginManifestLoad::Worker(load) = &mut empty_module.load else { panic!("fixture must be a worker plugin"); diff --git a/crates/cli/tests/coverage/shared/plugins_schema_tests.rs b/crates/cli/tests/coverage/shared/plugins_schema_tests.rs index 3221fb857..e126f62e7 100644 --- a/crates/cli/tests/coverage/shared/plugins_schema_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_schema_tests.rs @@ -194,6 +194,40 @@ fn resolves_percent_encoded_local_references() { ); } +#[test] +fn resolves_fragments_within_the_active_nested_schema_resource() { + let loaded = load(&json!({ + "$schema": DRAFT2020, + "type": "object", + "$defs": { + "rootValue": {"$anchor": "value", "type": "integer"}, + "child": { + "$id": "child.json", + "type": "object", + "$defs": { + "text": {"$anchor": "value", "type": "string"} + }, + "properties": { + "name": {"$ref": "#value"} + } + } + }, + "properties": { + "child": {"$ref": "#/$defs/child"} + } + })); + + let child = loaded + .fields() + .iter() + .find(|field| field.key == "child") + .expect("child field"); + assert!(matches!(child.kind, DynamicConfigFieldKind::Object { .. })); + loaded + .validate(&json!({"child": {"name": "relay"}})) + .expect("nested anchor resolves within child resource"); +} + #[test] fn canonicalizes_reference_fragments_and_rejects_malformed_encoding() { let schema = json!({ diff --git a/crates/cli/tests/coverage/shared/plugins_tests.rs b/crates/cli/tests/coverage/shared/plugins_tests.rs index 4b71f2b59..29dc913ae 100644 --- a/crates/cli/tests/coverage/shared/plugins_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_tests.rs @@ -1211,6 +1211,37 @@ fn reset_section_clears_optional_section_without_default() { assert!(config.optional.is_none()); } +#[test] +fn clear_value_field_refuses_required_fields() { + static FIELDS: [EditorFieldSpec; 2] = [ + EditorFieldSpec { + name: "required", + label: "required", + kind: EditorFieldKind::String, + enum_values: &[], + optional: false, + nested_schema: None, + nested_default: None, + }, + EditorFieldSpec { + name: "optional", + label: "optional", + kind: EditorFieldKind::String, + enum_values: &[], + optional: true, + nested_schema: None, + nested_default: None, + }, + ]; + let schema = EditorSchema { fields: &FIELDS }; + let mut value = json!({"required": "keep", "optional": "remove"}); + + assert!(!clear_value_field(&mut value, &schema, 0)); + assert_eq!(value["required"], json!("keep")); + assert!(clear_value_field(&mut value, &schema, 1)); + assert!(value.get("optional").is_none()); +} + #[test] fn nested_edit_empty_optional_section_without_default_clears_field() { let optional = optional_section_without_default("optional"); diff --git a/crates/cli/tests/coverage/shared/session_tests.rs b/crates/cli/tests/coverage/shared/session_tests.rs index 5a2353ae7..6fbad5748 100644 --- a/crates/cli/tests/coverage/shared/session_tests.rs +++ b/crates/cli/tests/coverage/shared/session_tests.rs @@ -5901,6 +5901,32 @@ async fn idle_timeout_waits_for_active_gateway_llm_call() { assert!(manager.inner.lock().await.is_empty()); } +#[tokio::test] +async fn a_single_stale_retained_session_is_not_used_for_headerless_calls() { + let manager = SessionManager::new(session_test_config()); + manager + .apply_events( + &HeaderMap::new(), + vec![NormalizedEvent::AgentStarted(session_event( + "stale-session", + "SessionStart", + ))], + ) + .await + .unwrap(); + let mut sessions = manager.inner.lock().await; + sessions.get_mut("stale-session").unwrap().last_activity = + Instant::now() - AGENT_IDLE_TIMEOUT - Duration::from_secs(1); + + assert_eq!(single_active_session_id(&sessions), None); +} + +#[test] +fn weak_subagent_start_status_does_not_teach_request_affinity() { + assert!(!owner_status_teaches_request_affinity("subagent_start")); + assert!(owner_status_teaches_request_affinity("active_subagent")); +} + #[tokio::test] async fn gateway_shutdown_attempts_remaining_sessions_after_close_error() { let subscriber_name = "cli-close-all-deferred-error-test"; diff --git a/crates/cli/tests/coverage/shared/setup_tests.rs b/crates/cli/tests/coverage/shared/setup_tests.rs index 2deec4a2c..0710bea1d 100644 --- a/crates/cli/tests/coverage/shared/setup_tests.rs +++ b/crates/cli/tests/coverage/shared/setup_tests.rs @@ -435,13 +435,13 @@ command = "codex" ) .unwrap(); - reset(Some(CodingAgent::ClaudeCode)).unwrap(); + reset(ConfigScope::Project, Some(CodingAgent::ClaudeCode)).unwrap(); let scoped = std::fs::read_to_string(&path).unwrap(); assert!(!scoped.contains("[agents.claude]")); assert!(scoped.contains("[agents.codex]")); - reset(None).unwrap(); + reset(ConfigScope::Project, None).unwrap(); assert!(!path.exists()); } @@ -455,7 +455,7 @@ fn reset_removes_empty_agents_table_when_last_agent_is_removed() { let path = config_dir.join("config.toml"); std::fs::write(&path, "[agents.codex]\ncommand = \"codex\"\n").unwrap(); - reset(Some(CodingAgent::Codex)).unwrap(); + reset(ConfigScope::Project, Some(CodingAgent::Codex)).unwrap(); let contents = std::fs::read_to_string(&path).unwrap(); assert!(!contents.contains("[agents]")); @@ -467,8 +467,8 @@ fn reset_noops_when_project_config_is_missing() { let temp = tempfile::tempdir().unwrap(); let _cwd = CwdScope::enter(temp.path()); - reset(None).unwrap(); - reset(Some(CodingAgent::Codex)).unwrap(); + reset(ConfigScope::Project, None).unwrap(); + reset(ConfigScope::Project, Some(CodingAgent::Codex)).unwrap(); } #[test] @@ -486,7 +486,7 @@ fn reset_reports_missing_or_malformed_agent_blocks_without_rewriting() { let path = config_dir.join("config.toml"); std::fs::write(&path, "agents = \"not-a-table\"\n").unwrap(); - reset(Some(CodingAgent::Hermes)).unwrap(); + reset(ConfigScope::Project, Some(CodingAgent::Hermes)).unwrap(); assert_eq!( std::fs::read_to_string(&path).unwrap(), @@ -494,13 +494,47 @@ fn reset_reports_missing_or_malformed_agent_blocks_without_rewriting() { ); std::fs::write(&path, "not valid toml = [\n").unwrap(); - let error = reset(Some(CodingAgent::Hermes)).unwrap_err().to_string(); + let error = reset(ConfigScope::Project, Some(CodingAgent::Hermes)) + .unwrap_err() + .to_string(); assert!( error.contains("could not parse existing config"), "error was: {error}" ); } +#[test] +fn reset_honors_global_and_both_scopes() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let home = temp.path().join("home"); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&home).unwrap(); + let _cwd = CwdScope::enter(&project); + let _env = EnvScope::set(&[ + ("HOME", Some(home.as_os_str())), + ("USERPROFILE", Some(home.as_os_str())), + ("XDG_CONFIG_HOME", Some(xdg.as_os_str())), + ]); + + let project_path = project.join(".nemo-relay/config.toml"); + let global_path = global_config_dir(&home).join("config.toml"); + std::fs::create_dir_all(project_path.parent().unwrap()).unwrap(); + std::fs::create_dir_all(global_path.parent().unwrap()).unwrap(); + std::fs::write(&project_path, "[agents.codex]\ncommand = \"codex\"\n").unwrap(); + std::fs::write(&global_path, "[agents.codex]\ncommand = \"codex\"\n").unwrap(); + + reset(ConfigScope::Global, None).unwrap(); + assert!(project_path.exists()); + assert!(!global_path.exists()); + + std::fs::write(&global_path, "[agents.codex]\ncommand = \"codex\"\n").unwrap(); + reset(ConfigScope::Both, None).unwrap(); + assert!(!project_path.exists()); + assert!(!global_path.exists()); +} + #[test] fn plugins_edit_command_for_scope_targets_expected_plugin_scope() { use crate::plugins::config_io::{TargetScope, target_scope}; From 2f640ce5a16f6ca60fc9ca3d45c0b6492ab8ec18 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Mon, 13 Jul 2026 20:09:15 -0400 Subject: [PATCH 094/102] fix(cli): address remaining review findings Signed-off-by: Will Killian --- crates/cli/src/agents/claude/host.rs | 30 +++- crates/cli/src/agents/hermes/integration.rs | 23 +++ crates/cli/src/bootstrap/mod.rs | 2 +- crates/cli/src/bootstrap/state.rs | 16 +- crates/cli/src/commands/configure/model.rs | 2 +- crates/cli/src/commands/diagnostics.rs | 2 +- crates/cli/src/commands/install.rs | 38 +++-- crates/cli/src/commands/mod.rs | 73 ++------- crates/cli/src/diagnostics/mod.rs | 2 +- crates/cli/src/diagnostics/render.rs | 6 +- crates/cli/src/gateway/client.rs | 98 +++++++++-- crates/cli/src/hooks/encoding.rs | 4 +- crates/cli/src/hooks/mod.rs | 4 +- crates/cli/src/hooks/response.rs | 17 +- crates/cli/src/installation/operation_lock.rs | 21 ++- crates/cli/src/lib.rs | 17 +- crates/cli/src/server/mod.rs | 53 +++--- .../cli/tests/coverage/agents/hermes_tests.rs | 2 + .../coverage/agents/plugin_host_tests.rs | 28 ++++ .../coverage/agents/plugin_install_tests.rs | 17 ++ .../cli/tests/coverage/commands/main_tests.rs | 39 +++++ .../coverage/shared/bootstrap_state_tests.rs | 107 ++---------- .../tests/coverage/shared/bootstrap_tests.rs | 136 ++++------------ .../shared/completions_install_tests.rs | 43 +---- .../cli/tests/coverage/shared/doctor_tests.rs | 94 +++-------- .../coverage/shared/gateway_client_tests.rs | 143 ++++++++++------ .../tests/coverage/shared/installer_tests.rs | 9 ++ .../cli/tests/coverage/shared/test_support.rs | 153 ++++++++++++++++++ 28 files changed, 689 insertions(+), 490 deletions(-) create mode 100644 crates/cli/tests/coverage/shared/test_support.rs diff --git a/crates/cli/src/agents/claude/host.rs b/crates/cli/src/agents/claude/host.rs index 2b2ceacc8..ad541463d 100644 --- a/crates/cli/src/agents/claude/host.rs +++ b/crates/cli/src/agents/claude/host.rs @@ -14,6 +14,7 @@ use crate::filesystem::{ }; const ABSENT_SETTINGS_BACKUP_KEY: &str = "__nemo_relay_original_settings_absent"; +const MANAGED_PROVIDER_BACKUP_KEY: &str = "__nemo_relay_managed_anthropic_base_url"; pub(crate) struct ClaudeSetupSnapshot { files: Vec, @@ -48,11 +49,29 @@ pub(crate) fn enable_claude_provider(gateway_url: &str) -> Result<(), String> { return Err(format!("{} has a non-object env field", path.display())); } let backup_snapshot = snapshot_optional_file(&backup_path(&path))?; - let managed_provider = json_env_string(&settings, "ANTHROPIC_BASE_URL") == Some(gateway_url); + let current_provider = json_env_string(&settings, "ANTHROPIC_BASE_URL"); + let backup_file = backup_path(&path); + let previous_managed_provider = read_json_object(&backup_file).ok().and_then(|backup| { + backup + .get(MANAGED_PROVIDER_BACKUP_KEY) + .and_then(Value::as_str) + .map(str::to_owned) + }); + let managed_provider = current_provider == Some(gateway_url) + || previous_managed_provider + .as_deref() + .is_some_and(|previous| current_provider == Some(previous)) + || (backup_file.exists() && current_provider == Some(crate::bootstrap::DEFAULT_URL)); if !managed_provider && let Err(error) = backup_claude_settings(&path, true) { restore_file_snapshot(&backup_snapshot)?; return Err(error); } + if backup_file.exists() + && let Err(error) = record_managed_provider(&backup_file, gateway_url) + { + restore_file_snapshot(&backup_snapshot)?; + return Err(error); + } let env = settings .as_object_mut() .expect("read_json_object returns an object") @@ -68,6 +87,15 @@ pub(crate) fn enable_claude_provider(gateway_url: &str) -> Result<(), String> { Ok(()) } +fn record_managed_provider(backup: &Path, gateway_url: &str) -> Result<(), String> { + let mut value = read_json_object(backup)?; + value + .as_object_mut() + .expect("read_json_object returns an object") + .insert(MANAGED_PROVIDER_BACKUP_KEY.into(), json!(gateway_url)); + write_json(backup, &value) +} + pub(crate) fn restore_claude_provider(gateway_url: &str) -> Result<(), String> { let path = claude_settings_path()?; let backup = backup_path(&path); diff --git a/crates/cli/src/agents/hermes/integration.rs b/crates/cli/src/agents/hermes/integration.rs index 88afb18d4..f14457a3f 100644 --- a/crates/cli/src/agents/hermes/integration.rs +++ b/crates/cli/src/agents/hermes/integration.rs @@ -180,6 +180,29 @@ fn allowlist_has_owned_command(allowlist: &Value, command: Option<&str>) -> bool } fn is_persistent_relay_hook_command(command: &str) -> bool { + #[cfg(any(windows, test))] + if let Some(arguments) = crate::hooks::decode_windows_hook_command(command) { + return matches!( + arguments.as_slice(), + [ + _, + hook_forward, + agent, + gateway_flag, + gateway_url, + generation_file_flag, + _, + generation_token_flag, + generation_token, + ] if hook_forward == "hook-forward" + && agent == "hermes" + && gateway_flag == "--gateway-url" + && gateway_url == crate::bootstrap::DEFAULT_URL + && generation_file_flag == "--generation-file" + && generation_token_flag == "--generation-token" + && !generation_token.is_empty() + ); + } command.contains("hook-forward") && command.contains("hermes") && command.contains("--gateway-url") diff --git a/crates/cli/src/bootstrap/mod.rs b/crates/cli/src/bootstrap/mod.rs index 3acad6305..54c890e00 100644 --- a/crates/cli/src/bootstrap/mod.rs +++ b/crates/cli/src/bootstrap/mod.rs @@ -279,7 +279,7 @@ fn start_gateway(spec: &GatewaySpec, state: &Path) -> Result Result<(), String> { )); } } - request_shutdown(url, &owner.shutdown_token)?; + request_shutdown( + url, + owner + .bootstrap_fingerprint + .as_deref() + .expect("validated owner record has a bootstrap fingerprint"), + &owner.shutdown_token, + )?; let deadline = Instant::now() + SHUTDOWN_TIMEOUT; loop { match probe(url, owner.bootstrap_fingerprint.as_deref()) { diff --git a/crates/cli/src/commands/configure/model.rs b/crates/cli/src/commands/configure/model.rs index ef8bdd697..336ef249b 100644 --- a/crates/cli/src/commands/configure/model.rs +++ b/crates/cli/src/commands/configure/model.rs @@ -74,7 +74,7 @@ pub(crate) fn detect_installed_agents_in(path_var: Option<&std::ffi::OsStr>) -> let Some(path_var) = path_var else { return Vec::new(); }; - // Pairs of (CodingAgent, exec name to look for on $PATH). + // Keep only agents whose canonical executable resolves on PATH. CodingAgent::ALL .into_iter() .filter(|agent| { diff --git a/crates/cli/src/commands/diagnostics.rs b/crates/cli/src/commands/diagnostics.rs index b1d49566c..bd689bb5c 100644 --- a/crates/cli/src/commands/diagnostics.rs +++ b/crates/cli/src/commands/diagnostics.rs @@ -13,7 +13,7 @@ use crate::error::CliError; #[derive(Debug, Clone, Args)] pub(crate) struct DoctorCommand { - #[arg(value_enum)] + #[arg(value_enum, conflicts_with = "plugin")] pub(crate) agent: Option, #[arg(long, value_enum)] pub(crate) plugin: Option, diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index b30ea474f..eaf4e1512 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -96,14 +96,9 @@ pub(super) fn install(command: InstallCommand) -> Result { "no supported Claude Code, Codex, or Hermes host CLI was detected".into(), )); } - let mut result = ExitCode::SUCCESS; - for agent in agents { - let status = crate::agents::install_integration(agent, request.clone())?; - if status != ExitCode::SUCCESS { - result = status; - } - } - Ok(result) + run_agent_operations(agents, "install", |agent| { + crate::agents::install_integration(agent, request.clone()) + }) } pub(super) fn uninstall(command: UninstallCommand) -> Result { @@ -120,12 +115,31 @@ pub(super) fn uninstall(command: UninstallCommand) -> Result "no installed Claude Code, Codex, or Hermes integration state was found".into(), )); } + run_agent_operations(agents, "uninstall", |agent| { + crate::agents::uninstall_integration(agent, request.clone()) + }) +} + +pub(super) fn run_agent_operations( + agents: Vec, + operation: &str, + mut run: impl FnMut(CodingAgent) -> Result, +) -> Result { let mut result = ExitCode::SUCCESS; + let mut errors = Vec::new(); for agent in agents { - let status = crate::agents::uninstall_integration(agent, request.clone())?; - if status != ExitCode::SUCCESS { - result = status; + match run(agent) { + Ok(status) if status != ExitCode::SUCCESS => result = status, + Ok(_) => {} + Err(error) => errors.push(format!("{}: {error}", agent.as_arg())), } } - Ok(result) + if errors.is_empty() { + Ok(result) + } else { + Err(CliError::Install(format!( + "failed to {operation} one or more integrations after attempting every target: {}", + errors.join("; ") + ))) + } } diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index a03f1d727..20ceb1827 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -35,8 +35,8 @@ use crate::{ // Runs the async CLI entrypoint and converts any surfaced gateway error into a non-zero process // exit. Errors are printed once here so subcommands can return structured errors without also // owning process-level reporting. -pub(crate) async fn run() -> ExitCode { - match dispatch().await { +pub(crate) async fn run(bootstrap_shutdown_token: Option) -> ExitCode { + match dispatch(bootstrap_shutdown_token).await { Ok(code) => code, Err(error) => { let exit_code = if error.guardrail_rejection_reason().is_some() { @@ -52,11 +52,11 @@ pub(crate) async fn run() -> ExitCode { // Dispatches CLI subcommands while keeping the no-subcommand path as server mode. `run` inherits // top-level server flags so transparent launch can share config parsing with daemon startup. -async fn dispatch() -> Result { +async fn dispatch(bootstrap_shutdown_token: Option) -> Result { let cli = Cli::parse(); match cli.command { Some(command) => run_command(command, &cli.server).await, - None => run_default(&cli.server).await, + None => run_default(&cli.server, bootstrap_shutdown_token).await, } } @@ -90,7 +90,10 @@ fn generate_completions_to( completions::generate_to(shell, writer) } -async fn run_default(server_args: &ServerArgs) -> Result { +async fn run_default( + server_args: &ServerArgs, + bootstrap_shutdown_token: Option, +) -> Result { let runtime_args = server_args.to_runtime(); // Bare `nemo-relay` with no subcommand: // - If the user passed any daemon-specific flag (`--bind`, upstream URLs, ATIF dir, @@ -116,6 +119,7 @@ async fn run_default(server_args: &ServerArgs) -> Result Result { model_pricing::execute(command) } -#[cfg(test)] -pub(crate) mod test_support { - #[must_use] - pub(crate) struct CwdTestScope { - _guard: std::sync::MutexGuard<'static, ()>, - prev: Option, - } - - impl CwdTestScope { - pub(crate) fn locked() -> Self { - Self { - _guard: lock_cwd(), - prev: None, - } - } - - pub(crate) fn enter(path: &std::path::Path) -> Self { - let guard = lock_cwd(); - let prev = std::env::current_dir().unwrap(); - std::env::set_current_dir(path).unwrap(); - Self { - _guard: guard, - prev: Some(prev), - } - } - } - - impl Drop for CwdTestScope { - fn drop(&mut self) { - if let Some(prev) = &self.prev - && let Err(error) = std::env::set_current_dir(prev) - { - CWD_RESTORE_FAILED.store(true, std::sync::atomic::Ordering::SeqCst); - if std::thread::panicking() { - eprintln!("failed to restore current_dir to {prev:?}: {error}"); - } else { - panic!("failed to restore current_dir to {prev:?}: {error}"); - } - } - } - } - - pub(crate) static CWD_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - static CWD_RESTORE_FAILED: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - pub(crate) static PLUGIN_CONFIG_TEST_LOCK: tokio::sync::Mutex<()> = - tokio::sync::Mutex::const_new(()); - - fn lock_cwd() -> std::sync::MutexGuard<'static, ()> { - let guard = CWD_TEST_LOCK.lock().expect("CWD_TEST_LOCK poisoned"); - assert!( - !CWD_RESTORE_FAILED.load(std::sync::atomic::Ordering::SeqCst), - "current_dir restore failed in a previous test; aborting to prevent cross-test contamination", - ); - guard - } -} - #[cfg(test)] #[path = "../../tests/coverage/commands/main_tests.rs"] mod tests; diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index 86073d530..7afbeb495 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -1098,7 +1098,7 @@ pub(crate) async fn run_doctor( /// Top-level entry point invoked by `nemo-relay agents`. Always exits 0; the data drives caller /// decisions (e.g., CI gating on JSON output). pub(crate) async fn run_agents(json: bool) -> Result { - let agents = agents_report().await; + let agents = agents_report().await?; let output = if json { format_agents_json(&agents)? } else { diff --git a/crates/cli/src/diagnostics/render.rs b/crates/cli/src/diagnostics/render.rs index 928728286..49190b710 100644 --- a/crates/cli/src/diagnostics/render.rs +++ b/crates/cli/src/diagnostics/render.rs @@ -267,9 +267,9 @@ pub(crate) fn format_json(report: &DoctorReport) -> Result { /// Runs `agents` — a thin wrapper over `collect_agents` that emits only the agent list. Shares /// the same JSON schema as `doctor.agents` for consistency. -pub(crate) async fn agents_report() -> Vec { - let resolved = resolve_server_config(&GatewayOverrides::default()).unwrap_or_default(); - collect_agents(None, &resolved).await +pub(crate) async fn agents_report() -> Result, CliError> { + let resolved = resolve_server_config(&GatewayOverrides::default())?; + Ok(collect_agents(None, &resolved).await) } /// Renders the agents listing in human form. diff --git a/crates/cli/src/gateway/client.rs b/crates/cli/src/gateway/client.rs index ade1d77ad..c26a822cb 100644 --- a/crates/cli/src/gateway/client.rs +++ b/crates/cli/src/gateway/client.rs @@ -3,8 +3,11 @@ //! Authenticated health and shutdown transport for loopback sidecars. +use std::collections::HashMap; use std::io::{Read, Write}; use std::net::{Ipv4Addr, SocketAddr, TcpStream, ToSocketAddrs}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use reqwest::Url; @@ -15,6 +18,12 @@ use crate::configuration::BootstrapChallengeKey; use crate::bootstrap::{BOOTSTRAP_PROTOCOL_VERSION, HEALTHZ_TIMEOUT}; +static CHALLENGE_KEY_CACHE: OnceLock>>> = + OnceLock::new(); +static TLS_IDENTITY_CACHE: OnceLock< + Mutex>>, +> = OnceLock::new(); + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum RelayHealth { Compatible, @@ -95,12 +104,12 @@ pub(crate) fn post_verified( )) })?; - let key = BootstrapChallengeKey::load().map_err(|error| { + let key = cached_bootstrap_challenge_key().map_err(|error| { VerifiedHttpError::before_payload(format!( "failed to load the Relay bootstrap challenge key: {error}" )) })?; - let tls_identity = crate::gateway::tls::RelayTlsIdentity::load().map_err(|error| { + let tls_identity = cached_tls_identity().map_err(|error| { VerifiedHttpError::before_payload(format!( "failed to load pinned Relay TLS identity: {error}" )) @@ -166,7 +175,7 @@ pub(crate) fn post_verified( let (health, _) = classify_health_response( &health_headers, &health_body, - Some((bootstrap_fingerprint, nonce.as_str(), &key)), + Some((bootstrap_fingerprint, nonce.as_str(), key.as_ref())), ); match health { RelayHealth::Compatible => {} @@ -185,7 +194,7 @@ pub(crate) fn post_verified( .is_some_and(|value| value.eq_ignore_ascii_case("close")) { return Err(VerifiedHttpError::before_payload( - "verified Relay gateway closed the authenticated connection before hook delivery", + "verified Relay gateway closed the authenticated connection before request delivery", )); } @@ -202,22 +211,22 @@ pub(crate) fn post_verified( )); stream.write_all(request.as_bytes()).map_err(|error| { VerifiedHttpError::before_payload(format!( - "failed to send verified hook request headers: {error}" + "failed to send verified gateway request headers: {error}" )) })?; stream.write_all(body).map_err(|error| { VerifiedHttpError::after_payload(format!( - "verified hook payload delivery became indeterminate: {error}" + "verified gateway payload delivery became indeterminate: {error}" )) })?; let (response_headers, response_body) = read_http_message(&mut stream, max_response_bytes) .map_err(|error| { VerifiedHttpError::after_payload(format!( - "failed to read verified hook response: {error}" + "failed to read verified gateway response: {error}" )) })?; let status = http_status(&response_headers).ok_or_else(|| { - VerifiedHttpError::after_payload("verified hook response had an invalid HTTP status") + VerifiedHttpError::after_payload("verified gateway response had an invalid HTTP status") })?; Ok(VerifiedHttpResponse { status, @@ -280,7 +289,7 @@ pub(crate) fn probe_with_instance( return (RelayHealth::Foreign, None); } let challenge = bootstrap_fingerprint.map(|fingerprint| { - let key = BootstrapChallengeKey::load().map_err(|_| ())?; + let key = cached_bootstrap_challenge_key().map_err(|_| ())?; let mut nonce = [0_u8; 32]; SystemRandom::new().fill(&mut nonce).map_err(|_| ())?; let nonce = nonce @@ -316,11 +325,15 @@ pub(crate) fn probe_with_instance( &body, challenge .as_ref() - .map(|(fingerprint, nonce, key)| (*fingerprint, nonce.as_str(), key)), + .map(|(fingerprint, nonce, key)| (*fingerprint, nonce.as_str(), key.as_ref())), ) } -pub(crate) fn request_shutdown(url: &str, token: &str) -> Result<(), String> { +pub(crate) fn request_shutdown( + url: &str, + bootstrap_fingerprint: &str, + token: &str, +) -> Result<(), String> { let (host, port) = parse_loopback_url(url)?; let addresses = (host.as_str(), port) .to_socket_addrs() @@ -333,9 +346,42 @@ pub(crate) fn request_shutdown(url: &str, token: &str) -> Result<(), String> { stream .set_write_timeout(Some(HEALTHZ_TIMEOUT)) .map_err(|error| format!("failed to configure sidecar shutdown write timeout: {error}"))?; + let key = cached_bootstrap_challenge_key() + .map_err(|error| format!("failed to load the Relay bootstrap challenge key: {error}"))?; + let mut nonce = [0_u8; 32]; + SystemRandom::new() + .fill(&mut nonce) + .map_err(|_| "failed to generate a Relay bootstrap shutdown challenge".to_string())?; + let nonce = nonce + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let authority = loopback_authority(&host, port); + let challenge = format!( + "GET /healthz HTTP/1.1\r\nHost: {authority}\r\nX-NeMo-Relay-Bootstrap-Fingerprint: {bootstrap_fingerprint}\r\nX-NeMo-Relay-Bootstrap-Nonce: {nonce}\r\nConnection: keep-alive\r\n\r\n" + ); + stream + .write_all(challenge.as_bytes()) + .map_err(|error| format!("failed to authenticate managed sidecar shutdown: {error}"))?; + let (health_headers, health_body) = read_http_message(&mut stream, 16 * 1024) + .map_err(|error| format!("failed to read managed sidecar shutdown proof: {error}"))?; + if classify_health_response( + &health_headers, + &health_body, + Some((bootstrap_fingerprint, nonce.as_str(), key.as_ref())), + ) + .0 != RelayHealth::Compatible + { + return Err("managed sidecar did not authenticate the shutdown connection".into()); + } + if http_header(&health_headers, "connection") + .is_some_and(|value| value.eq_ignore_ascii_case("close")) + { + return Err("managed sidecar closed the authenticated shutdown connection".into()); + } let request = format!( "POST /bootstrap/shutdown HTTP/1.1\r\nHost: {}\r\nX-NeMo-Relay-Bootstrap-Token: {token}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - loopback_authority(&host, port) + authority ); stream .write_all(request.as_bytes()) @@ -361,6 +407,34 @@ pub(crate) fn request_shutdown(url: &str, token: &str) -> Result<(), String> { } } +fn cached_bootstrap_challenge_key() -> Result, String> { + let state = crate::bootstrap::state::state_dir()?; + let cache = CHALLENGE_KEY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache + .lock() + .map_err(|_| "Relay bootstrap challenge key cache is poisoned".to_string())?; + if let Some(key) = cache.get(&state) { + return Ok(Arc::clone(key)); + } + let key = Arc::new(BootstrapChallengeKey::load().map_err(|error| error.to_string())?); + cache.insert(state, Arc::clone(&key)); + Ok(key) +} + +fn cached_tls_identity() -> Result, String> { + let state = crate::bootstrap::state::state_dir()?; + let cache = TLS_IDENTITY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache + .lock() + .map_err(|_| "Relay TLS identity cache is poisoned".to_string())?; + if let Some(identity) = cache.get(&state) { + return Ok(Arc::clone(identity)); + } + let identity = Arc::new(crate::gateway::tls::RelayTlsIdentity::load()?); + cache.insert(state, Arc::clone(&identity)); + Ok(identity) +} + fn http_header<'a>(headers: &'a [u8], name: &str) -> Option<&'a str> { headers.split(|byte| *byte == b'\n').find_map(|line| { let line = std::str::from_utf8(line).ok()?.trim_end_matches('\r'); diff --git a/crates/cli/src/hooks/encoding.rs b/crates/cli/src/hooks/encoding.rs index e89bf83ca..1ac762c65 100644 --- a/crates/cli/src/hooks/encoding.rs +++ b/crates/cli/src/hooks/encoding.rs @@ -234,7 +234,7 @@ pub(super) fn safe_windows_launcher_token(launcher: &str) -> bool { /// Decode only the exact PowerShell envelope emitted by [`encoded_windows_hook_command`]. /// /// Hermes uses this to migrate and replace Relay-owned hooks whose generation arguments change. -#[cfg(test)] +#[cfg(any(windows, test))] pub(crate) fn decode_windows_hook_command(command: &str) -> Option> { const COMMAND_SEPARATOR: &str = " -NoLogo -NoProfile -NonInteractive -EncodedCommand "; const SCRIPT_PREFIX: &str = "$ErrorActionPreference='Stop'; & "; @@ -273,7 +273,7 @@ pub(crate) fn decode_windows_hook_command(command: &str) -> Option> parse_powershell_single_quoted_arguments(invocation) } -#[cfg(test)] +#[cfg(any(windows, test))] pub(super) fn parse_powershell_single_quoted_arguments(mut raw: &str) -> Option> { let mut arguments = Vec::new(); while !raw.is_empty() { diff --git a/crates/cli/src/hooks/mod.rs b/crates/cli/src/hooks/mod.rs index 33653fa48..6389f3c2e 100644 --- a/crates/cli/src/hooks/mod.rs +++ b/crates/cli/src/hooks/mod.rs @@ -19,11 +19,13 @@ pub(crate) use delivery::{gateway_headers, insert_header, read_hook_payload_from pub(crate) use destination::{ HookGatewayLifecycle, resolve_hook_destination, transparent_gateway_spec, }; +#[cfg(any(windows, test))] +pub(crate) use encoding::decode_windows_hook_command; #[cfg(all(test, windows))] pub(crate) use encoding::windows_powershell_path; #[cfg(test)] pub(crate) use encoding::{ - decode_windows_hook_command, encoded_windows_hook_command, event_matches_tools, + encoded_windows_hook_command, event_matches_tools, persistent_hook_forward_command_for_platform, transparent_hook_forward_command_for_platform, }; pub(crate) use encoding::{ diff --git a/crates/cli/src/hooks/response.rs b/crates/cli/src/hooks/response.rs index 5e43f5000..c06264fba 100644 --- a/crates/cli/src/hooks/response.rs +++ b/crates/cli/src/hooks/response.rs @@ -47,11 +47,18 @@ pub(crate) fn handle_verified_hook_forward_response( ) -> Result<(), CliError> { match response { Ok(response) => { - let status = reqwest::StatusCode::from_u16(response.status).map_err(|error| { - CliError::Install(format!( - "verified hook response had an invalid status: {error}" - )) - })?; + let status = match reqwest::StatusCode::from_u16(response.status) { + Ok(status) => status, + Err(error) => { + let message = format!("verified hook response had an invalid status: {error}"); + eprintln!("nemo-relay hook forward failed: {message}"); + return if fail_closed { + Err(CliError::Install(message)) + } else { + Ok(()) + }; + } + }; handle_hook_forward_status( status, String::from_utf8_lossy(&response.body).into_owned(), diff --git a/crates/cli/src/installation/operation_lock.rs b/crates/cli/src/installation/operation_lock.rs index 67dd8b273..c1bfcbb7b 100644 --- a/crates/cli/src/installation/operation_lock.rs +++ b/crates/cli/src/installation/operation_lock.rs @@ -15,7 +15,7 @@ const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25); pub(crate) struct PluginOperationLock { _global_file: File, - _root_file: File, + _root_file: Option, } impl PluginOperationLock { @@ -27,7 +27,16 @@ impl PluginOperationLock { ) -> Result { let deadline = Instant::now() + timeout; let global_file = acquire_lock_file(installation_key, global_lock_dir, deadline, "global")?; - let root_file = acquire_lock_file(installation_key, install_dir, deadline, "install-root")?; + let root_file = if directories_alias(global_lock_dir, install_dir) { + None + } else { + Some(acquire_lock_file( + installation_key, + install_dir, + deadline, + "install-root", + )?) + }; Ok(Self { _global_file: global_file, _root_file: root_file, @@ -35,6 +44,14 @@ impl PluginOperationLock { } } +fn directories_alias(left: &Path, right: &Path) -> bool { + left == right + || matches!( + (fs::canonicalize(left), fs::canonicalize(right)), + (Ok(left), Ok(right)) if left == right + ) +} + fn acquire_lock_file( installation_key: &str, directory: &Path, diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 1b98d92db..d292fb9de 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -27,7 +27,8 @@ mod sessions; mod hook_assertions; #[cfg(test)] -pub(crate) use commands::test_support; +#[path = "../tests/coverage/shared/test_support.rs"] +pub(crate) mod test_support; use std::process::ExitCode; @@ -37,6 +38,7 @@ use std::process::ExitCode; #[doc(hidden)] pub fn run_cli() -> ExitCode { mcp_environment::remove_unresolved_mcp_placeholders(); + let bootstrap_shutdown_token = take_bootstrap_shutdown_token(); let runtime = match tokio::runtime::Builder::new_multi_thread() .enable_all() .build() @@ -47,5 +49,16 @@ pub fn run_cli() -> ExitCode { return ExitCode::FAILURE; } }; - runtime.block_on(commands::run()) + runtime.block_on(commands::run(bootstrap_shutdown_token)) +} + +fn take_bootstrap_shutdown_token() -> Option { + let token = std::env::var(bootstrap::state::BOOTSTRAP_SHUTDOWN_TOKEN_ENV) + .ok() + .filter(|token| !token.is_empty()); + // SAFETY: this runs before the Tokio runtime and application threads are created. + unsafe { + std::env::remove_var(bootstrap::state::BOOTSTRAP_SHUTDOWN_TOKEN_ENV); + } + token } diff --git a/crates/cli/src/server/mod.rs b/crates/cli/src/server/mod.rs index 5eab67aac..23f5eba42 100644 --- a/crates/cli/src/server/mod.rs +++ b/crates/cli/src/server/mod.rs @@ -68,12 +68,21 @@ pub(crate) struct BootstrapShutdown { sender: Arc>>>, } +#[derive(Default)] +struct BootstrapServeOptions<'a> { + fingerprint: Option, + identity: Option, + ready_file: Option<&'a Path>, + shutdown_token: Option, +} + /// Binds the configured address and activates enabled dynamic plugins before serving. pub(crate) async fn serve_with_dynamic( config: GatewayConfig, dynamic_plugins: Vec, managed_bootstrap: Option, ready_file: Option<&Path>, + bootstrap_shutdown_token: Option, ) -> Result<(), CliError> { let listener = bind_listener(config.bind).await?; print_startup_status(listener.local_addr()?, &config); @@ -84,10 +93,13 @@ pub(crate) async fn serve_with_dynamic( listener, config, dynamic_plugins, - bootstrap_fingerprint, - managed_bootstrap, Some(ShutdownMode::ProcessSignal), - ready_file, + BootstrapServeOptions { + fingerprint: bootstrap_fingerprint, + identity: managed_bootstrap, + ready_file, + shutdown_token: bootstrap_shutdown_token, + }, ) .await } @@ -169,10 +181,11 @@ pub(crate) async fn serve_listener_with_bootstrap( listener, config, Vec::new(), - Some(bootstrap_fingerprint), - None, shutdown.map(ShutdownMode::Receiver), - None, + BootstrapServeOptions { + fingerprint: Some(bootstrap_fingerprint), + ..BootstrapServeOptions::default() + }, ) .await } @@ -189,10 +202,8 @@ pub(crate) async fn serve_listener_with_dynamic( listener, config, dynamic_plugins, - None, - None, shutdown.map(ShutdownMode::Receiver), - None, + BootstrapServeOptions::default(), ) .await } @@ -210,10 +221,11 @@ pub(crate) async fn serve_transparent_listener_with_dynamic( listener, config, dynamic_plugins, - Some(bootstrap_fingerprint), - None, shutdown.map(ShutdownMode::Receiver), - None, + BootstrapServeOptions { + fingerprint: Some(bootstrap_fingerprint), + ..BootstrapServeOptions::default() + }, ) .await } @@ -229,18 +241,15 @@ async fn serve_listener_with_dynamic_inner( listener: TcpListener, config: GatewayConfig, dynamic_plugins: Vec, - bootstrap_fingerprint: Option, - managed_bootstrap: Option, shutdown_mode: Option, - ready_file: Option<&Path>, + bootstrap: BootstrapServeOptions<'_>, ) -> Result<(), CliError> { - let bootstrap_shutdown_token = std::env::var("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN") - .ok() - .filter(|token| !token.is_empty()); - // The token belongs only to this server. Remove it before worker plugin processes are spawned. - unsafe { - std::env::remove_var("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN"); - } + let BootstrapServeOptions { + fingerprint: bootstrap_fingerprint, + identity: managed_bootstrap, + ready_file, + shutdown_token: bootstrap_shutdown_token, + } = bootstrap; let bootstrap_challenge_key = bootstrap_fingerprint .as_ref() .map(|_| BootstrapChallengeKey::load()) diff --git a/crates/cli/tests/coverage/agents/hermes_tests.rs b/crates/cli/tests/coverage/agents/hermes_tests.rs index bcd44a9ee..2d8e59b3f 100644 --- a/crates/cli/tests/coverage/agents/hermes_tests.rs +++ b/crates/cli/tests/coverage/agents/hermes_tests.rs @@ -156,6 +156,7 @@ fn hook_command_round_trips_paths_and_platform_metacharacters() { TEST_GENERATION_TOKEN, true, ); + assert!(is_persistent_relay_hook_command(&encoded)); let encoded_codex = crate::hooks::persistent_hook_forward_command_for_platform( Path::new(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"), CodingAgent::Codex, @@ -164,6 +165,7 @@ fn hook_command_round_trips_paths_and_platform_metacharacters() { true, ); assert_ne!(encoded, encoded_codex); + assert!(!is_persistent_relay_hook_command(&encoded_codex)); } #[test] diff --git a/crates/cli/tests/coverage/agents/plugin_host_tests.rs b/crates/cli/tests/coverage/agents/plugin_host_tests.rs index 802fc700f..c9e4233c4 100644 --- a/crates/cli/tests/coverage/agents/plugin_host_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_host_tests.rs @@ -2761,6 +2761,34 @@ fn claude_reinstall_uses_fresh_backup_after_prior_restore() { assert!(!backup_path(&settings).exists()); } +#[test] +fn claude_gateway_url_change_preserves_the_pre_relay_backup() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com" } + })) + .unwrap(), + ) + .unwrap(); + + enable_claude_provider(DEFAULT_URL).unwrap(); + let replacement_gateway = "http://127.0.0.1:49999"; + enable_claude_provider(replacement_gateway).unwrap(); + restore_claude_provider(replacement_gateway).unwrap(); + + let restored: Value = serde_json::from_slice(&fs::read(&settings).unwrap()).unwrap(); + assert_eq!( + json_env_string(&restored, "ANTHROPIC_BASE_URL"), + Some("https://api.anthropic.com") + ); + assert!(!backup_path(&settings).exists()); +} + #[test] fn windows_shell_argument_quoting_and_hook_encoding_preserve_paths() { let relay = std::path::PathBuf::from(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"); diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index 91c2e3a09..9b06d9ed4 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -1216,6 +1216,23 @@ fn concurrent_different_install_roots_share_the_global_host_lock() { holder.release(); } +#[test] +fn plugin_operation_lock_acquires_an_aliased_global_and_install_root_once() { + let root = tempdir().unwrap(); + + let _lock = PluginOperationLock::acquire( + "codex", + root.path(), + &root.path().join("."), + Duration::from_millis(75), + ) + .unwrap(); + + assert!( + crate::installation::operation_lock::operation_lock_path("codex", root.path()).exists() + ); +} + #[test] fn generation_retirement_lock_contention_is_bounded_across_processes() { let dir = tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index cb0e3246b..de330957b 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -13,6 +13,20 @@ use crate::commands::plugins::{ PluginsSubcommand, PluginsValidateCommand, }; +#[test] +fn bootstrap_shutdown_token_is_removed_before_runtime_startup() { + let _environment = crate::test_support::EnvScope::set(&[( + crate::bootstrap::state::BOOTSTRAP_SHUTDOWN_TOKEN_ENV, + Some(std::ffi::OsStr::new("private-token")), + )]); + + assert_eq!( + crate::take_bootstrap_shutdown_token().as_deref(), + Some("private-token") + ); + assert!(std::env::var_os(crate::bootstrap::state::BOOTSTRAP_SHUTDOWN_TOKEN_ENV).is_none()); +} + struct EnvScope { _cwd_guard: Option, _guard: std::sync::MutexGuard<'static, ()>, @@ -98,6 +112,31 @@ fn cli_parses_native_mcp_subcommand_and_bind_override() { assert!(Cli::try_parse_from(["nemo-relay", "mcp", "--agent", "codex"]).is_err()); } +#[test] +fn doctor_rejects_conflicting_agent_and_plugin_targets() { + let error = + Cli::try_parse_from(["nemo-relay", "doctor", "codex", "--plugin", "all"]).unwrap_err(); + assert!(error.to_string().contains("cannot be used with")); +} + +#[test] +fn multi_agent_operations_attempt_every_target_before_reporting_errors() { + let visited = std::cell::RefCell::new(Vec::new()); + let error = install::run_agent_operations(CodingAgent::ALL.to_vec(), "install", |agent| { + visited.borrow_mut().push(agent); + match agent { + CodingAgent::Codex => Err(error::CliError::Install("codex failure".into())), + CodingAgent::ClaudeCode => Ok(ExitCode::FAILURE), + CodingAgent::Hermes => Ok(ExitCode::SUCCESS), + } + }) + .unwrap_err() + .to_string(); + + assert_eq!(*visited.borrow(), CodingAgent::ALL); + assert!(error.contains("codex failure"), "{error}"); +} + #[test] fn safe_dispatch_helpers_cover_completions_and_plugins_paths() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs b/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs index c10efc548..51a2e412d 100644 --- a/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs +++ b/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs @@ -2,100 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use std::ffi::{OsStr, OsString}; -use std::io::{Read, Write}; +use crate::test_support::{EnvScope, accept_bounded, header, read_headers}; +use std::ffi::OsStr; +use std::io::Write; use std::net::TcpListener; -struct EnvScope { - _guard: std::sync::MutexGuard<'static, ()>, - previous: Vec<(&'static str, Option)>, -} - -impl EnvScope { - fn set(values: &[(&'static str, Option<&OsStr>)]) -> Self { - let guard = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let previous = values - .iter() - .map(|(name, _)| (*name, std::env::var_os(name))) - .collect(); - for (name, value) in values { - // SAFETY: The process-wide environment lock is held for this scope. - unsafe { - match value { - Some(value) => std::env::set_var(name, value), - None => std::env::remove_var(name), - } - } - } - Self { - _guard: guard, - previous, - } - } -} - -impl Drop for EnvScope { - fn drop(&mut self) { - for (name, value) in self.previous.drain(..) { - // SAFETY: The process-wide environment lock remains held during restoration. - unsafe { - match value { - Some(value) => std::env::set_var(name, value), - None => std::env::remove_var(name), - } - } - } - } -} - -fn read_headers(stream: &mut std::net::TcpStream) -> String { - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - let mut bytes = Vec::new(); - let mut byte = [0_u8; 1]; - while !bytes.ends_with(b"\r\n\r\n") { - stream.read_exact(&mut byte).unwrap(); - bytes.push(byte[0]); - } - String::from_utf8(bytes).unwrap() -} - -fn accept_bounded(listener: &TcpListener) -> std::net::TcpStream { - listener.set_nonblocking(true).unwrap(); - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - match listener.accept() { - Ok((stream, _)) => { - stream.set_nonblocking(false).unwrap(); - return stream; - } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - assert!( - std::time::Instant::now() < deadline, - "timed out accepting test connection" - ); - std::thread::sleep(Duration::from_millis(10)); - } - Err(error) => panic!("failed to accept test connection: {error}"), - } - } -} - -fn header(request: &str, name: &str) -> String { - request - .lines() - .find_map(|line| { - let (candidate, value) = line.split_once(':')?; - candidate - .eq_ignore_ascii_case(name) - .then(|| value.trim().to_string()) - }) - .unwrap() -} - #[test] fn owner_records_are_versioned_endpoint_scoped_and_round_trip() { let dir = tempfile::tempdir().unwrap(); @@ -278,6 +189,18 @@ fn authenticated_owned_gateway_is_shut_down_and_cleaned_up() { .unwrap(); let mut shutdown = accept_bounded(&listener); + let challenge = read_headers(&mut shutdown); + let nonce = header(&challenge, "x-nemo-relay-bootstrap-nonce"); + let proof = key.proof("fingerprint", &nonce); + shutdown + .write_all( + format!( + "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); let request = read_headers(&mut shutdown); assert!(request.starts_with("POST /bootstrap/shutdown HTTP/1.1")); assert_eq!( diff --git a/crates/cli/tests/coverage/shared/bootstrap_tests.rs b/crates/cli/tests/coverage/shared/bootstrap_tests.rs index 59541284b..a668b386c 100644 --- a/crates/cli/tests/coverage/shared/bootstrap_tests.rs +++ b/crates/cli/tests/coverage/shared/bootstrap_tests.rs @@ -2,16 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use std::ffi::{OsStr, OsString}; -use std::io::{Read, Write}; +use crate::test_support::{EnvScope, accept_bounded, header, read_headers}; +use std::ffi::OsStr; +use std::io::Write; use std::net::TcpListener; use std::process::Command; -struct EnvScope { - _guard: std::sync::MutexGuard<'static, ()>, - previous: Vec<(&'static str, Option)>, -} - #[test] fn failed_reaper_spawn_terminates_and_reaps_the_retained_child() { let child = Command::new(std::env::current_exe().unwrap()) @@ -36,91 +32,6 @@ fn failed_reaper_spawn_terminates_and_reaps_the_retained_child() { assert!(error.contains("failed to start gateway reaper thread")); } -impl EnvScope { - fn set(values: &[(&'static str, Option<&OsStr>)]) -> Self { - let guard = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let previous = values - .iter() - .map(|(name, _)| (*name, std::env::var_os(name))) - .collect(); - for (name, value) in values { - // SAFETY: The process-wide environment lock is held. - unsafe { - match value { - Some(value) => std::env::set_var(name, value), - None => std::env::remove_var(name), - } - } - } - Self { - _guard: guard, - previous, - } - } -} - -impl Drop for EnvScope { - fn drop(&mut self) { - for (name, value) in self.previous.drain(..) { - // SAFETY: The process-wide environment lock remains held during restoration. - unsafe { - match value { - Some(value) => std::env::set_var(name, value), - None => std::env::remove_var(name), - } - } - } - } -} - -fn read_headers(stream: &mut std::net::TcpStream) -> String { - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - let mut bytes = Vec::new(); - let mut byte = [0_u8; 1]; - while !bytes.ends_with(b"\r\n\r\n") { - stream.read_exact(&mut byte).unwrap(); - bytes.push(byte[0]); - } - String::from_utf8(bytes).unwrap() -} - -fn accept_bounded(listener: &TcpListener) -> std::net::TcpStream { - listener.set_nonblocking(true).unwrap(); - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - match listener.accept() { - Ok((stream, _)) => { - stream.set_nonblocking(false).unwrap(); - return stream; - } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - assert!( - std::time::Instant::now() < deadline, - "timed out accepting test connection" - ); - std::thread::sleep(Duration::from_millis(10)); - } - Err(error) => panic!("failed to accept test connection: {error}"), - } - } -} - -fn header(request: &str, name: &str) -> String { - request - .lines() - .find_map(|line| { - let (candidate, value) = line.split_once(':')?; - candidate - .eq_ignore_ascii_case(name) - .then(|| value.trim().to_string()) - }) - .unwrap() -} - #[test] fn persistent_gateway_requires_a_loopback_endpoint() { let non_loopback = GatewaySpec::new("0.0.0.0:47632".parse().unwrap()) @@ -184,24 +95,39 @@ fn foreign_and_incompatible_listeners_are_never_adopted() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let body = body.to_string(); - let connections = if expected == "not a compatible" { 2 } else { 1 }; + listener.set_nonblocking(true).unwrap(); + let (stop_tx, stop_rx) = std::sync::mpsc::channel(); let server = std::thread::spawn(move || { - for _ in 0..connections { - let mut stream = accept_bounded(&listener); - let _ = read_headers(&mut stream); - stream - .write_all( - format!( - "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ) - .as_bytes(), - ) - .unwrap(); + loop { + if stop_rx.try_recv().is_ok() { + break; + } + match listener.accept() { + Ok((mut stream, _)) => { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let _ = read_headers(&mut stream); + stream + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("failed to accept test connection: {error}"), + } } }); let error = GatewaySpec::new(address).acquire().unwrap_err(); + stop_tx.send(()).unwrap(); server.join().unwrap(); assert!(error.contains(expected), "{error}"); } diff --git a/crates/cli/tests/coverage/shared/completions_install_tests.rs b/crates/cli/tests/coverage/shared/completions_install_tests.rs index 2976887e1..9bf2923b2 100644 --- a/crates/cli/tests/coverage/shared/completions_install_tests.rs +++ b/crates/cli/tests/coverage/shared/completions_install_tests.rs @@ -5,6 +5,7 @@ use super::*; use std::ffi::OsString; use std::path::PathBuf; +use crate::test_support::EnvScope; use clap_complete::Shell; #[test] @@ -103,45 +104,3 @@ fn install_writes_detected_shell_completion() { let script = std::fs::read_to_string(path).unwrap(); assert!(script.contains("nemo-relay")); } - -struct EnvScope { - _guard: std::sync::MutexGuard<'static, ()>, - values: Vec<(&'static str, Option)>, -} - -impl EnvScope { - fn set(values: &[(&'static str, Option<&std::ffi::OsStr>)]) -> Self { - let guard = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); - let previous = values - .iter() - .map(|(key, _)| (*key, std::env::var_os(key))) - .collect::>(); - for (key, value) in values { - unsafe { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } - } - } - Self { - _guard: guard, - values: previous, - } - } -} - -impl Drop for EnvScope { - fn drop(&mut self) { - for (key, value) in self.values.drain(..) { - unsafe { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } - } - } - } -} diff --git a/crates/cli/tests/coverage/shared/doctor_tests.rs b/crates/cli/tests/coverage/shared/doctor_tests.rs index 28adc4f52..43bc20938 100644 --- a/crates/cli/tests/coverage/shared/doctor_tests.rs +++ b/crates/cli/tests/coverage/shared/doctor_tests.rs @@ -2,37 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use std::ffi::OsString; use std::io::{Read, Write}; use std::net::TcpListener; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use crate::configuration::ResolvedDynamicPluginConfig; - -fn accept_bounded(listener: &TcpListener) -> std::net::TcpStream { - listener.set_nonblocking(true).unwrap(); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - match listener.accept() { - Ok((stream, _)) => { - stream.set_nonblocking(false).unwrap(); - stream - .set_read_timeout(Some(std::time::Duration::from_secs(5))) - .unwrap(); - return stream; - } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - assert!( - std::time::Instant::now() < deadline, - "timed out accepting test connection" - ); - std::thread::sleep(std::time::Duration::from_millis(10)); - } - Err(error) => panic!("failed to accept test connection: {error}"), - } - } -} +use crate::test_support::{EnvScope, accept_bounded}; fn start_doctor_http_capture_server() -> (String, Arc>, std::thread::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -68,48 +44,6 @@ fn start_doctor_http_capture_server() -> (String, Arc>, std::threa (url, body, handle) } -struct EnvScope { - _guard: std::sync::MutexGuard<'static, ()>, - values: Vec<(&'static str, Option)>, -} - -impl EnvScope { - fn set(values: &[(&'static str, Option<&std::ffi::OsStr>)]) -> Self { - let guard = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); - let previous = values - .iter() - .map(|(key, _)| (*key, std::env::var_os(key))) - .collect::>(); - for (key, value) in values { - unsafe { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } - } - } - Self { - _guard: guard, - values: previous, - } - } -} - -impl Drop for EnvScope { - fn drop(&mut self) { - for (key, value) in self.values.drain(..) { - unsafe { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } - } - } - } -} - fn empty_report() -> DoctorReport { DoctorReport { schema_version: 1, @@ -379,6 +313,27 @@ fn format_human_reports_config_resolution_failure() { assert!(rendered.contains("Some checks FAILED")); } +#[tokio::test] +async fn agents_report_surfaces_merged_config_resolution_errors() { + let temp = tempfile::tempdir().unwrap(); + let config_home = temp.path().join("config"); + let config = config_home.join("nemo-relay").join("config.toml"); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + std::fs::write(&config, "[upstream\n").unwrap(); + let _env = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(config_home.as_os_str())), + ( + "NEMO_RELAY_CONFIG_SCOPE", + Some(std::ffi::OsStr::new("user")), + ), + ]); + + let error = agents_report().await.unwrap_err().to_string(); + + assert!(error.contains("config"), "{error}"); + assert!(error.contains("TOML"), "{error}"); +} + #[test] fn format_human_distinguishes_pass_with_warnings_from_clean_pass() { let mut report = empty_report(); @@ -714,7 +669,10 @@ fn collect_completions_reports_shell_specific_paths() { #[test] fn collect_environment_and_completions_cover_missing_home_and_unknown_shell() { - let _env = EnvScope::set(&[("SHELL", Some(std::ffi::OsStr::new("/opt/bin/elvish")))]); + let _env = EnvScope::set(&[ + ("SHELL", Some(std::ffi::OsStr::new("/opt/bin/elvish"))), + ("COMSPEC", Some(std::ffi::OsStr::new("C:/opt/bin/elvish"))), + ]); let environment = collect_environment(); assert_eq!(environment.shell.as_deref(), Some("elvish")); diff --git a/crates/cli/tests/coverage/shared/gateway_client_tests.rs b/crates/cli/tests/coverage/shared/gateway_client_tests.rs index f7a17bd80..842691b82 100644 --- a/crates/cli/tests/coverage/shared/gateway_client_tests.rs +++ b/crates/cli/tests/coverage/shared/gateway_client_tests.rs @@ -8,46 +8,7 @@ use std::thread; use std::time::Duration; use super::*; - -struct TestEnvironment { - _guard: std::sync::MutexGuard<'static, ()>, - previous: Vec<(&'static str, Option)>, -} - -impl TestEnvironment { - fn isolated_home(path: &std::path::Path) -> Self { - let guard = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let previous = ["XDG_CONFIG_HOME", "HOME"] - .into_iter() - .map(|name| (name, std::env::var_os(name))) - .collect(); - // SAFETY: ENV_TEST_LOCK serializes process-wide environment changes. - unsafe { - std::env::set_var("XDG_CONFIG_HOME", path); - std::env::set_var("HOME", path); - } - Self { - _guard: guard, - previous, - } - } -} - -impl Drop for TestEnvironment { - fn drop(&mut self) { - for (name, value) in self.previous.drain(..) { - // SAFETY: ENV_TEST_LOCK remains held during restoration. - unsafe { - match value { - Some(value) => std::env::set_var(name, value), - None => std::env::remove_var(name), - } - } - } - } -} +use crate::test_support::{EnvScope, header, read_headers}; fn serve_once(response: &[u8]) -> (String, mpsc::Receiver>, thread::JoinHandle<()>) { let response = response.to_vec(); @@ -82,14 +43,58 @@ fn serve_once(response: &[u8]) -> (String, mpsc::Receiver>, thread::Join (url, receiver, server) } +fn serve_verified_shutdown( + key: crate::configuration::BootstrapChallengeKey, + response: &[u8], +) -> (String, mpsc::Receiver, thread::JoinHandle<()>) { + let response = response.to_vec(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let (sender, receiver) = mpsc::channel(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let challenge = read_headers(&mut stream); + let nonce = header(&challenge, "x-nemo-relay-bootstrap-nonce"); + let proof = key.proof("fingerprint", &nonce); + let body = format!( + "{{\"status\":\"ok\",\"service\":\"nemo-relay\",\"version\":\"{}\",\"bootstrap_protocol\":{},\"instance_id\":\"test-instance\"}}", + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + let _ = sender.send(read_headers(&mut stream)); + stream.write_all(&response).unwrap(); + }); + (url, receiver, server) +} + #[test] fn shutdown_request_sends_the_private_token_and_accepts_no_content() { - let (url, request, server) = - serve_once(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + let temp = tempfile::tempdir().unwrap(); + let _environment = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(temp.path().as_os_str())), + ("HOME", Some(temp.path().as_os_str())), + ]); + let key = crate::configuration::BootstrapChallengeKey::load().unwrap(); + let (url, request, server) = serve_verified_shutdown( + key, + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); - request_shutdown(&url, "private-token").unwrap(); + request_shutdown(&url, "fingerprint", "private-token").unwrap(); - let request = String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap(); + let request = request.recv_timeout(Duration::from_secs(2)).unwrap(); assert!( request.starts_with("POST /bootstrap/shutdown HTTP/1.1"), "{request}" @@ -103,10 +108,18 @@ fn shutdown_request_sends_the_private_token_and_accepts_no_content() { #[test] fn shutdown_request_reports_rejection_without_hiding_the_status() { - let (url, _, server) = - serve_once(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + let temp = tempfile::tempdir().unwrap(); + let _environment = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(temp.path().as_os_str())), + ("HOME", Some(temp.path().as_os_str())), + ]); + let key = crate::configuration::BootstrapChallengeKey::load().unwrap(); + let (url, _, server) = serve_verified_shutdown( + key, + b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); - let error = request_shutdown(&url, "wrong-token").unwrap_err(); + let error = request_shutdown(&url, "fingerprint", "wrong-token").unwrap_err(); assert!(error.contains("rejected shutdown"), "{error}"); assert!(error.contains("HTTP/1.1 403 Forbidden"), "{error}"); @@ -115,9 +128,15 @@ fn shutdown_request_reports_rejection_without_hiding_the_status() { #[test] fn shutdown_request_rejects_a_malformed_http_response() { - let (url, _, server) = serve_once(b"not-http"); + let temp = tempfile::tempdir().unwrap(); + let _environment = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(temp.path().as_os_str())), + ("HOME", Some(temp.path().as_os_str())), + ]); + let key = crate::configuration::BootstrapChallengeKey::load().unwrap(); + let (url, _, server) = serve_verified_shutdown(key, b"not-http"); - let error = request_shutdown(&url, "private-token").unwrap_err(); + let error = request_shutdown(&url, "fingerprint", "private-token").unwrap_err(); assert!(error.contains("malformed shutdown response"), "{error}"); server.join().unwrap(); @@ -129,7 +148,7 @@ fn shutdown_request_reports_connection_failure() { let url = format!("http://{}", listener.local_addr().unwrap()); drop(listener); - let error = request_shutdown(&url, "private-token").unwrap_err(); + let error = request_shutdown(&url, "fingerprint", "private-token").unwrap_err(); assert!(error.contains("failed to connect"), "{error}"); } @@ -168,7 +187,10 @@ fn loopback_helpers_normalize_localhost_and_ipv6_authorities() { #[test] fn verified_hook_payload_is_not_sent_before_the_tls_tunnel_is_authenticated() { let temp = tempfile::tempdir().unwrap(); - let _environment = TestEnvironment::isolated_home(temp.path()); + let _environment = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(temp.path().as_os_str())), + ("HOME", Some(temp.path().as_os_str())), + ]); crate::configuration::BootstrapChallengeKey::load().unwrap(); crate::gateway::tls::RelayTlsIdentity::load_or_create().unwrap(); let (url, request, server) = serve_once( @@ -195,3 +217,22 @@ fn verified_hook_payload_is_not_sent_before_the_tls_tunnel_is_authenticated() { assert!(error.to_string().contains("authenticated Relay TLS tunnel")); server.join().unwrap(); } + +#[test] +fn verified_transport_reuses_loaded_bootstrap_credentials() { + let temp = tempfile::tempdir().unwrap(); + let _environment = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(temp.path().as_os_str())), + ("HOME", Some(temp.path().as_os_str())), + ]); + crate::configuration::BootstrapChallengeKey::load().unwrap(); + crate::gateway::tls::RelayTlsIdentity::load_or_create().unwrap(); + + let first_key = cached_bootstrap_challenge_key().unwrap(); + let second_key = cached_bootstrap_challenge_key().unwrap(); + assert!(std::sync::Arc::ptr_eq(&first_key, &second_key)); + + let first_identity = cached_tls_identity().unwrap(); + let second_identity = cached_tls_identity().unwrap(); + assert!(std::sync::Arc::ptr_eq(&first_identity, &second_identity)); +} diff --git a/crates/cli/tests/coverage/shared/installer_tests.rs b/crates/cli/tests/coverage/shared/installer_tests.rs index bc2eebc98..e3908c6c7 100644 --- a/crates/cli/tests/coverage/shared/installer_tests.rs +++ b/crates/cli/tests/coverage/shared/installer_tests.rs @@ -203,6 +203,15 @@ fn verified_hook_response_rejects_invalid_status_and_fail_open_http_errors() { .to_string(); assert!(error.contains("invalid status"), "{error}"); + handle_verified_hook_forward_response( + Ok(crate::gateway::client::VerifiedHttpResponse { + status: 0, + body: Vec::new(), + }), + false, + ) + .unwrap(); + handle_hook_forward_status(reqwest::StatusCode::BAD_GATEWAY, String::new(), false).unwrap(); } diff --git a/crates/cli/tests/coverage/shared/test_support.rs b/crates/cli/tests/coverage/shared/test_support.rs new file mode 100644 index 000000000..a76959793 --- /dev/null +++ b/crates/cli/tests/coverage/shared/test_support.rs @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::{OsStr, OsString}; +use std::io::Read; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +#[must_use] +pub(crate) struct CwdTestScope { + _guard: MutexGuard<'static, ()>, + prev: Option, +} + +impl CwdTestScope { + pub(crate) fn locked() -> Self { + Self { + _guard: lock_cwd(), + prev: None, + } + } + + pub(crate) fn enter(path: &Path) -> Self { + let guard = lock_cwd(); + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(path).unwrap(); + Self { + _guard: guard, + prev: Some(prev), + } + } +} + +impl Drop for CwdTestScope { + fn drop(&mut self) { + if let Some(prev) = &self.prev + && let Err(error) = std::env::set_current_dir(prev) + { + CWD_RESTORE_FAILED.store(true, Ordering::SeqCst); + if std::thread::panicking() { + eprintln!("failed to restore current_dir to {prev:?}: {error}"); + } else { + panic!("failed to restore current_dir to {prev:?}: {error}"); + } + } + } +} + +#[must_use] +pub(crate) struct EnvScope { + _guard: MutexGuard<'static, ()>, + previous: Vec<(&'static str, Option)>, +} + +impl EnvScope { + pub(crate) fn set(values: &[(&'static str, Option<&OsStr>)]) -> Self { + let guard = ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut previous = Vec::with_capacity(values.len()); + for &(name, value) in values { + previous.push((name, std::env::var_os(name))); + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + Self { + _guard: guard, + previous, + } + } +} + +impl Drop for EnvScope { + fn drop(&mut self) { + for (name, value) in self.previous.drain(..).rev() { + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + } +} + +pub(crate) static CWD_TEST_LOCK: Mutex<()> = Mutex::new(()); +static CWD_RESTORE_FAILED: AtomicBool = AtomicBool::new(false); +pub(crate) static ENV_TEST_LOCK: Mutex<()> = Mutex::new(()); +pub(crate) static PLUGIN_CONFIG_TEST_LOCK: tokio::sync::Mutex<()> = + tokio::sync::Mutex::const_new(()); + +fn lock_cwd() -> MutexGuard<'static, ()> { + let guard = CWD_TEST_LOCK.lock().expect("CWD_TEST_LOCK poisoned"); + assert!( + !CWD_RESTORE_FAILED.load(Ordering::SeqCst), + "current_dir restore failed in a previous test; aborting to prevent cross-test contamination", + ); + guard +} + +pub(crate) fn accept_bounded(listener: &TcpListener) -> std::net::TcpStream { + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + Instant::now() < deadline, + "timed out waiting for connection" + ); + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("accept failed: {error}"), + } + } +} + +pub(crate) fn read_headers(stream: &mut std::net::TcpStream) -> String { + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0, "connection closed before complete HTTP headers"); + request.extend_from_slice(&buffer[..count]); + } + String::from_utf8(request).unwrap() +} + +pub(crate) fn header(request: &str, name: &str) -> String { + request + .lines() + .find_map(|line| { + let (candidate, value) = line.split_once(':')?; + candidate + .eq_ignore_ascii_case(name) + .then(|| value.trim().to_string()) + }) + .unwrap_or_else(|| panic!("missing {name} header in {request:?}")) +} From 4d6b464c71d5d369fce89caec71026c86cf31897 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Tue, 14 Jul 2026 09:28:52 -0400 Subject: [PATCH 095/102] fix(cli): address lifecycle review feedback Signed-off-by: Will Killian --- crates/cli/src/installation/operation_lock.rs | 17 ++- crates/cli/src/mcp/protocol.rs | 6 +- crates/cli/tests/cli_tests.rs | 103 ++++++++++++++++++ .../coverage/agents/plugin_install_tests.rs | 20 +++- crates/cli/tests/coverage/shared/mcp_tests.rs | 39 +++++-- scripts/README.md | 8 ++ 6 files changed, 167 insertions(+), 26 deletions(-) diff --git a/crates/cli/src/installation/operation_lock.rs b/crates/cli/src/installation/operation_lock.rs index c1bfcbb7b..c60bcdfe8 100644 --- a/crates/cli/src/installation/operation_lock.rs +++ b/crates/cli/src/installation/operation_lock.rs @@ -27,6 +27,7 @@ impl PluginOperationLock { ) -> Result { let deadline = Instant::now() + timeout; let global_file = acquire_lock_file(installation_key, global_lock_dir, deadline, "global")?; + ensure_lock_directory(install_dir)?; let root_file = if directories_alias(global_lock_dir, install_dir) { None } else { @@ -44,6 +45,15 @@ impl PluginOperationLock { } } +fn ensure_lock_directory(directory: &Path) -> Result<(), String> { + fs::create_dir_all(directory).map_err(|error| { + format!( + "failed to create plugin operation lock directory {}: {error}", + directory.display() + ) + }) +} + fn directories_alias(left: &Path, right: &Path) -> bool { left == right || matches!( @@ -58,12 +68,7 @@ fn acquire_lock_file( deadline: Instant, scope: &str, ) -> Result { - fs::create_dir_all(directory).map_err(|error| { - format!( - "failed to create plugin operation lock directory {}: {error}", - directory.display() - ) - })?; + ensure_lock_directory(directory)?; let path = operation_lock_path(installation_key, directory); let mut options = OpenOptions::new(); options.create(true).truncate(false).read(true).write(true); diff --git a/crates/cli/src/mcp/protocol.rs b/crates/cli/src/mcp/protocol.rs index ef23f881a..1af75c8d4 100644 --- a/crates/cli/src/mcp/protocol.rs +++ b/crates/cli/src/mcp/protocol.rs @@ -5,7 +5,8 @@ use serde_json::{Value, json}; -pub(super) const MCP_PROTOCOL_VERSION: &str = "2025-06-18"; +pub(super) const MCP_PROTOCOL_VERSION: &str = "2025-11-25"; +pub(super) const MCP_SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[MCP_PROTOCOL_VERSION, "2025-06-18"]; /// Result of decoding one newline-delimited MCP frame. pub(super) struct FrameAction { @@ -49,7 +50,8 @@ pub(super) fn response_for(message: &Value) -> Option { else { return Some(jsonrpc_error(id, -32602, "Missing protocolVersion")); }; - let protocol_version = if requested_protocol == MCP_PROTOCOL_VERSION { + let protocol_version = if MCP_SUPPORTED_PROTOCOL_VERSIONS.contains(&requested_protocol) + { requested_protocol } else { MCP_PROTOCOL_VERSION diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 7a83ef1fd..9840fa0ec 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -119,6 +119,54 @@ entrypoint = "plugin.py" .unwrap(); } +fn write_python_dynamic_plugin_manifest(dir: &std::path::Path, plugin_id: &str) { + std::fs::create_dir_all(dir).unwrap(); + let artifact_body = "def main():\n return None\n"; + std::fs::write(dir.join("plugin.py"), artifact_body).unwrap(); + let digest = format!( + "sha256:{}", + Sha256::digest(artifact_body.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + std::fs::write( + dir.join("relay-plugin.toml"), + format!( + r#"manifest_version = 1 + +[plugin] +id = {plugin_id} +kind = "worker" + +[compat] +relay = "0.5" +worker_protocol = "grpc-v1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker"] + +[source] +manifest_root = "." +artifact = "plugin.py" + +[integrity] +sha256 = {digest} + +[load] +runtime = "python" +entrypoint = "plugin:main" +"#, + plugin_id = toml_basic_string(plugin_id), + digest = toml_basic_string(&digest), + ), + ) + .unwrap(); +} + fn write_detached_ed25519_signature(dir: &std::path::Path, signature_name: &str) -> String { std::fs::create_dir_all(dir).unwrap(); let artifact = std::fs::read(dir.join("plugin.py")).unwrap(); @@ -1565,6 +1613,61 @@ fn cli_plugins_validate_json_emits_versioned_success_output() { assert_eq!(parsed["data"]["attestation_mode"], "integrity_only"); } +#[test] +fn cli_plugins_validate_rejects_malformed_python_entrypoints_by_path_and_id() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("workdir"); + let plugin_dir = cwd.join("plugins").join("acme"); + let config_dir = cwd.join(".nemo-relay"); + let plugin_id = "acme.invalid-python-entrypoint"; + std::fs::create_dir_all(&config_dir).unwrap(); + write_python_dynamic_plugin_manifest(&plugin_dir, plugin_id); + std::fs::write( + config_dir.join("plugins.toml"), + format!( + "[[plugins.dynamic]]\nmanifest = {}\n", + toml_basic_string(plugin_dir.to_string_lossy().as_ref()) + ), + ) + .unwrap(); + + let manifest_path = plugin_dir.join("relay-plugin.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + std::fs::write( + manifest_path, + manifest.replace("entrypoint = \"plugin:main\"", "entrypoint = \"plugin\""), + ) + .unwrap(); + + for target in [ + plugin_dir.to_string_lossy().into_owned(), + plugin_id.to_owned(), + ] { + let output = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args(["plugins", "validate", &target, "--json"]) + .output() + .unwrap(); + + assert!( + !output.status.success(), + "malformed Python entrypoint unexpectedly validated for {target}" + ); + let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(parsed["command"], "plugins validate"); + assert!( + parsed["error"]["message"] + .as_str() + .unwrap() + .contains("module:function form"), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + } +} + #[test] fn cli_plugins_list_json_emits_empty_versioned_success_output() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index 9b06d9ed4..011385529 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -1219,18 +1219,26 @@ fn concurrent_different_install_roots_share_the_global_host_lock() { #[test] fn plugin_operation_lock_acquires_an_aliased_global_and_install_root_once() { let root = tempdir().unwrap(); + let install_dir = root.path().join("new").join(".."); + let synchronization = tempdir().unwrap(); + let holder = CrossProcessLockHolder::spawn( + OPERATION_LOCK_HELPER_DIR_ENV, + &install_dir, + Some(root.path()), + synchronization.path(), + ); - let _lock = PluginOperationLock::acquire( + let Err(error) = PluginOperationLock::acquire( "codex", root.path(), &root.path().join("."), Duration::from_millis(75), - ) - .unwrap(); + ) else { + panic!("aliased lock acquisition unexpectedly succeeded"); + }; - assert!( - crate::installation::operation_lock::operation_lock_path("codex", root.path()).exists() - ); + assert!(error.contains("global lock"), "{error}"); + holder.release(); } #[test] diff --git a/crates/cli/tests/coverage/shared/mcp_tests.rs b/crates/cli/tests/coverage/shared/mcp_tests.rs index 7c4db75e0..bf84ab938 100644 --- a/crates/cli/tests/coverage/shared/mcp_tests.rs +++ b/crates/cli/tests/coverage/shared/mcp_tests.rs @@ -13,6 +13,7 @@ use super::*; use crate::installation::generation::{ GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration, write_new_generation, }; +use crate::mcp::protocol::MCP_SUPPORTED_PROTOCOL_VERSIONS; struct BootstrapConfigHome { _guard: std::sync::MutexGuard<'static, ()>, @@ -139,6 +140,32 @@ fn bounded_mcp_reader_rejects_one_oversized_unterminated_frame() { #[test] fn initialize_reports_native_server_and_supported_protocol() { + for protocol_version in MCP_SUPPORTED_PROTOCOL_VERSIONS { + let response = response_for(&json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "initialize", + "params": { "protocolVersion": protocol_version } + })) + .unwrap(); + + assert_eq!(response["jsonrpc"], json!("2.0")); + assert_eq!(response["id"], json!(7)); + assert_eq!( + response["result"]["protocolVersion"], + json!(protocol_version) + ); + assert_eq!(response["result"]["capabilities"], json!({})); + assert_eq!( + response["result"]["serverInfo"]["name"], + json!("nemo-relay") + ); + assert_eq!( + response["result"]["serverInfo"]["version"], + json!(env!("CARGO_PKG_VERSION")) + ); + } + let response = response_for(&json!({ "jsonrpc": "2.0", "id": 7, @@ -146,22 +173,10 @@ fn initialize_reports_native_server_and_supported_protocol() { "params": { "protocolVersion": "2024-11-05" } })) .unwrap(); - - assert_eq!(response["jsonrpc"], json!("2.0")); - assert_eq!(response["id"], json!(7)); assert_eq!( response["result"]["protocolVersion"], json!(MCP_PROTOCOL_VERSION) ); - assert_eq!(response["result"]["capabilities"], json!({})); - assert_eq!( - response["result"]["serverInfo"]["name"], - json!("nemo-relay") - ); - assert_eq!( - response["result"]["serverInfo"]["version"], - json!(env!("CARGO_PKG_VERSION")) - ); } #[test] diff --git a/scripts/README.md b/scripts/README.md index a2acd9b5f..c8fce82ab 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -18,6 +18,14 @@ Keep `scripts/` focused on helpers that are still script-native: - `test-install.ps1`: Run live GitHub release and local interface checks for the PowerShell CLI installer - `test-install-mocks.sh`: Run installer scenarios that require simulated platforms or failures +## Opt-In Coding-Agent E2E Tests + +These checks exercise installed coding-agent clients and are intentionally outside the default Rust and CI test suites. Run the recipe that matches an available local client: + +- `just test-codex-plugin-e2e` +- `just test-claude-plugin-e2e` +- `just test-hermes-mcp-e2e` + ## Internal Layout - `docs/`: Fern reference-generation, migration cleanup, and `docs-website` branch sync helpers. Generated API reference output under `docs/reference/api/*-library-reference/` is ignored and recreated by `just docs`. From ab7cf50c687969294f7768f005719a43ada6b95a Mon Sep 17 00:00:00 2001 From: Will Killian Date: Tue, 14 Jul 2026 09:53:02 -0400 Subject: [PATCH 096/102] test: eliminate bootstrap shutdown fixture race Signed-off-by: Will Killian --- crates/cli/tests/coverage/shared/bootstrap_state_tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs b/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs index 51a2e412d..c94580871 100644 --- a/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs +++ b/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs @@ -207,6 +207,9 @@ fn authenticated_owned_gateway_is_shut_down_and_cleaned_up() { header(&request, "x-nemo-relay-bootstrap-token"), "shutdown-token" ); + // Close the listener before acknowledging shutdown so the verifier's + // immediate health probe cannot race this fixture's teardown. + drop(listener); shutdown .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") .unwrap(); From 9a7dd8d76413443dd0ed14ca743c3e7005916b99 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Tue, 14 Jul 2026 12:46:08 -0400 Subject: [PATCH 097/102] test: wait for cancelled plugin initialization cleanup Signed-off-by: Will Killian --- crates/core/tests/unit/plugin_tests.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index e7e7dc969..fe70f1385 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -1155,6 +1155,17 @@ fn test_initialize_plugins_transaction_finishes_after_caller_cancellation() { }) .await .expect("owned initialization transaction did not finish after caller cancellation"); + + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if *PLUGIN_MUTATION_OWNER.lock().unwrap() == PluginMutationOwner::Idle { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("owned initialization transaction did not release its mutation lease"); }); reset_global(); From 2b1f17025da9b46a5ffadcc9fdb2f437a3fb4d2c Mon Sep 17 00:00:00 2001 From: Will Killian Date: Tue, 14 Jul 2026 15:55:49 -0400 Subject: [PATCH 098/102] test(ffi): isolate native plugin fixture builds Signed-off-by: Will Killian --- .../integration/plugin_activation_tests.rs | 102 ++++++++++-------- 1 file changed, 57 insertions(+), 45 deletions(-) diff --git a/crates/ffi/tests/integration/plugin_activation_tests.rs b/crates/ffi/tests/integration/plugin_activation_tests.rs index 5ecfb74a6..ab2be8e9f 100644 --- a/crates/ffi/tests/integration/plugin_activation_tests.rs +++ b/crates/ffi/tests/integration/plugin_activation_tests.rs @@ -17,6 +17,11 @@ static DISCOVERED_STATIC_REGISTRATIONS: AtomicUsize = AtomicUsize::new(0); static DISCOVERED_STATIC_CALLBACKS: AtomicUsize = AtomicUsize::new(0); static DISCOVERED_STATIC_CONFIG: Mutex> = Mutex::new(None); +struct NativeFixture { + _source_dir: TempDir, + library: PathBuf, +} + struct PluginDiscoveryTestEnv { previous_cwd: PathBuf, previous_xdg_config_home: Option, @@ -495,51 +500,58 @@ fn plugin_kinds() -> Vec { } fn build_native_fixture() -> &'static Path { - static FIXTURE: OnceLock = OnceLock::new(); - FIXTURE.get_or_init(|| { - let source_dir = TempDir::new().expect("native fixture source tempdir"); - let fixture_dir = source_dir.path().join("native_plugin"); - let source = fixture_dir.join("src"); - std::fs::create_dir_all(&source).expect("native fixture src dir"); - let plugin_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../plugin"); - let manifest_template = std::fs::read_to_string( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../core/tests/fixtures/native_plugin/Cargo.toml"), - ) - .expect("native fixture Cargo.toml"); - let manifest = manifest_template.replace( - r#"nemo-relay-plugin = { path = "../../../../plugin" }"#, - &format!("nemo-relay-plugin = {{ path = {plugin_path:?} }}"), - ); - std::fs::write(fixture_dir.join("Cargo.toml"), manifest) - .expect("write native fixture Cargo.toml"); - std::fs::copy( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../core/tests/fixtures/native_plugin/src/lib.rs"), - source.join("lib.rs"), - ) - .expect("copy native fixture source"); - - let target = - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/ffi-native-plugin-fixture"); - let status = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())) - .arg("build") - .arg("--quiet") - .arg("--manifest-path") - .arg(fixture_dir.join("Cargo.toml")) - .arg("--target-dir") - .arg(&target) - .status() - .expect("native fixture build should start"); - assert!(status.success(), "native fixture build failed: {status}"); - let library = target.join("debug").join(native_library_name()); - assert!( - library.exists(), - "missing native fixture: {}", - library.display() - ); - library - }) + static FIXTURE: OnceLock = OnceLock::new(); + &FIXTURE + .get_or_init(|| { + let source_dir = TempDir::new().expect("native fixture source tempdir"); + let fixture_dir = source_dir.path().join("native_plugin"); + let source = fixture_dir.join("src"); + std::fs::create_dir_all(&source).expect("native fixture src dir"); + let plugin_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../plugin"); + let manifest_template = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../core/tests/fixtures/native_plugin/Cargo.toml"), + ) + .expect("native fixture Cargo.toml"); + let manifest = manifest_template.replace( + r#"nemo-relay-plugin = { path = "../../../../plugin" }"#, + &format!("nemo-relay-plugin = {{ path = {plugin_path:?} }}"), + ); + std::fs::write(fixture_dir.join("Cargo.toml"), manifest) + .expect("write native fixture Cargo.toml"); + std::fs::copy( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../core/tests/fixtures/native_plugin/src/lib.rs"), + source.join("lib.rs"), + ) + .expect("copy native fixture source"); + + // Nextest runs each test in a separate process. Keep this process's + // generated crate and target directory together so parallel tests do + // not race on a shared fixture artifact. + let target = source_dir.path().join("target"); + let status = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())) + .arg("build") + .arg("--quiet") + .arg("--manifest-path") + .arg(fixture_dir.join("Cargo.toml")) + .arg("--target-dir") + .arg(&target) + .status() + .expect("native fixture build should start"); + assert!(status.success(), "native fixture build failed: {status}"); + let library = target.join("debug").join(native_library_name()); + assert!( + library.exists(), + "missing native fixture: {}", + library.display() + ); + NativeFixture { + _source_dir: source_dir, + library, + } + }) + .library } fn build_worker_fixture() -> &'static Path { From 8ccd903eff61201f68ed3880480786b252f35a66 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Tue, 14 Jul 2026 16:15:06 -0400 Subject: [PATCH 099/102] fix(cli): redact ATOF stream destinations Signed-off-by: Will Killian --- crates/cli/src/process/launcher.rs | 2 +- crates/cli/tests/coverage/agents/launcher_tests.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index 07dce4856..bb918d47b 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -611,7 +611,7 @@ fn observability_exporter_destinations(config: &ObservabilityConfig) -> Vec { - destinations.push(format!("ATOF {}", stream.url)); + destinations.push(format!("ATOF {}", sanitized_url(&stream.url))); } } } diff --git a/crates/cli/tests/coverage/agents/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs index c5bc88933..e9aa1e1d4 100644 --- a/crates/cli/tests/coverage/agents/launcher_tests.rs +++ b/crates/cli/tests/coverage/agents/launcher_tests.rs @@ -475,7 +475,7 @@ fn exporter_destinations_describe_observability_outputs() { }, { "type": "stream", - "url": "https://collector.example/atof" + "url": "https://user:secret@collector.example/atof?token=secret" } ] }, @@ -507,7 +507,7 @@ fn exporter_destinations_describe_observability_outputs() { assert!( destinations .iter() - .any(|line| line == "ATOF https://collector.example/atof") + .any(|line| line == "ATOF https://collector.example/atof?token=%5BREDACTED%5D") ); assert!(destinations.iter().any(|line| line == &format!( From 98518f844a2e1cf0e190ee029ec333d4f34c7a71 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Tue, 14 Jul 2026 18:44:33 -0400 Subject: [PATCH 100/102] test(cli): tolerate Windows refresh scheduling Signed-off-by: Will Killian --- crates/cli/tests/coverage/agents/plugin_install_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index 011385529..1f2ecc010 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -3177,7 +3177,7 @@ fn failed_force_refresh_hides_transient_generation_retirement_from_mcp() { install_host(CodingAgent::Codex, &options, &runner, &setup_runner) }); - entered_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); let (verified_tx, verified_rx) = std::sync::mpsc::channel(); let verifier = std::thread::spawn(move || verified_tx.send(previous.verify_current()).unwrap()); assert!( @@ -3191,7 +3191,7 @@ fn failed_force_refresh_hides_transient_generation_retirement_from_mcp() { let error = install.join().unwrap().unwrap_err(); assert!(error.contains("refresh gateway failed"), "{error}"); verified_rx - .recv_timeout(Duration::from_secs(1)) + .recv_timeout(Duration::from_secs(5)) .unwrap() .unwrap(); verifier.join().unwrap(); From 1a6a933e121edc8db6d1f9c68f6f7a428a42735c Mon Sep 17 00:00:00 2001 From: Will Killian Date: Tue, 14 Jul 2026 19:26:01 -0400 Subject: [PATCH 101/102] fix(cli): address gateway review feedback Signed-off-by: Will Killian --- crates/cli/src/diagnostics/mod.rs | 40 +- crates/cli/src/gateway/mod.rs | 477 ++---------------- crates/cli/src/gateway/routes.rs | 6 + crates/cli/src/plugins/config_io.rs | 22 +- crates/cli/src/server/mod.rs | 137 +++-- .../tests/coverage/shared/gateway_tests.rs | 167 ++++++ .../tests/coverage/shared/plugins_tests.rs | 16 +- 7 files changed, 333 insertions(+), 532 deletions(-) diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index fa7a61d95..c029768cb 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -27,12 +27,6 @@ use nemo_relay::api::event::{BaseEvent, Event, MarkEvent}; use nemo_relay::codec::model_pricing::{PricingCatalog, PricingConfig, PricingSourceConfig}; use nemo_relay::observability::plugin_component::OBSERVABILITY_PLUGIN_KIND; use nemo_relay::plugin::{DiagnosticLevel, PluginConfig, validate_plugin_config}; -use nemo_relay_adaptive::plugin_component::register_adaptive_component; -use nemo_relay_pii_redaction::component::register_pii_redaction_component; -#[cfg(feature = "switchyard")] -use nemo_relay_switchyard::{ - register_switchyard_component, validate_switchyard_atof_configuration, -}; use serde_json::{Value, json}; use tokio::time::timeout; use tokio_tungstenite::tungstenite::client::IntoClientRequest; @@ -44,7 +38,7 @@ use crate::configuration::{ default_plugin_config_paths, effective_plugin_toml_sources, resolve_server_config, }; use crate::error::CliError; -use crate::server::GatewayOverrides; +use crate::server::{GatewayOverrides, register_and_validate_plugin_components}; const NETWORK_TIMEOUT: Duration = Duration::from_secs(2); const PRICING_PLUGIN_KIND: &str = "pricing"; @@ -479,37 +473,11 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec { return checks; } }; - if let Err(error) = register_adaptive_component() { - checks.push(Check { - name: "Adaptive plugin", - status: Status::Fail, - details: format!("registration failed: {error}"), - }); - return checks; - } - if let Err(error) = register_pii_redaction_component() { - checks.push(Check { - name: "PII redaction plugin", - status: Status::Fail, - details: format!("registration failed: {error}"), - }); - return checks; - } - #[cfg(feature = "switchyard")] - if let Err(error) = register_switchyard_component() { - checks.push(Check { - name: "Switchyard plugin", - status: Status::Fail, - details: format!("registration failed: {error}"), - }); - return checks; - } - #[cfg(feature = "switchyard")] - if let Err(error) = validate_switchyard_atof_configuration(&plugin_config) { + if let Err(error) = register_and_validate_plugin_components(&plugin_config) { checks.push(Check { - name: "Switchyard ATOF", + name: error.check_name(), status: Status::Fail, - details: error, + details: error.diagnostic_details(), }); return checks; } diff --git a/crates/cli/src/gateway/mod.rs b/crates/cli/src/gateway/mod.rs index 71f4a5d4d..a9d39adbf 100644 --- a/crates/cli/src/gateway/mod.rs +++ b/crates/cli/src/gateway/mod.rs @@ -78,131 +78,6 @@ pub(crate) async fn passthrough( run_managed_gateway(state, prepared, prep).await } -/* Pre-refactor request helpers moved to `gateway::request`. -struct PreparedGatewayRequest { - method: Method, - headers: HeaderMap, - path: String, - provider: ProviderRoute, - upstream_url: String, - body_bytes: Bytes, - request_json: Value, - streaming: bool, -} - -// Validates the gateway route, buffers the request body exactly once, and derives the metadata used -// for both upstream forwarding and NeMo Relay LLM start events. Provider JSON parse failures are not -// request failures because the gateway still forwards raw bytes unchanged. -async fn prepare_gateway_request( - config: &crate::config::GatewayConfig, - request: Request, -) -> Result { - let (parts, body) = request.into_parts(); - let provider = ProviderRoute::from_path(parts.uri.path()).ok_or_else(|| { - CliError::InvalidPayload(format!("unsupported gateway path {}", parts.uri.path())) - })?; - let body_bytes = axum::body::to_bytes(body, config.max_passthrough_body_bytes) - .await - .map_err(passthrough_body_error)?; - let request_json = serde_json::from_slice::(&body_bytes).unwrap_or(Value::Null); - let path_and_query = parts - .uri - .path_and_query() - .map(|p| p.as_str()) - .unwrap_or(parts.uri.path()); - let upstream_url = gateway_upstream_url_override(provider, &parts.headers, path_and_query) - .unwrap_or_else(|| provider.upstream_url(config, path_and_query)); - let streaming = request_json - .get("stream") - .and_then(Value::as_bool) - .unwrap_or(false); - let mut headers = parts.headers; - strip_internal_dispatch_headers(&mut headers); - Ok(PreparedGatewayRequest { - method: parts.method, - headers, - path: parts.uri.path().to_string(), - provider, - upstream_url, - body_bytes, - request_json, - streaming, - }) -} - -fn passthrough_body_error(error: axum::Error) -> CliError { - if error.source().is_some_and(|source| { - source.is::() - || source - .source() - .is_some_and(|source| source.is::()) - }) { - CliError::PayloadTooLarge(error.to_string()) - } else { - CliError::InvalidPayload(error.to_string()) - } -} - -// Builds the [`LlmGatewayStart`] payload from a prepared request. Identifier resolution is shared -// across streaming and non-streaming paths so correlation behavior is consistent for every route. -// Provider-specific fallbacks are resolved here, before request execution leaves the gateway path, -// because the later runtime-managed LLM call only sees this normalized start payload. -fn build_llm_gateway_start(request: &PreparedGatewayRequest) -> LlmGatewayStart { - LlmGatewayStart { - // Explicit NeMo Relay headers still win, but alignment can recover agent-native session - // signals when available. Applies to Claude Code's session header and Codex's Responses - // prompt-cache thread id today. - session_id: gateway_session_id(&request.headers, &request.request_json, request.provider), - provider: request.provider.name().to_string(), - model_name: request - .request_json - .get("model") - .and_then(Value::as_str) - .map(ToOwned::to_owned), - // Subagent ownership is intentionally header-only at the gateway layer. Body fields can be - // provider payload content rather than scope identity, so the session layer handles other - // ownership hints. - subagent_id: gateway_subagent_id(&request.headers), - conversation_id: gateway_identifier( - &request.headers, - &request.request_json, - "x-nemo-relay-conversation-id", - &[ - &["conversation_id"], - &["conversationId"], - &["conversation", "id"], - ], - ), - generation_id: gateway_identifier( - &request.headers, - &request.request_json, - "x-nemo-relay-generation-id", - &[&["generation_id"], &["generationId"], &["generation", "id"]], - ), - request_id: gateway_identifier( - &request.headers, - &request.request_json, - "x-nemo-relay-request-id", - &[ - &["request_id"], - &["requestId"], - &["request", "id"], - &["metadata", "request_id"], - ], - ) - // Preserve a transport request id as a weak fallback for debugging even when the provider - // body does not expose an LLM request id. - .or_else(|| header_string(&request.headers, "x-request-id")), - request: LlmRequest { - headers: observable_headers(&request.headers), - content: request.request_json.clone(), - }, - streaming: request.streaming, - metadata: json!({ "gateway_path": request.path }), - } -} - -*/ // Captures upstream HTTP status and response headers from inside the managed `func`. The runtime's // LLM execution callback returns only a Json (or Json stream), so the outer gateway needs a side // channel to recover the bytes the client expects. @@ -732,22 +607,35 @@ impl GatewayCallGuard { .lock() .expect("stream final response lock poisoned") .take(); - if let Some(response) = response { - sessions - .record_gateway_response_hints( - &self.session_id, - self.owner_subagent_id.clone(), - response, - ) - .await; - } - sessions - .finish_gateway_call(&self.session_id, self.session_finish) - .await; + complete_gateway_call( + sessions, + self.session_id.clone(), + self.owner_subagent_id.clone(), + response, + self.session_finish, + ) + .await; } } } +async fn complete_gateway_call( + sessions: SessionManager, + session_id: String, + owner_subagent_id: Option, + response: Option, + session_finish: GatewaySessionFinish, +) { + if let Some(response) = response { + sessions + .record_gateway_response_hints(&session_id, owner_subagent_id, response) + .await; + } + sessions + .finish_gateway_call(&session_id, session_finish) + .await; +} + impl Drop for GatewayCallGuard { fn drop(&mut self) { let Some(sessions) = self.sessions.take() else { @@ -761,17 +649,21 @@ impl Drop for GatewayCallGuard { .lock() .expect("stream final response lock poisoned") .take(); + let cleanup = complete_gateway_call( + sessions, + session_id, + owner_subagent_id, + response, + session_finish, + ); if let Ok(handle) = tokio::runtime::Handle::try_current() { - handle.spawn(async move { - if let Some(response) = response { - sessions - .record_gateway_response_hints(&session_id, owner_subagent_id, response) - .await; - } - sessions - .finish_gateway_call(&session_id, session_finish) - .await; - }); + handle.spawn(cleanup); + } else { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("gateway cleanup runtime should build") + .block_on(cleanup); } } } @@ -897,12 +789,14 @@ fn effective_dispatch_request( }; let mut override_url = None; let mut override_route = None; + let mut dispatch_route_header_seen = false; for (name, value) in &request.headers { if name.eq_ignore_ascii_case(INTERNAL_DISPATCH_URL_HEADER) { override_url = json_header_string(value); continue; } if name.eq_ignore_ascii_case(INTERNAL_DISPATCH_ROUTE_HEADER) { + dispatch_route_header_seen = true; override_route = json_header_string(value) .and_then(|value| ProviderRoute::from_dispatch_override(&value)); continue; @@ -921,7 +815,11 @@ fn effective_dispatch_request( EffectiveUpstreamRequest { body_bytes, headers, - url: override_url.unwrap_or_else(|| url.to_string()), + url: if dispatch_route_header_seen && override_route.is_none() { + url.to_string() + } else { + override_url.unwrap_or_else(|| url.to_string()) + }, route: override_route.unwrap_or(route), } } @@ -1212,286 +1110,3 @@ pub(crate) async fn models( let bytes = upstream_response.bytes().await?; build_response(status, headers, Body::from(bytes)) } -/* Pre-refactor route and response helpers moved to `gateway::routes` and `gateway::response`. - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ProviderRoute { - OpenAiResponses, - OpenAiChatCompletions, - OpenAiModels, - AnthropicMessages, - AnthropicCountTokens, -} - -impl ProviderRoute { - // Maps public gateway paths to known upstream provider routes. Unsupported paths return `None` - // so the caller can fail as a bad hook/gateway payload instead of constructing arbitrary URLs. - fn from_path(path: &str) -> Option { - match path { - "/responses" => Some(Self::OpenAiResponses), - "/v1/responses" => Some(Self::OpenAiResponses), - "/chat/completions" => Some(Self::OpenAiChatCompletions), - "/v1/chat/completions" => Some(Self::OpenAiChatCompletions), - "/models" => Some(Self::OpenAiModels), - "/v1/models" => Some(Self::OpenAiModels), - "/v1/messages" => Some(Self::AnthropicMessages), - "/v1/messages/count_tokens" => Some(Self::AnthropicCountTokens), - _ => None, - } - } - - fn from_dispatch_override(value: &str) -> Option { - match value { - "openai_chat" - | "openai_chat_completions" - | "openai.chat_completions" - | "/v1/chat/completions" => Some(Self::OpenAiChatCompletions), - "openai_responses" | "openai.responses" | "/v1/responses" => { - Some(Self::OpenAiResponses) - } - "anthropic_messages" | "anthropic.messages" | "/v1/messages" => { - Some(Self::AnthropicMessages) - } - _ => None, - } - } - - const fn provider_surface(self) -> Option { - match self { - Self::OpenAiResponses => Some(ProviderSurface::OpenAIResponses), - Self::OpenAiChatCompletions => Some(ProviderSurface::OpenAIChat), - Self::AnthropicMessages => Some(ProviderSurface::AnthropicMessages), - Self::AnthropicCountTokens | Self::OpenAiModels => None, - } - } - - // Returns the provider route name recorded on managed LLM events. These names split OpenAI API - // variants because their request/response schemas differ even when they share a base URL, and - // they double as codec hints for ambiguous provider request shapes. - const fn name(self) -> &'static str { - self.alignment_route().name() - } - - // Builds the upstream URL by combining the configured provider base with the original path and - // query string. Trailing slashes are stripped from the base to avoid double-slash variants in - // configured enterprise or local proxy endpoints. - fn upstream_url(self, config: &crate::config::GatewayConfig, path_and_query: &str) -> String { - let base = match self { - Self::OpenAiResponses | Self::OpenAiChatCompletions | Self::OpenAiModels => { - config.openai_base_url.as_str() - } - Self::AnthropicMessages | Self::AnthropicCountTokens => { - config.anthropic_base_url.as_str() - } - }; - self.upstream_url_with_base(base, path_and_query) - } - - // Like `upstream_url` but with an explicit base URL. This keeps OpenAI `/v1` normalization in - // one place for configured public, enterprise, or local proxy bases. - fn upstream_url_with_base(self, base: &str, path_and_query: &str) -> String { - let base = base.trim_end_matches('/'); - let path_and_query = match self { - Self::OpenAiResponses | Self::OpenAiChatCompletions | Self::OpenAiModels => { - normalize_openai_path_for_base(base, path_and_query) - } - _ => path_and_query.to_string(), - }; - format!("{base}{path_and_query}") - } - - // Narrows gateway routing to the smaller taxonomy used by trace alignment. Keeping this - // conversion here prevents provider-specific alignment code from depending on gateway URL - // routing internals. - const fn alignment_route(self) -> GatewayRouteKind { - match self { - Self::OpenAiResponses => GatewayRouteKind::OpenAiResponses, - Self::OpenAiChatCompletions => GatewayRouteKind::OpenAiChatCompletions, - Self::OpenAiModels => GatewayRouteKind::OpenAiModels, - Self::AnthropicMessages => GatewayRouteKind::AnthropicMessages, - Self::AnthropicCountTokens => GatewayRouteKind::AnthropicCountTokens, - } - } -} - -fn normalize_openai_path_for_base(base: &str, path_and_query: &str) -> String { - match (base.ends_with("/v1"), path_and_query.starts_with("/v1/")) { - (true, true) => path_and_query - .strip_prefix("/v1") - .expect("path was checked to start with /v1") - .to_string(), - (false, false) => format!("/v1{path_and_query}"), - _ => path_and_query.to_string(), - } -} - -// Gives alignment adapters a chance to choose an agent-native upstream before default provider -// routing runs. Today this supports Codex ChatGPT auth; future harness fallbacks should stay in -// alignment rather than adding provider-shaped checks here. -fn gateway_upstream_url_override( - route: ProviderRoute, - headers: &HeaderMap, - path_and_query: &str, -) -> Option { - gateway_upstream_url_override_with_openai_key_state( - route, - headers, - path_and_query, - env_var_is_nonempty("OPENAI_API_KEY"), - ) -} - -fn gateway_upstream_url_override_with_openai_key_state( - route: ProviderRoute, - headers: &HeaderMap, - path_and_query: &str, - has_openai_replacement_key: bool, -) -> Option { - alignment::gateway_upstream_url_override( - headers, - route.alignment_route(), - path_and_query, - has_openai_replacement_key, - ) -} - -// Lets alignment adapters strip agent-native credentials only when the gateway can replace them -// with standard provider API keys. Whitespace-only env vars are treated as missing because -// forwarding an empty bearer value only replaces one authentication failure with another. -fn strip_replaceable_agent_auth_headers(headers: &HeaderMap, route: ProviderRoute) -> HeaderMap { - strip_replaceable_agent_auth_headers_with_openai_key_state( - headers, - route, - env_var_is_nonempty("OPENAI_API_KEY"), - ) -} - -fn strip_replaceable_agent_auth_headers_with_openai_key_state( - headers: &HeaderMap, - route: ProviderRoute, - has_openai_replacement_key: bool, -) -> HeaderMap { - alignment::gateway_forward_headers(headers, route.alignment_route(), has_openai_replacement_key) -} - -fn env_var_is_nonempty(name: &str) -> bool { - std::env::var(name) - .ok() - .filter(|value| !value.trim().is_empty()) - .is_some() -} - -// Delegates provider-specific session fallbacks to `alignment` so request construction stays -// generic and each coding-agent quirk has one documented adapter. -fn gateway_session_id(headers: &HeaderMap, body: &Value, route: ProviderRoute) -> Option { - alignment::gateway_session_id(headers, body, route.alignment_route()) -} - -fn gateway_subagent_id(headers: &HeaderMap) -> Option { - alignment::gateway_subagent_id(headers) -} - -// Keeps the gateway-facing helper local for tests while the generic extraction pattern lives in -// `alignment`. -fn gateway_identifier( - headers: &HeaderMap, - body: &Value, - header_name: &'static str, - body_paths: &[&[&str]], -) -> Option { - alignment::gateway_identifier(headers, body, header_name, body_paths) -} - -// Copies only non-sensitive, forwardable request headers into LLM request metadata. This preserves -// correlation headers while excluding credentials and hop-by-hop transport details. -fn observable_headers(headers: &HeaderMap) -> Map { - let mut output = Map::new(); - for (name, value) in headers { - if should_record_header(name) - && let Ok(value) = value.to_str() - { - output.insert(name.as_str().to_string(), json!(value)); - } - } - output -} - -// Copies upstream response headers except hop-by-hop transport headers that Axum/hyper must manage -// for the downstream connection. Multiple values are appended to preserve provider behavior. -// Content-Length is also dropped because the gateway re-encodes streaming responses and the -// upstream-reported length will not match the bytes the client sees. -fn response_headers(headers: &HeaderMap) -> HeaderMap { - let mut output = HeaderMap::new(); - for (name, value) in headers { - if !is_hop_by_hop(name) && name != http::header::CONTENT_LENGTH { - output.append(name.clone(), value.clone()); - } - } - output -} - -// Reconstructs an Axum response from upstream status, filtered headers, and the selected body. All -// builder errors are converted into gateway HTTP errors rather than panics. -fn build_response( - status: StatusCode, - headers: HeaderMap, - body: Body, -) -> Result, CliError> { - let mut builder = Response::builder().status(status); - for (name, value) in &headers { - builder = builder.header(name, value); - } - Ok(builder.body(body)?) -} - -// Allows provider request headers through unless they are transport-owned or must be recalculated -// for the forwarded body. Host and content length are intentionally excluded because reqwest sets -// them for the upstream connection. -fn should_forward_request_header(name: &HeaderName) -> bool { - !is_hop_by_hop(name) - && name != http::header::HOST - && name != http::header::CONTENT_LENGTH - // Strip Accept-Encoding so upstreams return identity-encoded bodies; otherwise the - // observability capture (`output.value` on LLM spans, ATIF trajectory bodies) records - // gzip/br/zstd bytes that downstream consumers can't read. Bandwidth cost is paid only - // on the gateway-upstream hop. The client never asked for the encoding it would have - // received from upstream, so its decoders never trigger. - && name != http::header::ACCEPT_ENCODING -} - -// Allows headers into observability metadata only after removing credentials and provider API keys. -// The forwarding filter runs first so hop-by-hop transport headers are also excluded from recorded -// LLM request attributes. The credential blocklist covers the four canonical cases we see in -// practice: `Authorization` (most providers), `Cookie` (session credentials), `x-api-key` (OpenAI -// SDK and similar), `anthropic-api-key` (Anthropic), and the generic `api-key` alias used by some -// providers/proxies (e.g., Azure OpenAI). `HeaderName::as_str()` already returns the canonical -// lowercase form so string comparisons are case-insensitive by construction. -fn should_record_header(name: &HeaderName) -> bool { - should_forward_request_header(name) - && name != http::header::AUTHORIZATION - && name != http::header::COOKIE - && name.as_str() != "x-api-key" - && name.as_str() != "api-key" - && name.as_str() != "anthropic-api-key" -} - -// Identifies headers that describe a single transport hop and therefore must not be proxied across -// the client-gateway-upstream boundary. -fn is_hop_by_hop(name: &HeaderName) -> bool { - matches!( - name.as_str(), - "connection" - | "keep-alive" - | "proxy-authenticate" - | "proxy-authorization" - | "te" - | "trailer" - | "transfer-encoding" - | "upgrade" - ) -} - -#[cfg(test)] -#[path = "../tests/coverage/gateway_tests.rs"] -mod tests; -*/ diff --git a/crates/cli/src/gateway/routes.rs b/crates/cli/src/gateway/routes.rs index 7a4255a76..08fb44c70 100644 --- a/crates/cli/src/gateway/routes.rs +++ b/crates/cli/src/gateway/routes.rs @@ -55,9 +55,15 @@ impl ProviderRoute { "openai_responses" | "openai.responses" | "/v1/responses" => { Some(Self::OpenAiResponses) } + "openai_models" | "openai.models" | "/models" | "/v1/models" => { + Some(Self::OpenAiModels) + } "anthropic_messages" | "anthropic.messages" | "/v1/messages" => { Some(Self::AnthropicMessages) } + "anthropic_count_tokens" | "anthropic.count_tokens" | "/v1/messages/count_tokens" => { + Some(Self::AnthropicCountTokens) + } _ => None, } } diff --git a/crates/cli/src/plugins/config_io.rs b/crates/cli/src/plugins/config_io.rs index 631756293..bb0b997c5 100644 --- a/crates/cli/src/plugins/config_io.rs +++ b/crates/cli/src/plugins/config_io.rs @@ -7,12 +7,6 @@ use std::path::{Path, PathBuf}; use console::style; use nemo_relay::plugin::{ConfigPolicy, PluginConfig, validate_plugin_config}; -use nemo_relay_adaptive::plugin_component::register_adaptive_component; -use nemo_relay_pii_redaction::component::register_pii_redaction_component; -#[cfg(feature = "switchyard")] -use nemo_relay_switchyard::{ - register_switchyard_component, validate_switchyard_atof_configuration, -}; use serde::Serialize; use serde_json::{Map, Value}; @@ -21,6 +15,7 @@ use crate::configuration::{ }; use crate::error::CliError; use crate::plugins::ConfigurationScope; +use crate::server::register_and_validate_plugin_components; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TargetScope { @@ -707,19 +702,8 @@ fn print_rendered_preview(rendered: &str) -> Result<(), CliError> { } pub(crate) fn validate_config(config: &PluginConfig) -> Result<(), CliError> { - register_adaptive_component().map_err(|error| { - CliError::Config(format!("adaptive plugin registration failed: {error}")) - })?; - register_pii_redaction_component().map_err(|error| { - CliError::Config(format!("PII redaction plugin registration failed: {error}")) - })?; - #[cfg(feature = "switchyard")] - register_switchyard_component().map_err(|error| { - CliError::Config(format!("Switchyard plugin registration failed: {error}")) - })?; - #[cfg(feature = "switchyard")] - validate_switchyard_atof_configuration(config) - .map_err(|error| CliError::Config(format!("Switchyard ATOF validation failed: {error}")))?; + register_and_validate_plugin_components(config) + .map_err(|error| CliError::Config(error.to_string()))?; let report = validate_plugin_config(config); if report.has_errors() { let messages = report diff --git a/crates/cli/src/server/mod.rs b/crates/cli/src/server/mod.rs index 65e8c9e2d..12dc440ad 100644 --- a/crates/cli/src/server/mod.rs +++ b/crates/cli/src/server/mod.rs @@ -737,6 +737,81 @@ impl ServerPluginActivation { } } +#[derive(Debug)] +pub(crate) enum PluginComponentSetupError { + Adaptive(String), + PiiRedaction(String), + #[cfg(feature = "switchyard")] + Switchyard(String), + #[cfg(feature = "switchyard")] + SwitchyardAtof(String), +} + +impl PluginComponentSetupError { + pub(crate) const fn check_name(&self) -> &'static str { + match self { + Self::Adaptive(_) => "Adaptive plugin", + Self::PiiRedaction(_) => "PII redaction plugin", + #[cfg(feature = "switchyard")] + Self::Switchyard(_) => "Switchyard plugin", + #[cfg(feature = "switchyard")] + Self::SwitchyardAtof(_) => "Switchyard ATOF", + } + } + + pub(crate) fn diagnostic_details(&self) -> String { + match self { + Self::Adaptive(error) | Self::PiiRedaction(error) => { + format!("registration failed: {error}") + } + #[cfg(feature = "switchyard")] + Self::Switchyard(error) => format!("registration failed: {error}"), + #[cfg(feature = "switchyard")] + Self::SwitchyardAtof(error) => error.clone(), + } + } +} + +impl std::fmt::Display for PluginComponentSetupError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Adaptive(error) => { + write!(formatter, "adaptive plugin registration failed: {error}") + } + Self::PiiRedaction(error) => { + write!( + formatter, + "PII redaction plugin registration failed: {error}" + ) + } + #[cfg(feature = "switchyard")] + Self::Switchyard(error) => { + write!(formatter, "Switchyard plugin registration failed: {error}") + } + #[cfg(feature = "switchyard")] + Self::SwitchyardAtof(error) => { + write!(formatter, "Switchyard ATOF validation failed: {error}") + } + } + } +} + +pub(crate) fn register_and_validate_plugin_components( + _plugin_config: &PluginConfig, +) -> Result<(), PluginComponentSetupError> { + register_adaptive_component() + .map_err(|error| PluginComponentSetupError::Adaptive(error.to_string()))?; + register_pii_redaction_component() + .map_err(|error| PluginComponentSetupError::PiiRedaction(error.to_string()))?; + #[cfg(feature = "switchyard")] + register_switchyard_component() + .map_err(|error| PluginComponentSetupError::Switchyard(error.to_string()))?; + #[cfg(feature = "switchyard")] + validate_switchyard_atof_configuration(_plugin_config) + .map_err(PluginComponentSetupError::SwitchyardAtof)?; + Ok(()) +} + async fn initialize_plugin_host( config: Option, dynamic_plugins: Vec, @@ -745,25 +820,13 @@ async fn initialize_plugin_host( return Ok(None); } if dynamic_plugins.is_empty() { - register_adaptive_component().map_err(|error| { - CliError::Config(format!("adaptive plugin registration failed: {error}")) - })?; - register_pii_redaction_component().map_err(|error| { - CliError::Config(format!("PII redaction plugin registration failed: {error}")) - })?; - #[cfg(feature = "switchyard")] - register_switchyard_component().map_err(|error| { - CliError::Config(format!("Switchyard plugin registration failed: {error}")) - })?; let plugin_config: PluginConfig = config .map(serde_json::from_value) .transpose() .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))? .unwrap_or_default(); - #[cfg(feature = "switchyard")] - validate_switchyard_atof_configuration(&plugin_config).map_err(|error| { - CliError::Config(format!("Switchyard ATOF validation failed: {error}")) - })?; + register_and_validate_plugin_components(&plugin_config) + .map_err(|error| CliError::Config(error.to_string()))?; initialize_plugins_exact(plugin_config) .await .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; @@ -795,16 +858,21 @@ impl PluginActivation { _snapshots: Vec::new(), }); }; - register_adaptive_component().map_err(|error| { - CliError::Config(format!("adaptive plugin registration failed: {error}")) - })?; - register_pii_redaction_component().map_err(|error| { - CliError::Config(format!("PII redaction plugin registration failed: {error}")) - })?; - #[cfg(feature = "switchyard")] - register_switchyard_component().map_err(|error| { - CliError::Config(format!("Switchyard plugin registration failed: {error}")) - })?; + // Gateway already resolved its config; activate exactly (no re-discovery). + let mut plugin_config: PluginConfig = match config { + Some(config) => serde_json::from_value(config) + .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))?, + None => PluginConfig::default(), + }; + plugin_config + .components + .extend(dynamic_plugins.iter().map(|plugin| PluginComponentSpec { + kind: plugin.plugin_id.clone(), + enabled: true, + config: plugin.config.clone(), + })); + register_and_validate_plugin_components(&plugin_config) + .map_err(|error| CliError::Config(error.to_string()))?; for plugin in &dynamic_plugins { if let Some(snapshot) = plugin.activation_snapshot.as_ref() { snapshot.verify_current()?; @@ -884,27 +952,6 @@ impl PluginActivation { 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, - }), - ); - #[cfg(feature = "switchyard")] - validate_switchyard_atof_configuration(&plugin_config).map_err(|error| { - CliError::Config(format!("Switchyard ATOF validation failed: {error}")) - })?; initialize_plugins_exact(plugin_config) .await .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; diff --git a/crates/cli/tests/coverage/shared/gateway_tests.rs b/crates/cli/tests/coverage/shared/gateway_tests.rs index 9847cd07f..a55a5f738 100644 --- a/crates/cli/tests/coverage/shared/gateway_tests.rs +++ b/crates/cli/tests/coverage/shared/gateway_tests.rs @@ -159,6 +159,28 @@ fn selects_provider_routes() { assert_eq!(ProviderRoute::from_path("/unsupported"), None); } +#[test] +fn dispatch_override_routes_cover_models_and_count_tokens() { + for alias in ["openai_models", "openai.models", "/models", "/v1/models"] { + assert_eq!( + ProviderRoute::from_dispatch_override(alias), + Some(ProviderRoute::OpenAiModels), + "alias {alias}" + ); + } + for alias in [ + "anthropic_count_tokens", + "anthropic.count_tokens", + "/v1/messages/count_tokens", + ] { + assert_eq!( + ProviderRoute::from_dispatch_override(alias), + Some(ProviderRoute::AnthropicCountTokens), + "alias {alias}" + ); + } +} + #[test] fn provider_route_names_round_trip_through_alignment_routes() { for route in [ @@ -391,6 +413,70 @@ fn internal_dispatch_controls_are_consumed_and_never_forwarded() { assert!(retry_aware_dispatch(&request)); } +#[test] +fn malformed_dispatch_route_discards_the_override_url() { + let original_body = Bytes::from_static(br#"{"model":"original"}"#); + let request = LlmRequest { + headers: Map::from_iter([ + ( + INTERNAL_DISPATCH_URL_HEADER.to_string(), + json!("http://127.0.0.1:9000/v1/models"), + ), + ( + INTERNAL_DISPATCH_ROUTE_HEADER.to_string(), + json!("not-a-provider-route"), + ), + ]), + content: Value::Null, + }; + + let effective = effective_dispatch_request( + &original_body, + &HeaderMap::new(), + Some(&request), + "http://default.invalid/v1/chat/completions", + ProviderRoute::OpenAiChatCompletions, + ); + + assert_eq!(effective.url, "http://default.invalid/v1/chat/completions"); + assert_eq!(effective.route, ProviderRoute::OpenAiChatCompletions); + assert!( + effective + .headers + .get(INTERNAL_DISPATCH_URL_HEADER) + .is_none() + ); + assert!( + effective + .headers + .get(INTERNAL_DISPATCH_ROUTE_HEADER) + .is_none() + ); +} + +#[test] +fn dispatch_url_without_a_route_remains_supported() { + let original_body = Bytes::from_static(br#"{"model":"original"}"#); + let request = LlmRequest { + headers: Map::from_iter([( + INTERNAL_DISPATCH_URL_HEADER.to_string(), + json!("http://127.0.0.1:9000/v1/chat/completions"), + )]), + content: Value::Null, + }; + + let effective = effective_dispatch_request( + &original_body, + &HeaderMap::new(), + Some(&request), + "http://default.invalid/v1/chat/completions", + ProviderRoute::OpenAiChatCompletions, + ); + + assert_eq!(effective.url, "http://127.0.0.1:9000/v1/chat/completions"); + assert_eq!(effective.route, ProviderRoute::OpenAiChatCompletions); +} + #[test] fn structured_upstream_failure_classification_matches_retry_policy() { let mut headers = HeaderMap::new(); @@ -1163,6 +1249,87 @@ async fn streaming_gateway_call_guard_finishes_when_body_is_dropped() { assert_eq!(closed, 1); } +#[test] +fn streaming_gateway_call_guard_finishes_without_a_current_runtime() { + let subscriber_name = "gateway-no-runtime-drop-test"; + let _ = nemo_relay::api::subscriber::deregister_subscriber(subscriber_name); + let captured_output = Arc::new(Mutex::new(None::)); + let captured = captured_output.clone(); + nemo_relay::api::subscriber::register_subscriber( + subscriber_name, + Arc::new(move |event| { + if event.scope_category() == Some(nemo_relay::api::event::ScopeCategory::End) + && event.name() == "codex-turn" + && event + .metadata() + .and_then(|metadata| metadata.get("session_id")) + .and_then(Value::as_str) + == Some("stream-no-runtime") + { + *captured.lock().unwrap() = event.output().cloned(); + } + }), + ) + .unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (manager, prep) = runtime.block_on(async { + let manager = SessionManager::new(GatewayConfig::default()); + let prep = manager + .prepare_gateway_call( + &HeaderMap::new(), + LlmGatewayStart { + session_id: Some("stream-no-runtime".into()), + provider: "openai.responses".into(), + model_name: Some("gpt-test".into()), + subagent_id: None, + conversation_id: None, + generation_id: None, + request_id: None, + request: LlmRequest { + headers: Map::new(), + content: json!({ "input": "Record a final response without a runtime." }), + }, + streaming: true, + metadata: json!({}), + }, + ) + .await + .unwrap(); + (manager, prep) + }); + let final_response = json!({ "output_text": "streamed final" }); + let stream: LlmJsonStream = Box::pin(futures_util::stream::pending::< + std::result::Result, + >()); + let body = client_sse_body( + stream, + ProviderRoute::OpenAiResponses, + manager.clone(), + prep.session_id, + prep.owner_subagent_id, + Arc::new(Mutex::new(Some(final_response.clone()))), + prep.session_finish, + ); + + drop(body); + + let closed = runtime + .block_on(manager.close_idle_sessions_at( + std::time::Instant::now() + std::time::Duration::from_secs(1), + std::time::Duration::from_millis(1), + "idle_timeout", + )) + .unwrap(); + assert_eq!(closed, 1); + nemo_relay::api::subscriber::flush_subscribers().unwrap(); + assert_eq!(*captured_output.lock().unwrap(), Some(final_response)); + nemo_relay::api::subscriber::deregister_subscriber(subscriber_name).unwrap(); +} + #[tokio::test] async fn streaming_body_records_final_response_for_turn_output() { let subscriber_name = "gateway-stream-final-response-turn-output-test"; diff --git a/crates/cli/tests/coverage/shared/plugins_tests.rs b/crates/cli/tests/coverage/shared/plugins_tests.rs index 55b0da319..cb4e3b9f2 100644 --- a/crates/cli/tests/coverage/shared/plugins_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_tests.rs @@ -2469,8 +2469,22 @@ fn tagged_unions_support_list_items_and_top_level_fields() { field.tagged_union.map(|metadata| metadata.discriminator), Some("kind") ); + let list = EditorFieldSpec { + name: "backends", + label: "Backends", + kind: EditorFieldKind::List, + enum_values: &[], + optional: false, + nested_schema: None, + nested_default: None, + list_item: Some(&TAGGED_LIST_ITEM), + tagged_union: None, + }; assert_eq!( - editor_item_label(&json!({ "kind": "example" }), &TAGGED_LIST_ITEM), + editor_item_label( + &json!({ "kind": "example" }), + list.list_item.expect("tagged list item metadata"), + ), "example" ); } From 38511b0b0f143c6c218f9ddfac53ffdcb7eb4d90 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Tue, 14 Jul 2026 21:04:16 -0400 Subject: [PATCH 102/102] fix(cli): report all plugin setup diagnostics Signed-off-by: Will Killian --- crates/cli/src/diagnostics/mod.rs | 7 ++--- crates/cli/src/plugins/config_io.rs | 8 ++++-- crates/cli/src/server/mod.rs | 41 +++++++++++++++++++---------- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index c029768cb..a5b7ab736 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -473,12 +473,13 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec { return checks; } }; - if let Err(error) = register_and_validate_plugin_components(&plugin_config) { - checks.push(Check { + let component_errors = register_and_validate_plugin_components(&plugin_config); + if !component_errors.is_empty() { + checks.extend(component_errors.into_iter().map(|error| Check { name: error.check_name(), status: Status::Fail, details: error.diagnostic_details(), - }); + })); return checks; } let report = validate_plugin_config(&plugin_config); diff --git a/crates/cli/src/plugins/config_io.rs b/crates/cli/src/plugins/config_io.rs index bb0b997c5..226ba112d 100644 --- a/crates/cli/src/plugins/config_io.rs +++ b/crates/cli/src/plugins/config_io.rs @@ -702,8 +702,12 @@ fn print_rendered_preview(rendered: &str) -> Result<(), CliError> { } pub(crate) fn validate_config(config: &PluginConfig) -> Result<(), CliError> { - register_and_validate_plugin_components(config) - .map_err(|error| CliError::Config(error.to_string()))?; + if let Some(error) = register_and_validate_plugin_components(config) + .into_iter() + .next() + { + return Err(CliError::Config(error.to_string())); + } let report = validate_plugin_config(config); if report.has_errors() { let messages = report diff --git a/crates/cli/src/server/mod.rs b/crates/cli/src/server/mod.rs index 12dc440ad..313f6ef65 100644 --- a/crates/cli/src/server/mod.rs +++ b/crates/cli/src/server/mod.rs @@ -798,18 +798,23 @@ impl std::fmt::Display for PluginComponentSetupError { pub(crate) fn register_and_validate_plugin_components( _plugin_config: &PluginConfig, -) -> Result<(), PluginComponentSetupError> { - register_adaptive_component() - .map_err(|error| PluginComponentSetupError::Adaptive(error.to_string()))?; - register_pii_redaction_component() - .map_err(|error| PluginComponentSetupError::PiiRedaction(error.to_string()))?; +) -> Vec { + let mut errors = Vec::new(); + if let Err(error) = register_adaptive_component() { + errors.push(PluginComponentSetupError::Adaptive(error.to_string())); + } + if let Err(error) = register_pii_redaction_component() { + errors.push(PluginComponentSetupError::PiiRedaction(error.to_string())); + } #[cfg(feature = "switchyard")] - register_switchyard_component() - .map_err(|error| PluginComponentSetupError::Switchyard(error.to_string()))?; + if let Err(error) = register_switchyard_component() { + errors.push(PluginComponentSetupError::Switchyard(error.to_string())); + } #[cfg(feature = "switchyard")] - validate_switchyard_atof_configuration(_plugin_config) - .map_err(PluginComponentSetupError::SwitchyardAtof)?; - Ok(()) + if let Err(error) = validate_switchyard_atof_configuration(_plugin_config) { + errors.push(PluginComponentSetupError::SwitchyardAtof(error)); + } + errors } async fn initialize_plugin_host( @@ -825,8 +830,12 @@ async fn initialize_plugin_host( .transpose() .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))? .unwrap_or_default(); - register_and_validate_plugin_components(&plugin_config) - .map_err(|error| CliError::Config(error.to_string()))?; + if let Some(error) = register_and_validate_plugin_components(&plugin_config) + .into_iter() + .next() + { + return Err(CliError::Config(error.to_string())); + } initialize_plugins_exact(plugin_config) .await .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; @@ -871,8 +880,12 @@ impl PluginActivation { enabled: true, config: plugin.config.clone(), })); - register_and_validate_plugin_components(&plugin_config) - .map_err(|error| CliError::Config(error.to_string()))?; + if let Some(error) = register_and_validate_plugin_components(&plugin_config) + .into_iter() + .next() + { + return Err(CliError::Config(error.to_string())); + } for plugin in &dynamic_plugins { if let Some(snapshot) = plugin.activation_snapshot.as_ref() { snapshot.verify_current()?;