From bdd74d3b5ee0a1737d8de05751a3206402dce76c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 20:27:57 +0000 Subject: [PATCH 01/14] Initial plan From 391ce076c23cd947b7e8645ac0dd0b4ba0cf515f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 20:34:27 +0000 Subject: [PATCH 02/14] Add /config REST endpoint to fetch agent configuration Co-authored-by: robot-ranger <93950632+robot-ranger@users.noreply.github.com> --- src/mtconnect/sink/rest_sink/rest_service.cpp | 82 +++++++++++++++++++ src/mtconnect/sink/rest_sink/rest_service.hpp | 2 + 2 files changed, 84 insertions(+) diff --git a/src/mtconnect/sink/rest_sink/rest_service.cpp b/src/mtconnect/sink/rest_sink/rest_service.cpp index 1f01a8445..709c0d86a 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.cpp +++ b/src/mtconnect/sink/rest_sink/rest_service.cpp @@ -118,6 +118,7 @@ namespace mtconnect { createAssetRoutings(); createProbeRoutings(); createPutObservationRoutings(); + createConfigRoutings(); createFileRoutings(); m_server->addCommands(); @@ -435,6 +436,87 @@ namespace mtconnect { session->writeResponse(std::move(response)); } + void RestService::createConfigRoutings() + { + using namespace rest_sink; + auto handler = [&](SessionPtr session, const RequestPtr request) -> bool { + auto pretty = request->parameter("pretty").value_or(false); + + // Build JSON response with agent configuration + std::stringstream json; + json << "{"; + + bool first = true; + for (const auto& [key, value] : m_options) + { + if (!first) + json << ","; + first = false; + + json << "\"" << key << "\":"; + + // Handle different variant types + std::visit([&json](auto&& arg) { + using T = std::decay_t; + if constexpr (std::is_same_v) + { + json << "null"; + } + else if constexpr (std::is_same_v) + { + json << (arg ? "true" : "false"); + } + else if constexpr (std::is_same_v) + { + json << arg; + } + else if constexpr (std::is_same_v) + { + json << arg; + } + else if constexpr (std::is_same_v) + { + json << "\"" << arg << "\""; + } + else if constexpr (std::is_same_v) + { + json << arg.count(); + } + else if constexpr (std::is_same_v) + { + json << arg.count(); + } + else if constexpr (std::is_same_v) + { + json << "["; + bool firstItem = true; + for (const auto& item : arg) + { + if (!firstItem) + json << ","; + firstItem = false; + json << "\"" << item << "\""; + } + json << "]"; + } + }, value); + } + + json << "}"; + + ResponsePtr response = make_unique( + rest_sink::status::ok, json.str(), "application/json"); + respond(session, std::move(response), request->m_requestId); + return true; + }; + + m_server + ->addRouting({boost::beast::http::verb::get, "/config?pretty={bool:false}", handler}) + .document("Agent configuration request", + "Returns the current agent configuration as JSON") + .command("config"); + } + void RestService::createFileRoutings() { using namespace rest_sink; diff --git a/src/mtconnect/sink/rest_sink/rest_service.hpp b/src/mtconnect/sink/rest_sink/rest_service.hpp index 17437f2fb..5c317bf89 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.hpp +++ b/src/mtconnect/sink/rest_sink/rest_service.hpp @@ -346,6 +346,8 @@ namespace mtconnect { void createAssetRoutings(); + void createConfigRoutings(); + // Current Data Collection std::string fetchCurrentData(const printer::Printer *printer, const FilterSetOpt &filterSet, const std::optional &at, bool pretty = false, From 4ea86a378c44ae08c6a9696b10261686e2df1631 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 20:36:26 +0000 Subject: [PATCH 03/14] Add test for /config endpoint Co-authored-by: robot-ranger <93950632+robot-ranger@users.noreply.github.com> --- test_package/agent_test.cpp | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test_package/agent_test.cpp b/test_package/agent_test.cpp index 5039741e1..e98fdd537 100644 --- a/test_package/agent_test.cpp +++ b/test_package/agent_test.cpp @@ -2622,3 +2622,29 @@ TEST_F(AgentTest, should_initialize_observaton_to_initial_value_when_available) ASSERT_XML_PATH_EQUAL(doc, "//m:DeviceStream//m:PartCount", "0"); } } + +TEST_F(AgentTest, should_return_agent_configuration) +{ + m_agentTestHelper->createAgent("/samples/test_config.xml", 8, 4, "2.6", 4, false); + + // Test the /config endpoint + auto session = m_agentTestHelper->m_server->createSession(m_agentTestHelper->m_context); + auto request = make_unique("/config", boost::beast::http::verb::get); + + auto routing = m_agentTestHelper->m_server->getRouting(request->m_path, request->m_verb); + ASSERT_TRUE(routing); + + routing->m_handler(session, std::move(request)); + + // Verify response is JSON + ASSERT_EQ(session->m_mimeType, "application/json"); + ASSERT_EQ(session->m_code, boost::beast::http::status::ok); + + // Parse JSON response + auto json = nlohmann::json::parse(session->m_body); + + // Verify key configuration values are present + ASSERT_TRUE(json.contains("Port")); + ASSERT_TRUE(json.contains("BufferSize")); + ASSERT_TRUE(json.contains("SchemaVersion")); +} From e1c3b6b901d1aff620c5b9a5a76bfb1c20f29972 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 20:37:45 +0000 Subject: [PATCH 04/14] Fix JSON serialization using nlohmann::json library for proper escaping Co-authored-by: robot-ranger <93950632+robot-ranger@users.noreply.github.com> --- src/mtconnect/sink/rest_sink/rest_service.cpp | 49 ++++++------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/src/mtconnect/sink/rest_sink/rest_service.cpp b/src/mtconnect/sink/rest_sink/rest_service.cpp index 709c0d86a..c0c5212a8 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.cpp +++ b/src/mtconnect/sink/rest_sink/rest_service.cpp @@ -19,6 +19,8 @@ #include +#include + #include "error.hpp" #include "mtconnect/configuration/config_options.hpp" #include "mtconnect/entity/xml_parser.hpp" @@ -439,79 +441,60 @@ namespace mtconnect { void RestService::createConfigRoutings() { using namespace rest_sink; + using json = nlohmann::json; + auto handler = [&](SessionPtr session, const RequestPtr request) -> bool { - auto pretty = request->parameter("pretty").value_or(false); - // Build JSON response with agent configuration - std::stringstream json; - json << "{"; + json config; - bool first = true; for (const auto& [key, value] : m_options) { - if (!first) - json << ","; - first = false; - - json << "\"" << key << "\":"; - // Handle different variant types - std::visit([&json](auto&& arg) { + std::visit([&config, &key](auto&& arg) { using T = std::decay_t; if constexpr (std::is_same_v) { - json << "null"; + config[key] = nullptr; } else if constexpr (std::is_same_v) { - json << (arg ? "true" : "false"); + config[key] = arg; } else if constexpr (std::is_same_v) { - json << arg; + config[key] = arg; } else if constexpr (std::is_same_v) { - json << arg; + config[key] = arg; } else if constexpr (std::is_same_v) { - json << "\"" << arg << "\""; + config[key] = arg; } else if constexpr (std::is_same_v) { - json << arg.count(); + config[key] = arg.count(); } else if constexpr (std::is_same_v) { - json << arg.count(); + config[key] = arg.count(); } else if constexpr (std::is_same_v) { - json << "["; - bool firstItem = true; - for (const auto& item : arg) - { - if (!firstItem) - json << ","; - firstItem = false; - json << "\"" << item << "\""; - } - json << "]"; + config[key] = arg; } }, value); } - json << "}"; - ResponsePtr response = make_unique( - rest_sink::status::ok, json.str(), "application/json"); + rest_sink::status::ok, config.dump(), "application/json"); respond(session, std::move(response), request->m_requestId); return true; }; m_server - ->addRouting({boost::beast::http::verb::get, "/config?pretty={bool:false}", handler}) + ->addRouting({boost::beast::http::verb::get, "/config", handler}) .document("Agent configuration request", "Returns the current agent configuration as JSON") .command("config"); From b1492c81405e9fb3fa9edc89f2807b41b614f610 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 20:39:48 +0000 Subject: [PATCH 05/14] Fix lambda capture and add static assertion for variant exhaustiveness Co-authored-by: robot-ranger <93950632+robot-ranger@users.noreply.github.com> --- src/mtconnect/sink/rest_sink/rest_service.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/mtconnect/sink/rest_sink/rest_service.cpp b/src/mtconnect/sink/rest_sink/rest_service.cpp index c0c5212a8..76a13b72b 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.cpp +++ b/src/mtconnect/sink/rest_sink/rest_service.cpp @@ -443,7 +443,7 @@ namespace mtconnect { using namespace rest_sink; using json = nlohmann::json; - auto handler = [&](SessionPtr session, const RequestPtr request) -> bool { + auto handler = [this](SessionPtr session, const RequestPtr request) -> bool { // Build JSON response with agent configuration json config; @@ -484,6 +484,20 @@ namespace mtconnect { { config[key] = arg; } + else + { + // If this static assertion fails, a new type has been added to ConfigOption + // and needs to be handled in the visitor + static_assert(std::is_same_v || + std::is_same_v || + std::is_same_v || + std::is_same_v || + std::is_same_v || + std::is_same_v || + std::is_same_v || + std::is_same_v, + "Unhandled type in ConfigOption variant"); + } }, value); } From 9d0d155ce28c5f5472388f3c85fc44955580f134 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 20:41:26 +0000 Subject: [PATCH 06/14] Add explicit include and fix static assertion with template-dependent false Co-authored-by: robot-ranger <93950632+robot-ranger@users.noreply.github.com> --- src/mtconnect/sink/rest_sink/rest_service.cpp | 17 ++++++----------- test_package/agent_test.cpp | 2 ++ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/mtconnect/sink/rest_sink/rest_service.cpp b/src/mtconnect/sink/rest_sink/rest_service.cpp index 76a13b72b..596e83b90 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.cpp +++ b/src/mtconnect/sink/rest_sink/rest_service.cpp @@ -486,17 +486,12 @@ namespace mtconnect { } else { - // If this static assertion fails, a new type has been added to ConfigOption - // and needs to be handled in the visitor - static_assert(std::is_same_v || - std::is_same_v || - std::is_same_v || - std::is_same_v || - std::is_same_v || - std::is_same_v || - std::is_same_v || - std::is_same_v, - "Unhandled type in ConfigOption variant"); + // If compilation fails here, a new type has been added to ConfigOption + // and needs to be handled above + []() + { + static_assert(flag, "Unhandled type in ConfigOption variant"); + }(); } }, value); } diff --git a/test_package/agent_test.cpp b/test_package/agent_test.cpp index e98fdd537..f9b6106ea 100644 --- a/test_package/agent_test.cpp +++ b/test_package/agent_test.cpp @@ -29,6 +29,8 @@ #include #include +#include + #include "agent_test_helper.hpp" #include "mtconnect/agent.hpp" #include "mtconnect/asset/file_asset.hpp" From 07e4e79eb86bc621a71567b510e06bfd3e271509 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 20:42:45 +0000 Subject: [PATCH 07/14] Final implementation of /config endpoint Co-authored-by: robot-ranger <93950632+robot-ranger@users.noreply.github.com> --- _codeql_detected_source_root | 1 + 1 file changed, 1 insertion(+) create mode 120000 _codeql_detected_source_root diff --git a/_codeql_detected_source_root b/_codeql_detected_source_root new file mode 120000 index 000000000..945c9b46d --- /dev/null +++ b/_codeql_detected_source_root @@ -0,0 +1 @@ +. \ No newline at end of file From 4e10e9c170231ed3be8f06d97a0236a283b8c9c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:01:22 +0000 Subject: [PATCH 08/14] WIP: Refactor PUT /config to use warm start mechanism Co-authored-by: robot-ranger <93950632+robot-ranger@users.noreply.github.com> Agent-Logs-Url: https://github.com/robot-ranger/cppagent/sessions/b380bd2a-959a-4a64-b609-ccba032cdc20 --- src/mtconnect/sink/rest_sink/rest_service.cpp | 177 +++++++++++++++++- 1 file changed, 175 insertions(+), 2 deletions(-) diff --git a/src/mtconnect/sink/rest_sink/rest_service.cpp b/src/mtconnect/sink/rest_sink/rest_service.cpp index 596e83b90..6b88847e4 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.cpp +++ b/src/mtconnect/sink/rest_sink/rest_service.cpp @@ -443,7 +443,8 @@ namespace mtconnect { using namespace rest_sink; using json = nlohmann::json; - auto handler = [this](SessionPtr session, const RequestPtr request) -> bool { + // GET /config - return current configuration + auto getHandler = [this](SessionPtr session, const RequestPtr request) -> bool { // Build JSON response with agent configuration json config; @@ -503,10 +504,182 @@ namespace mtconnect { }; m_server - ->addRouting({boost::beast::http::verb::get, "/config", handler}) + ->addRouting({boost::beast::http::verb::get, "/config", getHandler}) .document("Agent configuration request", "Returns the current agent configuration as JSON") .command("config"); + + // PUT /config - update configuration (only if AllowPut is enabled) + if (m_server->arePutsAllowed()) + { + auto putHandler = [this](SessionPtr session, RequestPtr request) -> bool { + try + { + // Parse JSON body + json updates = json::parse(request->m_body); + + // Store initial AllowPut state to enforce security constraint + bool initialAllowPut = m_server->arePutsAllowed(); + + // Track which config values were updated + std::vector updatedKeys; + std::vector deniedKeys; + + // Process each update + for (auto& [key, value] : updates.items()) + { + // Security check: prevent modification of AllowPut or AllowPutFrom + // if PUTs weren't initially allowed + if ((key == config::AllowPut || key == config::AllowPutFrom)) + { + if (!initialAllowPut) + { + deniedKeys.push_back(key); + continue; + } + // Even if allowed, don't allow disabling AllowPut + if (key == config::AllowPut && value.is_boolean() && !value.get()) + { + deniedKeys.push_back(key); + continue; + } + } + + // Check if this key exists in current options + auto it = m_options.find(key); + if (it == m_options.end()) + { + // Skip unknown keys + continue; + } + + // Update the config option based on its type + std::visit([&value, &key, this, &updatedKeys](auto&& arg) { + using T = std::decay_t; + try + { + if constexpr (std::is_same_v) + { + if (value.is_boolean()) + { + m_options[key] = value.get(); + updatedKeys.push_back(key); + } + } + else if constexpr (std::is_same_v) + { + if (value.is_number_integer()) + { + m_options[key] = value.get(); + updatedKeys.push_back(key); + } + } + else if constexpr (std::is_same_v) + { + if (value.is_number()) + { + m_options[key] = value.get(); + updatedKeys.push_back(key); + } + } + else if constexpr (std::is_same_v) + { + if (value.is_string()) + { + m_options[key] = value.get(); + updatedKeys.push_back(key); + } + } + else if constexpr (std::is_same_v) + { + if (value.is_number()) + { + m_options[key] = Seconds(value.get()); + updatedKeys.push_back(key); + } + } + else if constexpr (std::is_same_v) + { + if (value.is_number()) + { + m_options[key] = Milliseconds(value.get()); + updatedKeys.push_back(key); + } + } + else if constexpr (std::is_same_v) + { + if (value.is_array()) + { + StringList list; + for (const auto& item : value) + { + if (item.is_string()) + list.push_back(item.get()); + } + m_options[key] = list; + updatedKeys.push_back(key); + } + } + } + catch (const std::exception& e) + { + LOG(warning) << "Failed to update config key " << key << ": " << e.what(); + } + }, it->second); + + // Apply specific updates for certain config keys + if (key == config::HttpHeaders) + { + auto headers = GetOption(m_options, config::HttpHeaders); + if (headers) + m_server->setHttpHeaders(*headers); + } + else if (key == config::AllowPutFrom) + { + // Reload AllowPutFrom settings + loadAllowPut(); + } + } + + // Build response with update summary + json response; + response["status"] = "ok"; + response["updated"] = updatedKeys; + if (!deniedKeys.empty()) + { + response["denied"] = deniedKeys; + response["message"] = "Some keys were denied: " + + (initialAllowPut ? "Cannot disable AllowPut" + : "Cannot modify security settings"); + } + + ResponsePtr resp = make_unique( + rest_sink::status::ok, response.dump(), "application/json"); + respond(session, std::move(resp), request->m_requestId); + return true; + } + catch (const json::parse_error& e) + { + auto error = Error::make(Error::ErrorCode::INVALID_REQUEST, + "Invalid JSON in request body: "s + e.what()); + throw RestError(error, getPrinter(request->m_accepts, std::nullopt), + rest_sink::status::bad_request); + } + catch (const std::exception& e) + { + auto error = Error::make(Error::ErrorCode::INVALID_REQUEST, + "Failed to update configuration: "s + e.what()); + throw RestError(error, getPrinter(request->m_accepts, std::nullopt), + rest_sink::status::internal_server_error); + } + }; + + m_server + ->addRouting({boost::beast::http::verb::put, "/config", putHandler}) + .document("Update agent configuration", + "Updates runtime configuration. Requires AllowPut to be enabled. " + "Cannot modify AllowPut or AllowPutFrom if initially disabled."); + } } void RestService::createFileRoutings() From a6843dc11d5471495d0b251415c30ba1cb596017 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:03:48 +0000 Subject: [PATCH 09/14] Implement PUT /config with JSON file write and warm start trigger Co-authored-by: robot-ranger <93950632+robot-ranger@users.noreply.github.com> Agent-Logs-Url: https://github.com/robot-ranger/cppagent/sessions/b380bd2a-959a-4a64-b609-ccba032cdc20 --- src/mtconnect/configuration/agent_config.cpp | 3 + .../configuration/config_options.hpp | 1 + src/mtconnect/sink/rest_sink/rest_service.cpp | 237 ++++++++++-------- 3 files changed, 131 insertions(+), 110 deletions(-) diff --git a/src/mtconnect/configuration/agent_config.cpp b/src/mtconnect/configuration/agent_config.cpp index ab63fa019..0ac8e9b2c 100644 --- a/src/mtconnect/configuration/agent_config.cpp +++ b/src/mtconnect/configuration/agent_config.cpp @@ -1194,6 +1194,9 @@ namespace mtconnect::configuration { auto sinkContract = makeSinkContract(); sinkContract->m_pipelineContext = m_pipelineContext; + // Add config file path to options for REST service + options[configuration::ConfigFile] = m_configFile.string(); + auto sink = m_sinkFactory.make("RestService", "RestService", getAsyncContext(), std::move(sinkContract), options, config); m_agent->addSink(sink); diff --git a/src/mtconnect/configuration/config_options.hpp b/src/mtconnect/configuration/config_options.hpp index e2ac36aec..9a1a98727 100644 --- a/src/mtconnect/configuration/config_options.hpp +++ b/src/mtconnect/configuration/config_options.hpp @@ -48,6 +48,7 @@ namespace mtconnect { DECLARE_CONFIGURATION(AllowPutFrom); DECLARE_CONFIGURATION(BufferSize); DECLARE_CONFIGURATION(CheckpointFrequency); + DECLARE_CONFIGURATION(ConfigFile); DECLARE_CONFIGURATION(Devices); DECLARE_CONFIGURATION(HttpHeaders); DECLARE_CONFIGURATION(JsonVersion); diff --git a/src/mtconnect/sink/rest_sink/rest_service.cpp b/src/mtconnect/sink/rest_sink/rest_service.cpp index 6b88847e4..7c587f98b 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.cpp +++ b/src/mtconnect/sink/rest_sink/rest_service.cpp @@ -17,6 +17,8 @@ #include "rest_service.hpp" +#include +#include #include #include @@ -510,6 +512,7 @@ namespace mtconnect { .command("config"); // PUT /config - update configuration (only if AllowPut is enabled) + // Writes updated config to JSON file, triggering warm start via file monitoring if (m_server->arePutsAllowed()) { auto putHandler = [this](SessionPtr session, RequestPtr request) -> bool { @@ -518,140 +521,149 @@ namespace mtconnect { // Parse JSON body json updates = json::parse(request->m_body); - // Store initial AllowPut state to enforce security constraint + // Check if AllowPut was initially enabled bool initialAllowPut = m_server->arePutsAllowed(); - // Track which config values were updated - std::vector updatedKeys; + // Validate security constraints before updating std::vector deniedKeys; - // Process each update for (auto& [key, value] : updates.items()) { // Security check: prevent modification of AllowPut or AllowPutFrom - // if PUTs weren't initially allowed - if ((key == config::AllowPut || key == config::AllowPutFrom)) + // if PUTs weren't initially allowed (enforced by design) + if (key == config::AllowPut || key == config::AllowPutFrom) { if (!initialAllowPut) { deniedKeys.push_back(key); - continue; + LOG(warning) << "Denied config update for " << key + << ": AllowPut was not initially enabled"; } - // Even if allowed, don't allow disabling AllowPut - if (key == config::AllowPut && value.is_boolean() && !value.get()) + // Don't allow disabling AllowPut even if initially enabled + else if (key == config::AllowPut && value.is_boolean() && !value.get()) { deniedKeys.push_back(key); - continue; + LOG(warning) << "Denied config update: cannot disable AllowPut"; } } + } + + // If any security constraints were violated, reject the entire request + if (!deniedKeys.empty()) + { + json response; + response["status"] = "error"; + response["denied"] = deniedKeys; + response["message"] = initialAllowPut + ? "Cannot disable AllowPut once enabled" + : "Cannot modify AllowPut/AllowPutFrom when initially disabled"; - // Check if this key exists in current options - auto it = m_options.find(key); - if (it == m_options.end()) - { - // Skip unknown keys + ResponsePtr resp = make_unique( + rest_sink::status::forbidden, response.dump(), "application/json"); + respond(session, std::move(resp), request->m_requestId); + return true; + } + + // Get config file path + auto configFilePath = GetOption(m_options, config::ConfigFile); + if (!configFilePath || configFilePath->empty()) + { + auto error = Error::make(Error::ErrorCode::INVALID_REQUEST, + "Config file path not available"); + throw RestError(error, getPrinter(request->m_accepts, std::nullopt), + rest_sink::status::internal_server_error); + } + + // Build merged configuration (current + updates) + json mergedConfig; + for (const auto& [key, value] : m_options) + { + // Skip internal/runtime keys that shouldn't be persisted + if (key == config::ConfigFile) continue; - } - - // Update the config option based on its type - std::visit([&value, &key, this, &updatedKeys](auto&& arg) { + + std::visit([&mergedConfig, &key](auto&& arg) { using T = std::decay_t; - try + if constexpr (std::is_same_v) { - if constexpr (std::is_same_v) - { - if (value.is_boolean()) - { - m_options[key] = value.get(); - updatedKeys.push_back(key); - } - } - else if constexpr (std::is_same_v) - { - if (value.is_number_integer()) - { - m_options[key] = value.get(); - updatedKeys.push_back(key); - } - } - else if constexpr (std::is_same_v) - { - if (value.is_number()) - { - m_options[key] = value.get(); - updatedKeys.push_back(key); - } - } - else if constexpr (std::is_same_v) - { - if (value.is_string()) - { - m_options[key] = value.get(); - updatedKeys.push_back(key); - } - } - else if constexpr (std::is_same_v) - { - if (value.is_number()) - { - m_options[key] = Seconds(value.get()); - updatedKeys.push_back(key); - } - } - else if constexpr (std::is_same_v) - { - if (value.is_number()) - { - m_options[key] = Milliseconds(value.get()); - updatedKeys.push_back(key); - } - } - else if constexpr (std::is_same_v) - { - if (value.is_array()) - { - StringList list; - for (const auto& item : value) - { - if (item.is_string()) - list.push_back(item.get()); - } - m_options[key] = list; - updatedKeys.push_back(key); - } - } + // Skip monostate + } + else if constexpr (std::is_same_v) + { + mergedConfig[key] = arg; + } + else if constexpr (std::is_same_v) + { + mergedConfig[key] = arg; } - catch (const std::exception& e) + else if constexpr (std::is_same_v) { - LOG(warning) << "Failed to update config key " << key << ": " << e.what(); + mergedConfig[key] = arg; } - }, it->second); + else if constexpr (std::is_same_v) + { + mergedConfig[key] = arg; + } + else if constexpr (std::is_same_v) + { + mergedConfig[key] = arg.count(); + } + else if constexpr (std::is_same_v) + { + mergedConfig[key] = arg.count(); + } + else if constexpr (std::is_same_v) + { + mergedConfig[key] = arg; + } + }, value); + } + + // Apply updates to merged config + std::vector updatedKeys; + for (auto& [key, value] : updates.items()) + { + // Skip denied keys + if (std::find(deniedKeys.begin(), deniedKeys.end(), key) != deniedKeys.end()) + continue; - // Apply specific updates for certain config keys - if (key == config::HttpHeaders) - { - auto headers = GetOption(m_options, config::HttpHeaders); - if (headers) - m_server->setHttpHeaders(*headers); - } - else if (key == config::AllowPutFrom) - { - // Reload AllowPutFrom settings - loadAllowPut(); - } + // Update the merged config + mergedConfig[key] = value; + updatedKeys.push_back(key); + } + + // Write updated config to file as JSON + // This will trigger the file monitoring mechanism to detect change and warm start + std::filesystem::path configPath(*configFilePath); + + // If original config was not JSON, write to a .json version + if (!configPath.string().ends_with(".json")) + { + configPath.replace_extension(".json"); + } + + std::ofstream configFile(configPath); + if (!configFile.is_open()) + { + auto error = Error::make(Error::ErrorCode::INVALID_REQUEST, + "Failed to open config file for writing: " + configPath.string()); + throw RestError(error, getPrinter(request->m_accepts, std::nullopt), + rest_sink::status::internal_server_error); } - // Build response with update summary + // Write formatted JSON + configFile << mergedConfig.dump(2); // Pretty print with 2-space indent + configFile.close(); + + LOG(info) << "Updated configuration written to " << configPath.string(); + LOG(info) << "File monitoring will detect change and trigger warm start"; + + // Build response json response; response["status"] = "ok"; + response["message"] = "Configuration updated. Agent will warm start shortly."; response["updated"] = updatedKeys; - if (!deniedKeys.empty()) - { - response["denied"] = deniedKeys; - response["message"] = "Some keys were denied: " + - (initialAllowPut ? "Cannot disable AllowPut" - : "Cannot modify security settings"); - } + response["configFile"] = configPath.string(); ResponsePtr resp = make_unique( rest_sink::status::ok, response.dump(), "application/json"); @@ -660,16 +672,20 @@ namespace mtconnect { } catch (const json::parse_error& e) { - auto error = Error::make(Error::ErrorCode::INVALID_REQUEST, + auto error = Error::make(Error::ErrorCode::INVALID_REQUEST, "Invalid JSON in request body: "s + e.what()); - throw RestError(error, getPrinter(request->m_accepts, std::nullopt), + throw RestError(error, getPrinter(request->m_accepts, std::nullopt), rest_sink::status::bad_request); } + catch (const RestError&) + { + throw; // Re-throw RestError as-is + } catch (const std::exception& e) { - auto error = Error::make(Error::ErrorCode::INVALID_REQUEST, + auto error = Error::make(Error::ErrorCode::INVALID_REQUEST, "Failed to update configuration: "s + e.what()); - throw RestError(error, getPrinter(request->m_accepts, std::nullopt), + throw RestError(error, getPrinter(request->m_accepts, std::nullopt), rest_sink::status::internal_server_error); } }; @@ -677,7 +693,8 @@ namespace mtconnect { m_server ->addRouting({boost::beast::http::verb::put, "/config", putHandler}) .document("Update agent configuration", - "Updates runtime configuration. Requires AllowPut to be enabled. " + "Updates configuration by writing to JSON config file, triggering warm start. " + "Requires AllowPut to be enabled. " "Cannot modify AllowPut or AllowPutFrom if initially disabled."); } } From 2c23862ac9db105437bad6515afb037cf3425e20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:05:04 +0000 Subject: [PATCH 10/14] Simplify PUT /config to write config.json in config directory Co-authored-by: robot-ranger <93950632+robot-ranger@users.noreply.github.com> Agent-Logs-Url: https://github.com/robot-ranger/cppagent/sessions/b380bd2a-959a-4a64-b609-ccba032cdc20 --- src/mtconnect/sink/rest_sink/rest_service.cpp | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/mtconnect/sink/rest_sink/rest_service.cpp b/src/mtconnect/sink/rest_sink/rest_service.cpp index 7c587f98b..01ba358c3 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.cpp +++ b/src/mtconnect/sink/rest_sink/rest_service.cpp @@ -512,7 +512,7 @@ namespace mtconnect { .command("config"); // PUT /config - update configuration (only if AllowPut is enabled) - // Writes updated config to JSON file, triggering warm start via file monitoring + // Writes updated config to config.json, triggering warm start via file monitoring if (m_server->arePutsAllowed()) { auto putHandler = [this](SessionPtr session, RequestPtr request) -> bool { @@ -564,14 +564,20 @@ namespace mtconnect { return true; } - // Get config file path + // Get current config file path to determine directory auto configFilePath = GetOption(m_options, config::ConfigFile); - if (!configFilePath || configFilePath->empty()) + std::filesystem::path configDir; + + if (configFilePath && !configFilePath->empty()) { - auto error = Error::make(Error::ErrorCode::INVALID_REQUEST, - "Config file path not available"); - throw RestError(error, getPrinter(request->m_accepts, std::nullopt), - rest_sink::status::internal_server_error); + // Use the directory of the current config file + std::filesystem::path currentConfig(*configFilePath); + configDir = currentConfig.parent_path(); + } + else + { + // Fallback to current working directory + configDir = std::filesystem::current_path(); } // Build merged configuration (current + updates) @@ -632,21 +638,15 @@ namespace mtconnect { updatedKeys.push_back(key); } - // Write updated config to file as JSON - // This will trigger the file monitoring mechanism to detect change and warm start - std::filesystem::path configPath(*configFilePath); - - // If original config was not JSON, write to a .json version - if (!configPath.string().ends_with(".json")) - { - configPath.replace_extension(".json"); - } + // Write to config.json in the config directory + // The agent searches for config.json at startup and file monitor will detect changes + std::filesystem::path jsonConfigPath = configDir / "config.json"; - std::ofstream configFile(configPath); + std::ofstream configFile(jsonConfigPath); if (!configFile.is_open()) { auto error = Error::make(Error::ErrorCode::INVALID_REQUEST, - "Failed to open config file for writing: " + configPath.string()); + "Failed to open config.json for writing: " + jsonConfigPath.string()); throw RestError(error, getPrinter(request->m_accepts, std::nullopt), rest_sink::status::internal_server_error); } @@ -655,7 +655,7 @@ namespace mtconnect { configFile << mergedConfig.dump(2); // Pretty print with 2-space indent configFile.close(); - LOG(info) << "Updated configuration written to " << configPath.string(); + LOG(info) << "Updated configuration written to " << jsonConfigPath.string(); LOG(info) << "File monitoring will detect change and trigger warm start"; // Build response @@ -663,7 +663,7 @@ namespace mtconnect { response["status"] = "ok"; response["message"] = "Configuration updated. Agent will warm start shortly."; response["updated"] = updatedKeys; - response["configFile"] = configPath.string(); + response["configFile"] = jsonConfigPath.string(); ResponsePtr resp = make_unique( rest_sink::status::ok, response.dump(), "application/json"); @@ -693,7 +693,7 @@ namespace mtconnect { m_server ->addRouting({boost::beast::http::verb::put, "/config", putHandler}) .document("Update agent configuration", - "Updates configuration by writing to JSON config file, triggering warm start. " + "Updates configuration by writing to config.json, triggering warm start. " "Requires AllowPut to be enabled. " "Cannot modify AllowPut or AllowPutFrom if initially disabled."); } From b9785da2ec2ebc138d6095451e5f18b674e7629b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:05:39 +0000 Subject: [PATCH 11/14] Add tests for PUT /config endpoint with security enforcement Co-authored-by: robot-ranger <93950632+robot-ranger@users.noreply.github.com> Agent-Logs-Url: https://github.com/robot-ranger/cppagent/sessions/b380bd2a-959a-4a64-b609-ccba032cdc20 --- test_package/agent_test.cpp | 91 +++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/test_package/agent_test.cpp b/test_package/agent_test.cpp index f9b6106ea..047757299 100644 --- a/test_package/agent_test.cpp +++ b/test_package/agent_test.cpp @@ -2650,3 +2650,94 @@ TEST_F(AgentTest, should_return_agent_configuration) ASSERT_TRUE(json.contains("BufferSize")); ASSERT_TRUE(json.contains("SchemaVersion")); } + +TEST_F(AgentTest, should_reject_put_config_when_puts_not_allowed) +{ + // Create agent without AllowPut enabled + m_agentTestHelper->createAgent("/samples/test_config.xml", 8, 4, "2.6", 4, false); + + // Test PUT /config endpoint when PUTs not allowed + auto session = m_agentTestHelper->m_server->createSession(m_agentTestHelper->m_context); + auto request = make_unique("/config", boost::beast::http::verb::put); + + // PUT routing should not exist when AllowPut is disabled + auto routing = m_agentTestHelper->m_server->getRouting(request->m_path, request->m_verb); + ASSERT_FALSE(routing); +} + +TEST_F(AgentTest, should_reject_security_setting_modifications_when_puts_not_initially_allowed) +{ + ConfigOptions options; + options[configuration::AllowPut] = true; + options[configuration::AllowPutFrom] = "127.0.0.1"s; + + // Create agent with AllowPut enabled but simulate it wasn't initially enabled + m_agentTestHelper->createAgent("/samples/test_config.xml", 8, 4, "2.6", 4, false, true, options); + + // Try to modify AllowPut or AllowPutFrom + nlohmann::json updates; + updates["AllowPut"] = false; + updates["Port"] = 5002; + + auto session = m_agentTestHelper->m_server->createSession(m_agentTestHelper->m_context); + auto request = make_unique("/config", boost::beast::http::verb::put); + request->m_body = updates.dump(); + + auto routing = m_agentTestHelper->m_server->getRouting(request->m_path, request->m_verb); + ASSERT_TRUE(routing); + + routing->m_handler(session, std::move(request)); + + // Verify response indicates denied keys + ASSERT_EQ(session->m_mimeType, "application/json"); + ASSERT_EQ(session->m_code, boost::beast::http::status::forbidden); + + auto json = nlohmann::json::parse(session->m_body); + ASSERT_EQ(json["status"], "error"); + ASSERT_TRUE(json.contains("denied")); +} + +TEST_F(AgentTest, should_update_config_and_write_json_file) +{ + ConfigOptions options; + options[configuration::AllowPut] = true; + options[configuration::AllowPutFrom] = "127.0.0.1"s; + + // Create agent with AllowPut enabled + m_agentTestHelper->createAgent("/samples/test_config.xml", 8, 4, "2.6", 4, false, true, options); + + // Update some config values + nlohmann::json updates; + updates["Port"] = 5555; + updates["BufferSize"] = 20; + + auto session = m_agentTestHelper->m_server->createSession(m_agentTestHelper->m_context); + auto request = make_unique("/config", boost::beast::http::verb::put); + request->m_body = updates.dump(); + + auto routing = m_agentTestHelper->m_server->getRouting(request->m_path, request->m_verb); + ASSERT_TRUE(routing); + + routing->m_handler(session, std::move(request)); + + // Verify response is successful + ASSERT_EQ(session->m_mimeType, "application/json"); + ASSERT_EQ(session->m_code, boost::beast::http::status::ok); + + auto json = nlohmann::json::parse(session->m_body); + ASSERT_EQ(json["status"], "ok"); + ASSERT_TRUE(json.contains("updated")); + ASSERT_TRUE(json.contains("configFile")); + + // Verify config.json was written + std::string configFilePath = json["configFile"]; + ASSERT_FALSE(configFilePath.empty()); + + // Read and verify the config file exists + std::ifstream configFile(configFilePath); + ASSERT_TRUE(configFile.is_open()); + + nlohmann::json savedConfig = nlohmann::json::parse(configFile); + ASSERT_EQ(savedConfig["Port"], 5555); + ASSERT_EQ(savedConfig["BufferSize"], 20); +} From 4275704b7abe505886c6717cbc1542428bb8dac2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:07:19 +0000 Subject: [PATCH 12/14] Fix security enforcement to track initial AllowPut state from config file Co-authored-by: robot-ranger <93950632+robot-ranger@users.noreply.github.com> Agent-Logs-Url: https://github.com/robot-ranger/cppagent/sessions/b380bd2a-959a-4a64-b609-ccba032cdc20 --- src/mtconnect/sink/rest_sink/rest_service.cpp | 9 +++++++-- src/mtconnect/sink/rest_sink/rest_service.hpp | 4 ++++ test_package/agent_test.cpp | 7 ++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/mtconnect/sink/rest_sink/rest_service.cpp b/src/mtconnect/sink/rest_sink/rest_service.cpp index 01ba358c3..634b58601 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.cpp +++ b/src/mtconnect/sink/rest_sink/rest_service.cpp @@ -93,6 +93,10 @@ namespace mtconnect { loadStyle(config, "ErrorStyle", xmlPrinter, &XmlPrinter::setErrorStyle); loadTypes(config); + + // Track initial AllowPut state before loadAllowPut() potentially modifies it + m_initialAllowPut = IsOptionSet(options, config::AllowPut); + loadAllowPut(); m_server->addParameterDocumentation( @@ -521,8 +525,9 @@ namespace mtconnect { // Parse JSON body json updates = json::parse(request->m_body); - // Check if AllowPut was initially enabled - bool initialAllowPut = m_server->arePutsAllowed(); + // Use the initial AllowPut state from config file (not current runtime state) + // This enforces "if the first config does not allow any, then it wont ever by design" + bool initialAllowPut = m_initialAllowPut; // Validate security constraints before updating std::vector deniedKeys; diff --git a/src/mtconnect/sink/rest_sink/rest_service.hpp b/src/mtconnect/sink/rest_sink/rest_service.hpp index 5c317bf89..bdbc50451 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.hpp +++ b/src/mtconnect/sink/rest_sink/rest_service.hpp @@ -284,6 +284,7 @@ namespace mtconnect { ///@{ auto instanceId() const { return m_instanceId; } void setInstanceId(uint64_t id) { m_instanceId = id; } + void setInitialAllowPut(bool allow) { m_initialAllowPut = allow; } ///@} protected: @@ -384,6 +385,9 @@ namespace mtconnect { // Buffers FileCache m_fileCache; bool m_logStreamData {false}; + + // Track initial AllowPut state from config file (for security enforcement) + bool m_initialAllowPut {false}; }; } // namespace sink::rest_sink } // namespace mtconnect diff --git a/test_package/agent_test.cpp b/test_package/agent_test.cpp index 047757299..a6fd87505 100644 --- a/test_package/agent_test.cpp +++ b/test_package/agent_test.cpp @@ -2671,9 +2671,14 @@ TEST_F(AgentTest, should_reject_security_setting_modifications_when_puts_not_ini options[configuration::AllowPut] = true; options[configuration::AllowPutFrom] = "127.0.0.1"s; - // Create agent with AllowPut enabled but simulate it wasn't initially enabled + // Create agent with AllowPut enabled m_agentTestHelper->createAgent("/samples/test_config.xml", 8, 4, "2.6", 4, false, true, options); + // Simulate that AllowPut was NOT initially enabled in config file + auto restService = dynamic_cast(m_agentTestHelper->getAgent()->findSink("RestService")); + ASSERT_TRUE(restService); + restService->setInitialAllowPut(false); + // Try to modify AllowPut or AllowPutFrom nlohmann::json updates; updates["AllowPut"] = false; From d243300efebd772ca05eebadbbadb87800a46ab2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:08:16 +0000 Subject: [PATCH 13/14] Remove redundant denied keys check in PUT /config Co-authored-by: robot-ranger <93950632+robot-ranger@users.noreply.github.com> Agent-Logs-Url: https://github.com/robot-ranger/cppagent/sessions/b380bd2a-959a-4a64-b609-ccba032cdc20 --- src/mtconnect/sink/rest_sink/rest_service.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/mtconnect/sink/rest_sink/rest_service.cpp b/src/mtconnect/sink/rest_sink/rest_service.cpp index 634b58601..668031990 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.cpp +++ b/src/mtconnect/sink/rest_sink/rest_service.cpp @@ -634,10 +634,6 @@ namespace mtconnect { std::vector updatedKeys; for (auto& [key, value] : updates.items()) { - // Skip denied keys - if (std::find(deniedKeys.begin(), deniedKeys.end(), key) != deniedKeys.end()) - continue; - // Update the merged config mergedConfig[key] = value; updatedKeys.push_back(key); From 0d09a2352b6f13def36db1f4d9ce61f04efea6aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 01:16:57 +0000 Subject: [PATCH 14/14] Remove ConfigFile option, use ConfigPath instead Co-authored-by: robot-ranger <93950632+robot-ranger@users.noreply.github.com> Agent-Logs-Url: https://github.com/robot-ranger/cppagent/sessions/049f148d-4f08-4985-8f03-040dc1a7392c --- src/mtconnect/configuration/agent_config.cpp | 3 --- src/mtconnect/configuration/config_options.hpp | 1 - src/mtconnect/sink/rest_sink/rest_service.cpp | 14 +++++++------- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/mtconnect/configuration/agent_config.cpp b/src/mtconnect/configuration/agent_config.cpp index 0ac8e9b2c..ab63fa019 100644 --- a/src/mtconnect/configuration/agent_config.cpp +++ b/src/mtconnect/configuration/agent_config.cpp @@ -1194,9 +1194,6 @@ namespace mtconnect::configuration { auto sinkContract = makeSinkContract(); sinkContract->m_pipelineContext = m_pipelineContext; - // Add config file path to options for REST service - options[configuration::ConfigFile] = m_configFile.string(); - auto sink = m_sinkFactory.make("RestService", "RestService", getAsyncContext(), std::move(sinkContract), options, config); m_agent->addSink(sink); diff --git a/src/mtconnect/configuration/config_options.hpp b/src/mtconnect/configuration/config_options.hpp index 9a1a98727..e2ac36aec 100644 --- a/src/mtconnect/configuration/config_options.hpp +++ b/src/mtconnect/configuration/config_options.hpp @@ -48,7 +48,6 @@ namespace mtconnect { DECLARE_CONFIGURATION(AllowPutFrom); DECLARE_CONFIGURATION(BufferSize); DECLARE_CONFIGURATION(CheckpointFrequency); - DECLARE_CONFIGURATION(ConfigFile); DECLARE_CONFIGURATION(Devices); DECLARE_CONFIGURATION(HttpHeaders); DECLARE_CONFIGURATION(JsonVersion); diff --git a/src/mtconnect/sink/rest_sink/rest_service.cpp b/src/mtconnect/sink/rest_sink/rest_service.cpp index 668031990..88a8ef394 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.cpp +++ b/src/mtconnect/sink/rest_sink/rest_service.cpp @@ -569,15 +569,14 @@ namespace mtconnect { return true; } - // Get current config file path to determine directory - auto configFilePath = GetOption(m_options, config::ConfigFile); + // Get config directory from ConfigPath (first path is the config file's directory) std::filesystem::path configDir; + auto configPaths = GetOption(m_options, config::ConfigPath); - if (configFilePath && !configFilePath->empty()) + if (configPaths && !configPaths->empty()) { - // Use the directory of the current config file - std::filesystem::path currentConfig(*configFilePath); - configDir = currentConfig.parent_path(); + // Use the first path which is the config file's parent directory + configDir = configPaths->front(); } else { @@ -590,7 +589,8 @@ namespace mtconnect { for (const auto& [key, value] : m_options) { // Skip internal/runtime keys that shouldn't be persisted - if (key == config::ConfigFile) + // (ConfigPath is a list of search paths, not a config value to persist) + if (key == config::ConfigPath) continue; std::visit([&mergedConfig, &key](auto&& arg) {