feat: add logos_core_get_modules_info() for generic module introspection (#159)

* feat: add logos_core_get_modules_info() for generic module introspection

New C API returning a JSON array describing every known module — one
object each with name, path, loaded flag, direct dependencies, direct
dependents, and the full embedded metadata (parsed from the plugin's
declarative metadata.json; null when unreadable).

ModuleInfo now caches the raw metadata JSON at discovery (via
ModuleLib::LogosModule::getRawMetadataJson, no plugin instantiation),
ModuleRegistry::allModulesInfo() assembles the array under the registry
lock, and ModuleManager exposes it as getModulesInfoJson()/CStr.

Methods/events are intentionally excluded: they require instantiating the
plugin in-process, which would defeat the subprocess-isolation model for
a bulk "all known modules" query.

Tests: ModuleManagerTest.GetModulesInfo_* (shape, empty) and
RealModuleRegistryTest.GetModulesInfo_PopulatesEmbeddedMetadata (real
plugin metadata via TEST_PLUGIN).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump logos-module to merged getRawMetadataJson (#21)

Re-pin logos-module a3e288a → 2ec64c4 (master, includes #21) so the
modules-info API builds against the merged getRawMetadataJson without an
override. Full test suite green on this lock (181/181).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: record module load timestamp (loaded_at) in modules-info

Add ModuleInfo::loadedAt — a unix-seconds timestamp stamped by markLoaded
(both overloads) and cleared to 0 by markUnloaded, so loaded_at is 0 ⟺ not
loaded (reload re-stamps it). Surfaced as "loaded_at" in
logos_core_get_modules_info(), letting callers derive a module's uptime as
now - loaded_at (valid only while loaded).

Tests: GetModulesInfo_ReturnsRichEntryPerModule now asserts loaded_at is 0
for an unloaded module and > 0 for a loaded one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dario Lipicar
2026-06-29 18:36:09 -03:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 87ae7ce5db
commit 819faac420
8 changed files with 183 additions and 5 deletions
Generated
+3 -3
View File
@@ -1417,11 +1417,11 @@
]
},
"locked": {
"lastModified": 1781311603,
"narHash": "sha256-Ym3i52f5MWuZy8Qas3+zfoxz+mxQUsyW+Qol1no6kDs=",
"lastModified": 1782766905,
"narHash": "sha256-oYE7sMr7PS+kDoX//Cx90HmRiRHBnHONx6r4Ekkc7DI=",
"owner": "logos-co",
"repo": "logos-module",
"rev": "a3e288a71d6f79445db598b0ffcca2ee435596b9",
"rev": "2ec64c4a65f8966b5137cdba3a19a6498ce17b6d",
"type": "github"
},
"original": {
+4
View File
@@ -67,6 +67,10 @@ char** logos_core_get_module_dependents(const char* module_name, bool recursive)
return ModuleManager::getDependentsCStr(module_name, recursive);
}
char* logos_core_get_modules_info() {
return ModuleManager::getModulesInfoCStr();
}
char* logos_core_process_module(const char* module_path) {
if (!module_path) { logos::logger("core").critical("logos_core_process_module: module_path must not be null"); std::abort(); }
return ModuleManager::processModuleCStr(module_path);
+14
View File
@@ -70,6 +70,20 @@ LOGOS_CORE_EXPORT char** logos_core_get_module_dependencies(const char* module_n
// Returns a null-terminated array of module names that must be freed by the caller.
LOGOS_CORE_EXPORT char** logos_core_get_module_dependents(const char* module_name, bool recursive);
// Get information about all known modules as a JSON string.
// Returns a JSON array; each element is an object with:
// "name" module name
// "path" path to the module binary
// "loaded" bool — whether the module is currently loaded
// "loaded_at" unix-seconds timestamp of the current load, 0 when not
// loaded (callers compute uptime as now - loaded_at)
// "dependencies" array of direct dependency names
// "dependents" array of direct dependent names
// "metadata" the module's full embedded metadata object (name, version,
// type, description, dependencies, …), or null if unreadable
// The returned string must be freed by the caller.
LOGOS_CORE_EXPORT char* logos_core_get_modules_info();
// Process a module file and add it to known modules
// Returns the module name if successful, NULL if failed
LOGOS_CORE_EXPORT char* logos_core_process_module(const char* module_path);
+11
View File
@@ -671,6 +671,17 @@ namespace ModuleManager {
getDependents(std::string(name), recursive));
}
std::string getModulesInfoJson() {
return registryInstance().allModulesInfo().dump();
}
char* getModulesInfoCStr() {
std::string json = getModulesInfoJson();
char* result = new char[json.size() + 1];
strcpy(result, json.c_str());
return result;
}
std::vector<std::string> computeDerivedAllowedCallers(const std::string& target) {
std::lock_guard lock(loadMutex());
return computeDerivedAllowedCallersLocked(target);
+7
View File
@@ -96,6 +96,13 @@ namespace ModuleManager {
// array, matching the C API contract used by the other getters.
char** getDependenciesCStr(const char* name, bool recursive);
char** getDependentsCStr(const char* name, bool recursive);
// JSON (string) describing every known module: name, path, loaded flag,
// direct dependencies, direct dependents, and full embedded metadata.
// See ModuleRegistry::allModulesInfo for the shape.
std::string getModulesInfoJson();
// char* variant. Caller owns the returned string. Never null.
char* getModulesInfoCStr();
}
#endif // MODULE_MANAGER_H
+43 -2
View File
@@ -1,6 +1,7 @@
#include "module_registry.h"
#include <spdlog/spdlog.h>
#include <cassert>
#include <ctime>
#include <deque>
#include <mutex>
#include <shared_mutex>
@@ -165,6 +166,7 @@ std::string ModuleRegistry::processModuleInternal(const std::string& modulePath,
// (and any other state that lives on ModuleInfo).
ModuleInfo& info = m_modules[name];
info.path = modulePath;
info.metadataJson = ModuleLib::LogosModule::getRawMetadataJson(modulePath);
info.dependencies.clear();
for (const auto& d : ModuleLib::LogosModule::getModuleDependencies(modulePath)) {
info.dependencies.push_back(d);
@@ -184,6 +186,33 @@ std::string ModuleRegistry::modulePath(const std::string& name) const {
return it != m_modules.end() ? it->second.path : std::string{};
}
nlohmann::json ModuleRegistry::allModulesInfo() const {
std::shared_lock lock(m_mutex);
nlohmann::json modules = nlohmann::json::array();
for (const auto& [name, info] : m_modules) {
nlohmann::json entry;
entry["name"] = name;
entry["path"] = info.path;
entry["loaded"] = info.loaded;
// Unix-seconds timestamp of the current load (0 when not loaded).
// Callers compute uptime as now - loaded_at while loaded.
entry["loaded_at"] = info.loadedAt;
entry["dependencies"] = info.dependencies;
entry["dependents"] = info.dependents;
// Parse the cached metadata JSON back into structured form. Tolerate a
// missing/garbled blob by reporting null rather than aborting the call.
if (info.metadataJson.empty()) {
entry["metadata"] = nlohmann::json(nullptr);
} else {
nlohmann::json meta = nlohmann::json::parse(
info.metadataJson, nullptr, /*allow_exceptions=*/false);
entry["metadata"] = meta.is_discarded() ? nlohmann::json(nullptr) : meta;
}
modules.push_back(std::move(entry));
}
return modules;
}
std::vector<std::string> ModuleRegistry::moduleDependencies(const std::string& name,
bool recursive) const {
std::shared_lock lock(m_mutex);
@@ -322,9 +351,18 @@ bool ModuleRegistry::isLoaded(const std::string& name) const {
return it != m_modules.end() && it->second.loaded;
}
// Current wall-clock time in unix seconds. Stamped on load so callers can
// derive a module's uptime; a free function so both markLoaded overloads
// agree on the source.
static int64_t nowUnixSeconds() {
return static_cast<int64_t>(std::time(nullptr));
}
void ModuleRegistry::markLoaded(const std::string& name) {
std::unique_lock lock(m_mutex);
m_modules[name].loaded = true;
auto& info = m_modules[name];
info.loaded = true;
info.loadedAt = nowUnixSeconds();
}
void ModuleRegistry::markLoaded(const std::string& name,
@@ -333,6 +371,7 @@ void ModuleRegistry::markLoaded(const std::string& name,
std::unique_lock lock(m_mutex);
auto& info = m_modules[name];
info.loaded = true;
info.loadedAt = nowUnixSeconds();
info.loader = std::move(loader);
info.handle = std::move(handle);
}
@@ -348,8 +387,10 @@ ModuleRegistry::loaderFor(const std::string& name) const {
void ModuleRegistry::markUnloaded(const std::string& name) {
std::unique_lock lock(m_mutex);
auto it = m_modules.find(name);
if (it != m_modules.end())
if (it != m_modules.end()) {
it->second.loaded = false;
it->second.loadedAt = 0;
}
}
std::vector<std::string> ModuleRegistry::loadedModuleNames() const {
+14
View File
@@ -23,6 +23,10 @@ bool isValidModuleName(const std::string& name);
struct ModuleInfo {
std::string path;
// The module's full embedded metadata as a compact JSON string, read once
// at discovery time via ModuleLib::LogosModule (no plugin instantiation).
// Empty when the plugin exposes no readable metadata.
std::string metadataJson;
std::vector<std::string> dependencies;
// Direct reverse edges — names of modules whose `dependencies` list
// includes this module. Kept in sync with `dependencies` across every
@@ -30,6 +34,10 @@ struct ModuleInfo {
// directly. Use ModuleRegistry::moduleDependents() for transitive walks.
std::vector<std::string> dependents;
bool loaded = false;
// Unix timestamp (seconds) of the most recent load, set by markLoaded and
// cleared to 0 by markUnloaded. 0 ⟺ not currently loaded. Callers derive a
// module's uptime from it (now - loadedAt), valid only while loaded.
int64_t loadedAt = 0;
// Null when loaded directly via markLoaded(name) (test/external scenarios).
std::shared_ptr<LogosCore::ModuleLoader> loader;
LogosCore::LoadedModuleHandle handle;
@@ -46,6 +54,12 @@ public:
bool isKnown(const std::string& name) const;
std::string modulePath(const std::string& name) const;
// A JSON array describing every known module: one object per module with
// its name, path, loaded flag, load timestamp (loaded_at, unix seconds; 0
// when not loaded), direct dependencies, direct dependents, and full
// embedded metadata (parsed from the cached metadata JSON; null when
// unreadable). This is the data backing logos_core_get_modules_info.
nlohmann::json allModulesInfo() const;
// Forward-edge accessor. `recursive=false` returns the direct
// dependencies stored on ModuleInfo. `recursive=true` walks the forward
// graph breadth-first and returns every transitive dependency. Unknown
+87
View File
@@ -146,6 +146,66 @@ TEST_F(ModuleManagerTest, GetKnownModules_ReturnsCorrectHash) {
freeStringArray(result);
}
// logos_core_get_modules_info returns one rich JSON entry per known module:
// name, path, loaded flag, direct dependencies, direct dependents, and the
// embedded metadata. (Registered fake modules have no plugin file, so their
// metadata is null — the real-plugin metadata is covered separately.)
TEST_F(ModuleManagerTest, GetModulesInfo_ReturnsRichEntryPerModule) {
logos_core_register_module("module_a", "/path/to/module_a.dylib");
logos_core_register_module("module_b", "/path/to/module_b.dylib");
const char* depsA[] = {"module_b"};
logos_core_register_module_dependencies("module_a", depsA, 1);
logos_core_mark_module_loaded("module_b");
char* json = logos_core_get_modules_info();
ASSERT_NE(json, nullptr);
nlohmann::json info = nlohmann::json::parse(json, nullptr, /*allow_exceptions=*/false);
free(json);
ASSERT_TRUE(info.is_array());
ASSERT_EQ(info.size(), 2u);
auto find = [&](const std::string& n) -> nlohmann::json {
for (const auto& e : info)
if (e.value("name", std::string{}) == n) return e;
return nlohmann::json();
};
nlohmann::json a = find("module_a");
ASSERT_FALSE(a.is_null());
EXPECT_EQ(a.value("path", std::string{}), "/path/to/module_a.dylib");
EXPECT_FALSE(a.value("loaded", true));
// Not loaded ⇒ loaded_at is 0.
EXPECT_EQ(a.value("loaded_at", int64_t{-1}), 0);
ASSERT_TRUE(a["dependencies"].is_array());
ASSERT_EQ(a["dependencies"].size(), 1u);
EXPECT_EQ(a["dependencies"][0].get<std::string>(), "module_b");
EXPECT_TRUE(a["dependents"].is_array());
EXPECT_TRUE(a["dependents"].empty());
// metadata key is always present; null for a registered (un-processed) module.
ASSERT_TRUE(a.contains("metadata"));
EXPECT_TRUE(a["metadata"].is_null());
nlohmann::json b = find("module_b");
ASSERT_FALSE(b.is_null());
EXPECT_TRUE(b.value("loaded", false));
// Loaded ⇒ loaded_at is a real timestamp (stamped at markLoaded).
EXPECT_GT(b.value("loaded_at", int64_t{0}), 0);
// module_a depends on module_b ⇒ module_b lists module_a as a dependent.
ASSERT_TRUE(b["dependents"].is_array());
ASSERT_EQ(b["dependents"].size(), 1u);
EXPECT_EQ(b["dependents"][0].get<std::string>(), "module_a");
}
TEST_F(ModuleManagerTest, GetModulesInfo_EmptyWhenNoModules) {
char* json = logos_core_get_modules_info();
ASSERT_NE(json, nullptr);
nlohmann::json info = nlohmann::json::parse(json, nullptr, /*allow_exceptions=*/false);
free(json);
ASSERT_TRUE(info.is_array());
EXPECT_TRUE(info.empty());
}
TEST_F(ModuleManagerTest, IsModuleLoaded_ReturnsFalseForUnloaded) {
EXPECT_EQ(logos_core_is_module_loaded("nonexistent_module"), 0);
}
@@ -626,6 +686,33 @@ TEST_F(RealModuleRegistryTest, ProcessModule_RegistersRealModule) {
delete[] name;
}
// For a real plugin, get_modules_info must carry the embedded metadata parsed
// straight from the binary (via ModuleLib::LogosModule) — name + version.
TEST_F(RealModuleRegistryTest, GetModulesInfo_PopulatesEmbeddedMetadata) {
char* name = logos_core_process_module(modulePath.c_str());
ASSERT_NE(name, nullptr) << "process_module failed for " << modulePath;
std::string moduleName(name);
delete[] name;
char* json = logos_core_get_modules_info();
ASSERT_NE(json, nullptr);
nlohmann::json info = nlohmann::json::parse(json, nullptr, /*allow_exceptions=*/false);
free(json);
ASSERT_TRUE(info.is_array());
nlohmann::json entry;
for (const auto& e : info)
if (e.value("name", std::string{}) == moduleName) { entry = e; break; }
ASSERT_FALSE(entry.is_null()) << "processed module absent from modules-info";
EXPECT_FALSE(entry.value("path", std::string{}).empty());
ASSERT_TRUE(entry["metadata"].is_object())
<< "real plugin must yield a non-null metadata object";
EXPECT_EQ(entry["metadata"].value("name", std::string{}), moduleName);
EXPECT_FALSE(entry["metadata"].value("version", std::string{}).empty())
<< "built test modules declare a version in metadata.json";
}
// =============================================================================
// Security regression: privileged-name impersonation during discovery (F-022).
//