`logosctl daemon stop` printed {"code":"RPC_FAILED","message":"shutdown RPC
call failed."} and exited 3 for shutdowns that had already succeeded. It cost
the "Stop the daemon" step of doctests/logosctl-daemon.test.yaml one failure
out of nine identical shutdowns in the same macOS CI job; the daemon really
had stopped, and `daemon status` two seconds later said so.
Two independent defects, one on each side of the call.
DAEMON. CoreServiceImpl::shutdown() returned {"status":"ok"} and left the
event loop from a detached std::thread that slept 200ms and called
QCoreApplication::quit(). The reply is not on the wire at that point: the
transport serialises it after the handler returns and hands it to the socket,
which only pushes it out when the event loop services that socket's write
notifier. quit() is not a queued event -- QCoreApplication::exit() interrupts
the dispatcher directly -- so if the main thread was descheduled for longer
than the sleep, the loop came back, exited, and the buffered reply died with
the process. QtRO surfaces no transport error for this; the client just waited
out its 20s deadline and saw nothing.
The quit now runs on the main thread, from a timer, and drains the event loop
before ending it. The 200ms is now a courtesy margin rather than the
correctness mechanism, and $LOGOSCTL_SHUTDOWN_GRACE_MS makes it settable --
including to 0, which the new regression test uses because it is the setting
that used to lose the reply outright.
QtRO offers nothing better: QRemoteObjectHostBase has no per-reply
write-completion signal and no client-disconnect signal, so "quit when the
response has actually been flushed" is not reachable without forking Qt, and
the daemon also serves plain TCP/TLS through a different transport.
CLIENT. RpcClient::shutdown() reported RPC_FAILED whenever the reply was not
an object -- including when there was no reply. But a missing reply is the
expected outcome of asking a process to die, and both docs said so already:
docs/spec.md promised "the client treats the connection loss as a successful
shutdown" and docs/project.md promised exit 0 for it. Neither was implemented.
It now answers the question the reply was standing in for, from evidence: the
pid recorded in daemon/state.json (snapshotted before the call, since a clean
shutdown deletes that file) is watched for up to 15s, or for a remote daemon
the endpoint is re-probed. Gone means success, with `confirmed_by` naming the
evidence; still running means a real error, with a message that says which.
Blindly treating silence as success would have been the more dangerous
mistake -- a wedged daemon is also silent -- so it is not what this does.
That inference is only sound about a pid that was alive to begin with, so
`stop` now refuses a stale session up front the way `daemon status` already
does: a state.json naming this client's instance and a dead pid means there is
no daemon to stop (NO_DAEMON, exit 2). Without it, a session left behind by
last week's daemon would "connect" to nothing, time out, observe that the pid
is gone, and call that a successful shutdown.
TESTS.
* ShutdownReplyTest.StopSucceedsWithNoGracePeriod (integration): 60
start/stop cycles at LOGOSCTL_SHUTDOWN_GRACE_MS=0, asserting the command
succeeds, the daemon is actually gone, and the reply arrived rather than
being reconstructed from the process exiting. Measured through this
fixture on macOS: 6 losses in 100 cycles before the daemon fix, 0 in 120
after.
* CommandTest.Stop_StaleSession_* : the stale-session guard, its live-pid
control, and the remote-client case it must not block. CommandTest now
isolates HOME and the config dir, so the suite no longer reads whichever
~/.logosctl the developer happens to have.
* ProcessUtil.WaitForProcessExit* : the primitive the confirmation rests on.
A/B over the shipped binaries, 30 stop cycles per arm at zero grace, macOS:
pre-fix 8 failures; daemon fix only 0 (no reply lost); client fix only 0
(20 replies lost, every command still correct); both 0. At the default 200ms
grace both arms are clean, which is why this presented as a rare CI flake.
Independent of PR #99: that PR does not touch either function, and the two
diffs do not overlap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
65 KiB
Logosctl CLI — Project Description
Overview
The logosctl CLI is a standalone application that provides the command-line interface for the Logos Core runtime. It depends on liblogos, a C library that provides the core runtime (plugin discovery, loading, dependency resolution, event loop). The CLI is responsible for:
- Running as a daemon that hosts the liblogos runtime
- Providing client commands that talk to the daemon via RPC
This project will live in its own repository. liblogos is consumed as an external C library dependency.
Project Structure
logos-logoscore-cli/
├── src/ # All CLI source code
│ ├── main.cpp # logosctl entry point — detects mode, dispatches
│ ├── main_legacy.cpp # logoscore entry point — same daemon/client/core_service
│ │ # code below, frozen surface (see the README)
│ ├── config.cpp/h # Token + config file resolution
│ ├── paths.cpp/h # Executable/bundle-relative path resolution (no Qt)
│ │
│ ├── daemon/ # Daemon path (logosctl daemon start)
│ │ ├── daemon.cpp/h # Start core, register core_service, run event loop,
│ │ │ # open each --module-transport listener
│ │ ├── daemon_state.cpp/h # DaemonConfig (config.json) + DaemonRuntimeState
│ │ │ # (state.json) — operator preferences (writes only
│ │ │ # on --persist-config) + live runtime state.
│ │ ├── access_policy_arg.cpp/h # Resolve --access-policy into the JSON document
│ │ │ # handed to logos_core_set_access_policy()
│ │ ├── log_sink.cpp/h # Pipe-based capture of daemon + module-host
│ │ │ # stdout/stderr into a rotating log file
│ │ ├── port_allocator.cpp/h # Reserve an ephemeral TCP port before spawning a child
│ │ └── token_store.cpp/h # Named-token table — TokensFile owns daemon/tokens.json
│ │ # (hashed entries) + raw daemon/tokens/<name>.json
│ │
│ ├── client/ # Client path (all subcommands)
│ │ ├── client.cpp/h # Client interface + RpcClient — connect to the
│ │ │ # daemon's core_service via LogosAPIClient
│ │ ├── client_state.cpp/h # Read/write <configDir>/client/config.json (dial spec)
│ │ ├── output.cpp/h # Output formatter (human / JSON / NDJSON)
│ │ └── commands/ # Subcommand implementations
│ │ ├── command.cpp/h # Base command class
│ │ ├── status_command.cpp/h
│ │ ├── load_module_command.cpp/h
│ │ ├── unload_module_command.cpp/h
│ │ ├── reload_module_command.cpp/h
│ │ ├── list_modules_command.cpp/h
│ │ ├── module_info_command.cpp/h
│ │ ├── call_command.cpp/h
│ │ ├── watch_command.cpp/h
│ │ ├── stats_command.cpp/h
│ │ ├── stop_command.cpp/h
│ │ ├── package_command.cpp/h # install / remove / update (plan + apply)
│ │ ├── catalog_command.cpp/h # Browse + download from the online catalog
│ │ ├── config_command.cpp/h # Inspect/edit the config tree
│ │ ├── issue_token_command.cpp/h # Mints named tokens (daemon/tokens/<name>.json)
│ │ ├── revoke_token_command.cpp/h # Revokes by name
│ │ └── list_tokens_command.cpp/h # Lists issued tokens (name + metadata, no plaintext)
│ │
│ └── core_service/ # Built-in module — CLI ↔ daemon RPC gateway
│ ├── core_service_impl.h # Plain C++ class deriving LogosProviderObject; its
│ │ # public methods ARE the API (no marker macro — there
│ │ # used to be LOGOS_PROVIDER/LOGOS_METHOD here)
│ ├── core_service_impl.cpp # Method implementations (delegates to liblogos C API)
│ ├── package_ops.cpp/h # Daemon-side plan/apply for package operations
│ ├── metadata.json # Plugin metadata
│ └── core_service_dispatch.cpp # Hand-written callMethodStd/getMethodsStd dispatch
│ # (no core_service_loader.h: the daemon registers the
│ # object in-process, it is never discovered as a plugin)
│
├── tests/
│ ├── test_commands.cpp # Subcommands against a mock Client
│ ├── test_mode_detection.cpp # Mode detection, subcommand dispatch
│ ├── test_output.cpp # Output formatter tests
│ ├── test_daemon_state.cpp # daemon/state.json + tokens round-trip
│ ├── test_token_store.cpp # Token issue / revoke / list / persistence
│ ├── test_config.cpp # Token + config-dir resolution
│ ├── test_paths.cpp # Executable/bundle path resolution
│ ├── test_port_allocator.cpp # Ephemeral-port allocation
│ ├── test_access_policy_arg.cpp # --access-policy argument resolution
│ ├── test_log_sink.cpp # Log capture + rotation
│ ├── test_cli.cpp # End-to-end logosctl CLI
│ ├── test_integration.cpp # logosctl against a live daemon
│ ├── test_cli_logoscore.cpp # End-to-end logoscore CLI (frozen duplicate)
│ └── test_integration_logoscore.cpp # logoscore against a live daemon (frozen duplicate)
│
├── docs/
│ ├── index.md # Doc index
│ ├── spec.md # CLI specification (user-facing behavior)
│ ├── project.md # This file (implementation details)
│ ├── logoscore.md # logoscore user guide
│ └── logosctl.md # logosctl user guide
│
├── doctests/ # Executable documentation specs
├── CMakeLists.txt # Build configuration
├── flake.nix # Nix flake
└── nix/ # Nix build modules
Dependencies
| Dependency | Type | Purpose |
|---|---|---|
| liblogos | C library (external) | Core runtime: plugin discovery, loading, dependency resolution, event loop, process stats |
| logos-cpp-sdk | C++ library (external) | Qt-free SDK surface the CLI links directly (logos_sdk), incl. logos::transportSetToJsonString |
| logos-protocol | C++ library (external) | Transport + provider protocol: LogosProviderObject, ModuleProxy, TokenManager |
| logos-qt-host (in logos-plugin-qt) | C++ library (external) | The Qt host runtime the daemon and its in-process core service are built on: LogosAPI, LogosAPIClient, LogosAPIProvider. Comes from logos-plugin-qt, not logos-qt-sdk |
| Qt6 Core | Framework | Event loop, JSON handling, process management |
| Qt6 RemoteObjects | Framework | IPC between daemon and module host processes |
| CMake 3.14+ | Build system | — |
| Google Test | Test framework | — |
| Nix | Package manager | Reproducible builds |
No code generator is in this build. core_service used to be listed here
as depending on logos-cpp-generator to emit a LOGOS_METHOD dispatch table;
that marker macro and that generator mode are gone from this repo's path —
core_service_dispatch.cpp is hand-written (see Build integration below).
liblogos C API surface used
The CLI uses these functions from liblogos (declared in logos_core.h):
| Function | Used by |
|---|---|
logos_core_init(argc, argv) |
Daemon |
logos_core_add_modules_dir(path) |
Daemon |
logos_core_start() |
Daemon |
logos_core_exec() |
Daemon |
logos_core_cleanup() |
Daemon |
logos_core_load_module(name, true) |
Daemon, core_service |
logos_core_unload_module(name, false) |
core_service |
logos_core_get_known_modules() |
core_service |
logos_core_get_loaded_modules() |
core_service |
logos_core_get_modules_info() |
core_service |
logos_core_get_module_stats() |
core_service |
CLI Execution Paths
The logosctl binary detects its mode from the first argument and dispatches to one of two paths:
logosctl daemon start / daemon → Daemon path (long-running, hosts modules)
logosctl <subcommand> → Client path (short-lived, talks to daemon)
Detection logic (main.cpp)
Before mode detection, main() scans argv for -v/--verbose and installs a custom Qt message handler that suppresses debug/info/warning logs unless verbose is set.
if argv contains "-D" or "daemon" → daemon path
else if argv[1] is a known subcommand → client path
else if argv contains -m/-p (no -D) → error (inline mode removed)
else → print help
Daemon Path (logosctl daemon start)
main.cpp
→ Daemon::start(modulesDirs, persistencePath, transportInfos)
1. Generate instance ID, set LOGOS_INSTANCE_ID env var
Refuse to start if a live daemon already owns this config-dir: read
daemon/state.json and, if its pid is still alive (kill(pid,0)), exit 1.
A stale state.json from a crashed daemon (pid gone) is not a live
owner and is overwritten normally. This runs before logos_core_init so
a duplicate launch fails fast instead of spawning module hosts first.
2. logos_core_init(argc, argv)
3. logos_core_add_modules_dir() for each -m path
4. logos_core_start() // discover modules
5. Register core_service (and capability_module) in-process via
LogosAPI/LogosAPIProvider. For each --module-transport NAME=PROTOCOL[,k=v]
flag, pass the resolved TransportInfo to LogosAPIProvider so it opens
one listener on the named module (local + tcp + tcp_ssl can coexist).
6. Mint the auto token, hash it into tokens.json["tokens"], emit raw
value into client/auto.json, register hashes with TokenManager
7. Write <configDir>/daemon/state.json // resolved listeners + instance_id
(Tokens already persisted to daemon/tokens.json by step 6.)
Write <configDir>/client/config.json // local-default dial spec (first-boot only)
If --persist-config: write <configDir>/daemon/config.json (operator intent)
8. Print startup message to stdout
9. logos_core_exec() // Qt event loop (blocks)
10. On SIGINT/SIGTERM or shutdown RPC:
logos_core_cleanup()
Remove daemon/state.json (tokens.json + config.json survive)
exit(0)
The daemon path calls the liblogos C API directly. It owns the runtime and hosts all modules, including the built-in core_service module. Startup/shutdown messages go to stdout (so > logs.txt works); debug logs go to stderr and are suppressed unless --verbose is passed.
Multi-transport. --module-transport is repeatable, scoped per
module (well-known or user-configured). When the daemon exposes a
module over several transports at once, each one becomes an entry under
that module's transports array in daemon/state.json, and the provider
maintains one listener per entry.
Local is always present. Every configured module — well-known or
user — implicitly carries a LocalSocket listener prepended to its
resolved set, even when the operator only passed --module-transport NAME=tcp,.... The operator's TCP / TCP+SSL flags add additional
outside-facing listeners; they don't replace the same-host LocalSocket.
This is what keeps module ↔ module traffic working on the local socket
in every configuration: the parent's notifyCapabilityModule handshake,
the SDK's auto-requestModule flow inside LogosAPIClient, and any
cross-module getClient(name) calls all default to LocalSocket and
have no plumbing to discover the operator's chosen TCP endpoint —
forcing a LocalSocket listener alongside whatever else the operator
named keeps those paths working without fan-out. The advertised
transports[] array always lists the LocalSocket entry first,
followed by operator-named entries in the order they were typed.
Plaintext-TCP guard. Plaintext tcp listeners on a non-loopback host
expose tokens in cleartext. The daemon refuses to bind such a listener
unless --insecure-tcp was passed.
IPv6 bind targets. Ephemeral-port allocation (used when a transport asks
the kernel to pick a port) binds on the address family that matches the host:
an IPv6 literal such as :: or ::1 allocates on AF_INET6, IPv4 literals on
AF_INET. (Previously the allocator was IPv4-only and returned 0 for any IPv6
host, aborting daemon startup for IPv6 TCP transports.)
Strict port parsing. A --module-transport ...,port=<n> value must be a
whole valid integer — trailing garbage (6000x) or hex (0x1F90) is a hard
error (exit 1), not a silently-wrong or auto-allocated 0 port.
Named tokens. TokenStore (owned by the daemon) persists the issued-token
table inside <configDir>/daemon/tokens.json["tokens"] ({name, hash, issued_at, expires_at, local_only} rows; hashes are SHA-256 hex). The auto
token's hash lands there too at boot, with its raw value emitted to
client/auto.json. Named tokens from issue-token --name <n> are
additionally written to daemon/tokens/<n>.json for distribution to a
specific client; once copied to the target host, the daemon-side raw file may
be deleted because validation runs against the in-memory map seeded from the
hashes.
Client Path (logosctl <subcommand>)
main.cpp
→ Client::connect()
1. Read <configDir>/client/config.json (dial spec + instance_id + token_file)
2. Set LOGOS_INSTANCE_ID env var from instance_id
→ now LogosInstance::id("core_service") returns the correct registry URL
3. Read the raw token from token_file (or LOGOSCTL_TOKEN env var if set)
4. Build LogosTransportConfig from the dial spec (endpoint/host/port/codec
and cert/key/ca/verify_peer for TLS) — applied per-connection only,
never installed as a process-wide default (the SDK's LogosAPIProvider
reads the global default to bind its own server socket, so flipping
the default would try to bind a TLS server with no cert/key and abort)
5. Create LogosAPIClient targeting "core_service" with that explicit
transport config (LogosAPI itself stays on the local-socket default)
6. Authenticate with token
→ Command::execute(args)
1. Call a core_service method by name via LogosAPIClient
2. Format result (human / JSON)
3. Print to stdout, exit
Client commands never call liblogos C API functions, and they never read
daemon/state.json. They talk exclusively to the daemon's core_service
module via the SDK's RPC mechanism, using whatever dial spec
client/config.json provides. This means the client path depends only on
logos-cpp-sdk, not on liblogos.
Liveness is no longer a separate pre-check. The previous PID-alive probe
only worked for local daemons — it's meaningless for a daemon in a container
or across NAT. The first RPC (commonly status) surfaces connect failures
through the same timeout/error path as any other method, so there's one error
story. DaemonRuntimeStateFile::read().fileOk now just reflects "file exists
and parses" — the on-disk precondition, not liveness. The status command
does opportunistically kill(pid, 0) against state.json's pid for fast
same-host stale-state detection, but that's a short-circuit before falling
through to the same RPC path.
Inline Path (removed)
The legacy inline path (logosctl -m -l -c "module.method(args)" --quit-on-finish)
started the core in the same short-lived process, loaded modules, executed the
-c calls directly via the C API, and exited. It has been removed — use a
daemon (-D) plus load-module / call client subcommands instead. The
daemon starts clean; -m/--persistence-path configure daemon startup only
(the -l/--load-modules autoload flag was also removed).
CoreService Module
The core_service module is the RPC gateway between CLI clients and the daemon. It is a proper Logos module — it implements the same LogosProviderObject interface the runtime calls on every module — but it lives in the CLI codebase (not in liblogos) because it is the CLI's concern — it exists to serve CLI clients.
Its business methods are Qt-free: they take and return std::string /
LogosMap / LogosList / StdLogosResult, and the Qt side of
LogosProviderObject is satisfied by trivial delegates in
core_service_dispatch.cpp. Its plain public methods are its API — see
Definition below for what replaced the old marker-macro spelling.
Why a module?
- Uses the same SDK API as any other module — no special plumbing
- CLI clients connect to it via
LogosAPIClient, same as module-to-module communication - Auth tokens work the same way (TokenManager validates the client token)
- Events can be forwarded using the standard event system
- If needed in the future, it could be extracted into a standalone plugin
Definition
Files: src/core_service/core_service_impl.h
#include <logos_provider_object.h>
class CoreServiceImpl : public LogosProviderObject
{
public:
// Emitted events go out through this hook, installed by
// setEventListenerStd() (see core_service_dispatch.cpp).
std::function<void(const std::string& eventName,
const std::string& data)> emitEvent;
// Module lifecycle
StdLogosResult loadModule(const std::string& name);
// withDependents cascades the unload to dependents, leaves-first.
StdLogosResult unloadModule(const std::string& name, bool withDependents);
StdLogosResult reloadModule(const std::string& name);
// Re-scan the module directories so packages installed since boot
// become discoverable without restarting the daemon.
LogosMap refreshModules();
// Package operations, split plan/apply so the client can prompt.
LogosMap planPackageOperation(const std::string& op,
const LogosList& names, const LogosMap& opts);
LogosMap applyPackageOperation(const std::string& op,
const LogosList& names, const LogosMap& opts);
LogosMap downloadPackage(const std::string& name, const LogosMap& opts);
// Queries
LogosList listModules(const std::string& filter);
LogosMap getStatus();
LogosMap getModuleInfo(const std::string& name);
LogosList getModuleStats();
// Proxied call — delegates to target module
StdLogosResult callModuleMethod(const std::string& module,
const std::string& method,
const LogosList& args);
// Event forwarding
bool watchModuleEvents(const std::string& module,
const std::string& eventName);
// Daemon lifecycle
LogosMap shutdown();
void onInit(LogosAPI* api);
// LogosProviderObject — Qt side (trivial delegates to the std bridge)
QVariant callMethod(const QString& methodName, const QVariantList& args) override;
QJsonArray getMethods() override;
QString providerName() const override;
QString providerVersion() const override;
void setEventListener(EventCallback callback) override;
bool informModuleToken(const QString& moduleName, const QString& token) override;
void init(void* apiInstance) override;
// LogosProviderObject — universal (Qt-free) dispatch
nlohmann::json callMethodStd(const std::string& methodName,
const nlohmann::json& args) override;
std::vector<LogosMethodMetadata> getMethodsStd() override;
void setEventListenerStd(UniversalEventCallback callback) override;
private:
EventCallback m_eventCallback;
LogosAPI* m_api = nullptr;
};
There is no marker macro on these declarations. (There used to be a
LOGOS_PROVIDER(...) line and a LOGOS_METHOD prefix on each callable
method, scanned by a code generator; neither is used here any more.)
How each method works
| Method | What it does (daemon-side) |
|---|---|
loadModule(name) |
Calls logos_core_load_module(name, true). Returns {"status":"ok","module":"...","version":"...","dependencies_loaded":[...]} |
unloadModule(name, withDependents) |
Calls logos_core_unload_module(name, withDependents). With withDependents (the CLI default) liblogos cascades the unload to every module that depends on name, leaves-first, so nothing is left talking to a dead provider. Returns {"status":"ok","module":"...","dependents_unloaded":[...]} |
refreshModules() |
Re-scans the daemon's module directories so a package installed since boot becomes loadable without a restart — this is what lets install be followed by load in one session |
planPackageOperation(op, names, opts) / applyPackageOperation(op, names, opts) |
Daemon-side plan/apply for install / remove / update (see src/core_service/package_ops.h). The split exists so the client can show what would change and prompt; --dry-run stops after the plan |
downloadPackage(name, opts) |
Fetches a .lgx without installing it. Daemon-side because the downloader drops the file in the daemon's $TMPDIR, so the move to the requested directory has to happen on that host |
reloadModule(name) |
Checks if loaded/crashed → unload if needed → load. Returns result with previous_status. Non-destructive on failure: if the module was loaded before and the reload's load step fails, it attempts to restore the prior instance and reports restored: true/false plus an explanatory error rather than leaving the module down |
listModules(filter) |
Calls logos_core_get_modules_info() (name + loaded flag + embedded metadata per module). Emits version from metadata + status enum. Returns JSON array |
getStatus() |
Reads daemon state (PID, uptime, version) + calls listModules("all"). Returns {"daemon":{...},"modules_summary":{...},"modules":[...]} |
getModuleInfo(name) |
Pulls the module's entry from logos_core_get_modules_info() (version from embedded metadata, dependencies, dependents) and, for loaded modules, methods/events via SDK introspection over RPC. Returns extended JSON |
getModuleStats() |
Calls logos_core_get_module_stats(). Returns CPU/memory per module |
callModuleMethod(module, method, args) |
Uses m_api->getClient(module)->invokeRemoteMethod() to proxy the call to the target module. Returns the result. LogosResult return values are unpacked into {success, value, error} here so that the JSON shape is identical regardless of whether the daemon-module hop went over the local socket (QRO) or the plain-C++ transport (tcp / tcp_ssl). |
watchModuleEvents(module, event) |
Registers an event listener on the target module via m_api->getClient(module)->onEvent(). Forwards received events by calling emitEvent() on core_service, which the CLI client receives over its own event subscription |
shutdown() |
Schedules QCoreApplication::quit() after a 200ms delay (to allow the RPC response to be sent), then the daemon performs its normal cleanup (unload modules, remove daemon/state.json, exit) |
Loader — there isn't one
core_service is not discovered as a plugin, so it has no
Q_PLUGIN_METADATA loader class. (This section used to document a
src/core_service/core_service_loader.h holding a CoreServiceLoader : QObject, PluginInterface, LogosProviderPlugin with a createProviderObject()
factory. That file no longer exists.) The daemon constructs CoreServiceImpl
itself and hands it to the provider — see Registration (daemon-side) below.
Metadata
Files: src/core_service/metadata.json
Declarative identity only — nothing in the build reads it, because there is no
plugin to attach it to. CoreServiceImpl::name() / ::version() return the
same values in code.
{
"name": "core_service",
"version": "1.0.0",
"type": "core",
"category": "management",
"description": "RPC gateway for CLI client commands"
}
Registration (daemon-side)
The daemon registers core_service as an in-process module during startup, before entering the event loop:
// In Daemon::start()
auto* coreServiceApi = new LogosAPI("core_service", coreTransports);
auto* coreServiceImpl = new CoreServiceImpl();
coreServiceImpl->init(coreServiceApi);
auto* provider = coreServiceApi->getProvider();
// Accept operator-issued named tokens, not just the boot `auto` token.
// Installed before registerObject so the proxy is validated from its
// first published call.
provider->setTokenValidator(...);
provider->registerObject("core_service", static_cast<LogosProviderObject*>(coreServiceImpl));
This registers the module directly into the runtime using the Qt host classes (LogosAPI, LogosAPIProvider) without directory scanning. The daemon also saves a client token via TokenManager::instance().saveToken("cli_client", token) so CLI clients can authenticate.
Build integration
The core_service_dispatch.cpp file provides a hand-written callMethodStd() dispatch table and getMethodsStd() metadata for CoreServiceImpl, plus the trivial Qt-side delegates (callMethod, getMethods, setEventListener, …) that bridge to them. Dynamically loaded modules get this glue generated for them from their header; core_service is written by hand because it is statically linked into the daemon binary and never goes through a module build.
The dispatch wraps argument coercion in a try/catch: a malformed RPC (e.g. a number where a string arg is expected, which makes args[i].get<std::string>() throw nlohmann::json::type_error) is converted into a structured {status:"error", code:"INVALID_ARGS", message:...} response instead of an uncaught exception that would propagate through the Qt event loop and terminate the whole daemon. This keeps one authenticated client from crashing the daemon with a single bad argument.
// core_service_dispatch.cpp — maps method names to CoreServiceImpl methods
nlohmann::json CoreServiceImpl::callMethodStd(const std::string& methodName,
const nlohmann::json& args) {
try {
if (methodName == "loadModule" && args.size() >= 1)
return stdLogosResultToJson(loadModule(args[0].get<std::string>()));
if (methodName == "shutdown") return shutdown();
// ... etc
return nullptr;
} catch (const std::exception& e) { /* -> INVALID_ARGS envelope */ }
}
Components
main.cpp (entry point)
Files: src/main.cpp
Purpose: Detect execution mode and dispatch to the appropriate path.
API:
| Function | Description |
|---|---|
detectMode(argc, argv) -> Mode |
Returns Daemon or Client |
main(argc, argv) -> int |
Dispatch to Daemon::start or a Client command |
Daemon
Files: src/daemon/daemon.cpp/h
Purpose: Manage the daemon lifecycle: start liblogos, register core_service, write the daemon state file, emit the local-default client config, handle signals for clean shutdown.
API:
| Method | Description |
|---|---|
Daemon::start(modulesDirs) -> int |
Init liblogos, register core_service, write daemon/state.json + emit client/config.json and client/auto.json, run event loop |
Daemon::setupSignalHandlers() |
Handle SIGINT/SIGTERM for clean shutdown |
DaemonConfigFile + DaemonRuntimeStateFile
Files: src/daemon/daemon_state.cpp/h
Purpose: Manage the two daemon-side config-tree files. DaemonConfigFile reads/writes <configDir>/daemon/config.json (operator preferences, written only when --persist-config is passed). DaemonRuntimeStateFile writes <configDir>/daemon/state.json on every successful boot and removes it at shutdown. Both files are daemon-owned; the client never reads config.json, and only consults state.json for a fast same-host liveness check.
API:
| Method | Description |
|---|---|
DaemonConfigFile::read() -> optional<DaemonConfig> |
Parse daemon/config.json. Returns nullopt if missing or schema-mismatched. |
DaemonConfigFile::write(cfg) |
Atomic write of operator-intent values. port: 0 stays 0 (resolved values live in state.json). |
DaemonRuntimeStateFile::write(state) |
Atomic write of resolved live state (instance_id, pid, started_at, resolved.modules with actually-bound ports). The temp file staged before the atomic rename is per-writer-unique (<path>.tmp.<pid>.<seq>) so concurrent writers can't truncate/rename the same temp and corrupt the result. |
DaemonRuntimeStateFile::read() -> DaemonRuntimeState |
Parse state.json. fileOk is true iff the file exists with a non-empty instance_id — says nothing about liveness; pair with kill(pid, 0) for that. |
DaemonRuntimeStateFile::remove() |
Remove state.json. Called from clean shutdown / aboutToQuit hook. |
state.json format (lifecycle: created at boot, removed at shutdown):
{
"version": 2,
"instance_id": "a3f1c8d20b4e",
"pid": 12345,
"started_at": "2026-03-23T14:00:00Z",
"config_source": "cli",
"resolved": {
"modules_dirs": ["/path/to/modules"],
"persistence_path": "/var/lib/logosctl",
"modules": {
"core_service": {
"transports": [
{ "protocol": "local" },
{ "protocol": "tcp", "host": "0.0.0.0", "port": 6000, "codec": "json" },
{ "protocol": "tcp_ssl", "host": "0.0.0.0", "port": 6443,
"codec": "cbor", "ca_file": "/etc/logosctl/ca.pem",
"verify_peer": true }
]
},
"capability_module": {
"transports": [
{ "protocol": "local" },
{ "protocol": "tcp", "host": "127.0.0.1", "port": 6001, "codec": "json" }
]
}
},
"ssl": { "cert": "", "key": "", "ca": "" },
"insecure_tcp": false
}
}
config.json format (lifecycle: written only on --persist-config): same as state.json's resolved block + version. Reflects operator intent (port: 0 stays 0).
tokens.json format (lifecycle: independent — survives daemon restarts):
{
"version": 2,
"tokens": [
{ "name": "auto", "hash": "<sha256-hex>", "issued_at": "...", "expires_at": null, "local_only": true },
{ "name": "alice", "hash": "<sha256-hex>", "issued_at": "...", "expires_at": "...", "local_only": false }
]
}
TokenStore
Files: src/daemon/token_store.cpp/h
Purpose: In-memory map of issued client tokens, persisted into the daemon state file. Plaintext tokens are never stored on the daemon side — only SHA-256 hashes in tokens.json["tokens"]. The raw value of each named token lives only in daemon/tokens/<name>.json at the moment of issuance, for the operator to copy off.
API:
| Method | Description |
|---|---|
TokenStore() |
Default-constructed; paths come from Config::* (the process-global config dir). Seeds itself from tokens.json["tokens"]. Tests isolate state via LOGOSCTL_CONFIG_DIR / Config::setConfigDir. |
issueToken(name, expires, localOnly, replace) -> IssueResult { status, token } |
Mint a new token. status is one of Ok / InvalidName / AlreadyExists / IoError; token is set only on Ok. The CLI keys exit-code distinct error categories off this status so operators don't see "name collision" for permission failures. Fails closed rather than corrupting state: a CSPRNG failure (empty raw token) returns IoError and persists nothing; an on-disk tokens.json whose schema version this build doesn't support returns IoError and is left byte-for-byte intact (instead of being rewritten at the current version, wiping operator tokens). On --replace the new raw token is staged to daemon/tokens/<name>.json.new and promoted only after tokens.json commits, so a failed write never destroys the still-valid prior raw token. |
revokeToken(name) -> RevokeStatus |
Remove the name entry from tokens.json["tokens"] and delete daemon/tokens/<name>.json. Returns Ok / InvalidName / NotFound / IoError. Like issueToken, refuses (IoError) to rewrite an unsupported-schema-version tokens.json. |
listTokens() -> vector<IssuedToken> |
Enumerate {name, issued_at, expires_at, local_only} — never plaintext, never the digest. |
lookupByToken(token) -> optional<Entry> |
Daemon-side: validate an incoming token against the in-memory digest map (also enforces expires_at and local_only). Fails closed on an empty token — hashToken("") is a fixed digest, so an empty credential is rejected before consulting the store and can never match a corrupt empty-hash entry. |
The on-disk digest is a SHA-256 hex string — collision-resistant by design so
two distinct tokens can never validate to the same name. The only place the
raw token ever lives is in daemon/tokens/<name>.json at the moment of
issuance; treat that file like a private key. After the operator copies it
to the client host (typically into the client's <configDir>/client/), the
daemon-side raw file may be deleted — validation keeps working because the
hash is what the daemon checks. The state file and per-token files are
written with mode 0600.
How the client finds the daemon:
The logos-cpp-sdk uses LogosInstance::id(moduleName) to build registry URLs in the format local:logos_{moduleName}_{instanceId}. The instance ID is a 12-char UUID prefix shared by all processes in the same daemon tree (via the LOGOS_INSTANCE_ID env var). Child processes (like logos_host) inherit it automatically.
The CLI client is not a child process of the daemon — it's a separate invocation. So it cannot inherit the env var. Instead:
- Daemon starts →
LogosInstance::id()generatesa3f1c8d20b4e→ setsLOGOS_INSTANCE_ID - core_service registers at
local:logos_core_service_a3f1c8d20b4e - Daemon writes
instance_idinto bothdaemon/state.jsonand the auto-emittedclient/config.json - Client reads
instance_idfromclient/config.json→ setsLOGOS_INSTANCE_ID=a3f1c8d20b4ein its own process → nowLogosInstance::id("core_service")returns the matching URL - Client connects via
LogosAPIClient→ reaches the correct daemon
The client/config.json carries instance_id rather than a hardcoded registry URL so the client can reconstruct URLs using the same LogosInstance::id() function the SDK uses internally.
The token is generated on daemon startup, hashed into tokens.json["tokens"], and emitted raw to client/auto.json for the local-default client to pick up. Client commands read it automatically via client/config.json's token_file pointer. For remote/CI usage, the token can also be passed via LOGOSCTL_TOKEN env var.
ClientStateFile
Files: src/client/client_state.cpp/h
Purpose: Read/write <configDir>/client/config.json — the client's
dial spec. This is the only daemon-tree file a client command ever opens
during normal RPC (it never touches daemon/state.json,
daemon/config.json, or daemon/tokens.json). The daemon auto-emits one
for the local-default client at boot; remote clients hand-write it (or
generate it via the --client-* flags + --persist-config).
client/config.json format (version must equal kClientStateSchemaVersion, currently 2):
{
"version": 2,
"token_file": "dario.json", // filename inside <configDir>/client/ holding
// the raw token ({"token":"<raw>",...}); read by
// readTokenFile(), which extracts the "token" field
"instance_id": "a3f1c8d20b4e", // optional; required ONLY for the LocalSocket dial
// path (registry name local:logos_<module>_<id>).
// Omitted for remote tcp / tcp_ssl clients.
"daemon": { // per-module dial spec; map key = module name
"core_service": { // mandatory — RpcClient::connect fails without it
"transport": "tcp", // "local" | "tcp" | "tcp_ssl" (strict allowlist)
"host": "192.168.1.20", // tcp / tcp_ssl
"port": 8645, // tcp / tcp_ssl (0..65535)
"codec": "json" // "json" (default) | "cbor"
},
"capability_module": { // required for any remote client: the client's own
"transport": "tcp", // LogosAPIClient does a requestModule handshake
"host": "192.168.1.20", // against capability_module before reaching
"port": 8646 // core_service (client.cpp wires this via
} // LogosAPI::setCapabilityModuleTransport)
}
}
For tcp_ssl, each module entry also accepts "ca": "<path>" and
"verify_peer": true|false.
Parsing contract (ClientStateFile::read):
- The per-module field is
transport, notprotocol. An unknown value (typo) makestransportFromJsonreturnnullopt, which fails the whole parse (ClientState{},fileOk=false) rather than silently dropping the entry — otherwise a missingcore_service/capability_modulewould surface as an obscure connect error later. - A
versionother than2is rejected with a "relaunch the daemon to regenerate, or hand-edit" message andfileOk=false. codecis validated up front when supplied via--client-codec: anything other thanjsonorcboris a hard error (exit 1) at flag-merge time, rather than being stored verbatim and silently coerced to JSON at dial time (which would defeat the "connect fails on codec mismatch" guarantee).fileOk(the "usable for dialing" bitRpcClient::connectchecks) is true iff at least onedaemonentry parsed andtoken_fileis non-empty.core_serviceandcapability_modulemay target different ports — they're independent daemon listeners. The--client-*CLI flags apply one transport shape to both modules, so divergent ports require hand-editing this file.
API:
| Method | Description |
|---|---|
ClientStateFile::read() -> ClientState |
Parse client/config.json (or return the in-process override set by setOverride). fileOk=false on missing file, bad version, or invalid transport entry. |
ClientStateFile::write(state) -> bool |
Serialize a ClientState back to client/config.json (used by the --persist-config path). Writes transport/host/port/codec (+ ca/verify_peer for tcp_ssl), and instance_id only when non-empty. |
ClientStateFile::setOverride(opt) |
Inject a CLI-flag-merged ClientState that read() returns verbatim — lets --client-* flags affect a run without writing to disk. |
ClientStateFile::readTokenFile(filename) -> string |
Read <configDir>/client/<filename> and return its "token" field. Empty string if missing/malformed. When --token-file is passed explicitly, main now validates the content with this up front: a file that exists but yields an empty token (missing/empty token field, or unparseable JSON) is a hard error (exit 1) pointing at the bad file, instead of being accepted and surfacing later as "No authentication token" at connect time. |
Client
Files: src/client/client.cpp/h
Purpose: Connect to the daemon's core_service module via LogosAPIClient and invoke its methods by name.
Client is an abstract interface (so tests can substitute a mock);
RpcClient is the real implementation and is a thin wrapper around
LogosAPIClient. Its surface is Qt-free — std::string in, LogosMap /
LogosList out — and each method maps 1:1 to a core_service method:
API:
| Method | core_service method called |
|---|---|
Client::connect() -> bool |
Read <configDir>/client/config.json, set LOGOS_INSTANCE_ID from instance_id, build LogosTransportConfig from the dial spec, load token from token_file (or LOGOSCTL_TOKEN env), create LogosAPIClient targeting "core_service", authenticate |
Client::isConnected() -> bool |
— |
Client::lastError() -> std::string |
— (last connect/RPC failure reason) |
Client::loadModule(name) -> LogosMap |
core_service.loadModule(name) |
Client::unloadModule(name, withDependents) -> LogosMap |
core_service.unloadModule(name, withDependents) |
Client::reloadModule(name) -> LogosMap |
core_service.reloadModule(name) |
Client::refreshModules() -> LogosMap |
core_service.refreshModules() |
Client::planPackageOperation(op, names, opts) -> LogosMap |
core_service.planPackageOperation(...) |
Client::applyPackageOperation(op, names, opts) -> LogosMap |
core_service.applyPackageOperation(...) |
Client::downloadPackage(name, opts) -> LogosMap |
core_service.downloadPackage(name, opts) |
Client::listModules(filter) -> LogosList |
core_service.listModules(filter) |
Client::getStatus() -> LogosMap |
core_service.getStatus() |
Client::getModuleInfo(name) -> LogosMap |
core_service.getModuleInfo(name) |
Client::getModuleStats() -> LogosList |
core_service.getModuleStats() |
Client::callModuleMethod(module, method, args) -> LogosMap |
core_service.callModuleMethod(module, method, args) |
Client::shutdown() -> LogosMap |
core_service.shutdown() |
Client::watchModuleEvents(module, event, callback) -> bool |
core_service.watchModuleEvents(module, event) + event subscription |
Implementation pattern:
LogosMap RpcClient::loadModule(const std::string& name) {
nlohmann::json ret = d->invoke("loadModule", nlohmann::json::array({name}));
if (ret.is_object()) return ret;
return LogosMap{{"status","error"},{"code","RPC_FAILED"}, /* ... */};
}
Output
Files: src/client/output.cpp/h
Purpose: Format output for human or JSON consumption. Detects TTY status for automatic mode selection.
API:
| Method | Description |
|---|---|
Output::isTTY() -> bool |
Check if stdout is a terminal |
Output::isJsonMode() -> bool |
Check if JSON output is active (flag or non-TTY) |
Output::printSuccess(data) |
Print success result (human table or JSON) |
Output::printError(code, message) |
Print error to stderr (human) or JSON to stdout |
Output::printList(items) |
Print a list (table or JSON array) |
Output::printEvent(event) |
Print a single event (formatted line or NDJSON) |
Config
Files: src/config.cpp/h
Purpose: Read authentication credentials from environment variables and the client's dial-spec file.
API:
| Method | Description |
|---|---|
Config::getToken() -> QString |
Token resolution: only LOGOSCTL_TOKEN env var. Filesystem fallback (client/<token_file>) lives in ClientStateFile::readTokenFile since it requires parsing the client config. |
Config::configDir() -> QString |
Resolve config dir: explicit setter (--config-dir) → LOGOSCTL_CONFIG_DIR env → ~/.logosctl |
Config::setConfigDir(QString) |
Process-wide override set from main when --config-dir is passed |
Config::daemonConfigPath() / daemonStatePath() / daemonTokensPath() / daemonTokensDir() |
Daemon-side path helpers under <configDir>/daemon/ |
Config::clientConfigPath() / clientDir() / clientTokenPath(filename) |
Client-side path helpers under <configDir>/client/. clientTokenPath rejects any filename that isn't a plain name (contains /, \, or ..) and resolves it to an in-client/ sentinel, so an operator-influenced token_file value can't escape the dir to read an arbitrary file as a credential. |
The client dial spec lives in client/config.json and is loaded via ClientStateFile::read() (not Config). See the ClientStateFile section above for the schema and parsing contract.
Token resolution order:
LOGOSCTL_TOKENenvironment variable<configDir>/client/<token_file>(token_filedefaults toauto.jsonwhenclient/config.jsondoesn't override it)
Config dir resolution order:
--config-dir <path>CLI flag (sets process-wide override, mirrors intoLOGOSCTL_CONFIG_DIR)LOGOSCTL_CONFIG_DIRenvironment variable~/.logosctl(default)
Parallel daemons run side-by-side when invoked with distinct --config-dir values; client commands must target the daemon by passing the same --config-dir. Two daemons may not share a config-dir: startup reads daemon/state.json and refuses (exit 1) if its recorded pid is still alive, since both would write the same state.json and either one's clean shutdown would unlink it out from under the other. A stale state.json left by a crashed daemon (pid no longer alive) is ignored and overwritten.
Command Base Class
Files: src/client/commands/command.cpp/h
Purpose: Base class for all client subcommand implementations.
API:
| Method | Description |
|---|---|
Command::execute(args) -> int |
Run the command, return exit code |
Command::client() -> Client& |
Access the core_service client |
Command::output() -> Output& |
Access the output formatter |
CLI Commands
All client-path commands connect to the daemon's core_service module via LogosAPIClient and call its methods by name. They never call liblogos C API functions directly.
logosctl daemon
Start the daemon process. This is the only command that runs the daemon path.
logosctl daemon start [--modules-dir <path>]...
logosctl daemon [--modules-dir <path>]...
Behavior:
logos_core_init(argc, argv), add module directories,logos_core_start()- Register
core_servicein-process viaLogosAPIProvider::registerObject()(notlogos_core_register_module(), which only maps a plugin name to a file path for on-disk discovery) - Write
~/.logosctl/daemon/state.json(listeners + hashed-token table) and emit~/.logosctl/client/config.json+~/.logosctl/client/auto.jsonfor the local client logos_core_exec()(Qt event loop — blocks)- On SIGINT/SIGTERM:
logos_core_cleanup(), removedaemon/state.json, exit
Exit codes: 0 on clean shutdown, 1 on error.
logosctl module load
Load a module into the running daemon.
logosctl module load <name>
Behavior:
- Connects to daemon via
Client - Calls
core_service.loadModule(name) - Prints result and exits
Exit codes: 0 on success, 2 if no daemon, 3 if module not found or load failed.
logosctl module unload
Unload a module from the running daemon.
logosctl module unload <name>
Behavior:
- Connects to daemon via
Client - Calls
core_service.unloadModule(name)
Exit codes: 0 on success, 2 if no daemon, 3 if module not found or unload failed.
logosctl module ls
List available or loaded modules.
logosctl module ls [--loaded]
Behavior:
- Connects to daemon via
Client - Calls
core_service.listModules(filter)— filter is"loaded"or"all" - Returns all modules with status enum (
loaded | not_loaded | crashed | loading) - Formats and prints result with NAME, VERSION, STATUS, UPTIME columns
- Crash metadata (
exit_code,crashed_at,crash_reason) is included in JSON for crashed modules
Exit codes: 0 on success, 2 if no daemon.
logosctl daemon status
Show overall daemon and module health.
logosctl daemon status
Behavior:
- Reads
<configDir>/client/config.jsonto learn how to dial. If missing or unparseable, prints "not running" and exits with code 1 (no point trying to connect). - Otherwise tries to connect and call
core_service.getStatus(). The RPC call IS the liveness check — there's no separate cheap probe, because no cheap probe is correct across every transport (local Unix socket vs remote TCP across NAT is a meaningless question for PID-based liveness). - On RPC timeout / connect refused: reports "not running" with the error reason, exits with code 1.
- On success: displays daemon info (PID, uptime, version, instance ID) and all module statuses with summary counts.
Exit codes: 0 on success, 1 if daemon not running (uses 1 not 2 because the status command itself succeeded — it's reporting the state, not failing to connect).
logosctl module reload
Unload and re-load a module.
logosctl module reload <name>
Behavior:
- Connects to daemon via
Client - Calls
core_service.reloadModule(name)— core_service handles the unload/load logic internally, including fallback to plain load if module isn't currently loaded - Returns result with
previous_statusfield
Exit codes: 0 on success, 2 if no daemon, 3 if module not found or reload failed.
logosctl module show
Show detailed information about a specific module.
logosctl module show <name>
Behavior:
- Connects to daemon via
Client - Calls
core_service.getModuleInfo(name) - For loaded modules: displays name, version, status, PID, uptime, dependencies, and available methods — each method shows its signature and, when documented, a
descriptionsourced from the method's header doc comment (carried in the module'sgetPluginMethodsintrospection) - For crashed modules: displays name, version, status, exit code, crash signal, crashed_at, restart count, last log line, PID before crash
- For not-loaded modules: displays name, version, status, dependencies
Exit codes: 0 on success, 2 if no daemon, 3 if module not found.
logosctl call
Call a method on a loaded module.
logosctl call <module> <method> [args...]
Alternative syntax:
logosctl module <name> method <method> [args...]
Behavior:
- Connects to daemon via
Client - Resolves
@filearguments to file contents - Type-coerces arguments: numeric strings → int/double,
"true"/"false"→ bool, rest → string - Calls
core_service.callModuleMethod(module, method, args)— core_service proxies the call to the target module viaLogosAPIClient - In human mode: prints scalar results as plain values, structured results as indented JSON, null produces no output. In JSON mode: prints the full result envelope.
Exit codes: 0 on success, 2 if no daemon, 3 if module not loaded, 4 if method not found or call failed.
logosctl watch
Watch events from a loaded module.
logosctl watch <module> [--event <name>]
Behavior:
- Connects to daemon via
Client - Calls
core_service.watchModuleEvents(module, event)— core_service registers an event listener on the target module and forwards events through its own event system - Client subscribes to core_service events via
LogosAPIClient::onEvent() - On each event: prints formatted line (human) or NDJSON line (JSON mode)
- Runs until SIGINT/SIGTERM
Exit codes: 0 on clean shutdown, 2 if no daemon, 3 if module not loaded.
logosctl stats
Show resource usage for loaded modules.
logosctl stats
Behavior:
- Connects to daemon via
Client - Calls
core_service.getModuleStats() - Formats as table (human) or JSON array
Exit codes: 0 on success, 2 if no daemon.
logosctl daemon stop
Stop the running daemon via RPC.
logosctl daemon stop
Behavior:
- Refuses up front if
daemon/state.jsonnames this client's instance and a pid that is no longer alive — a stale session has no daemon to stop, and a LocalSocket client would otherwise "connect" to nothing (NO_DAEMON, exit 2; the same guarddaemon statusapplies) - Connects to daemon via
Client - Reads
daemon/state.jsonfor the daemon's pid before issuing the call — a clean shutdown deletes that file, so afterwards it is unreadable - Calls
core_service.shutdown()(5s deadline; the daemon answers before doing any work, so a slower reply is a lost one) - core_service posts a main-thread timer,
LOGOSCTL_SHUTDOWN_GRACE_MS(default 200ms) later, that drains the event loop and then callsQCoreApplication::quit() - If the RPC response arrives: prints success and exits
- If it does not, the client asks whether the daemon actually died, for up to 15s: by watching the pid from step 3, or — for a remote daemon, where there is no local pid — by re-probing
getStatus. Gone ⇒ success, withconfirmed_bynaming the evidence. Still there ⇒RPC_FAILED
Exit codes: 0 on success (including when daemon exits before response), 2 if no daemon (or a stale session), 3 if the daemon neither replied nor exited.
logosctl info
Alias for module-info. Delegates to module-info command.
logosctl module show <module>
Behavior: Same as module-info <module> — see above.
Exit codes: 0 on success, 2 if no daemon, 3 if module not found.
Call Chain: CLI → core_service → liblogos
Client commands never call liblogos functions directly. The full call chain is:
CLI client core_service (daemon-side) liblogos C API
───────── ───────────────────────── ──────────────
logosctl module load waku
→ Client::loadModule("waku")
→ LogosAPIClient::invokeRemoteMethod(
"core_service", "loadModule", "waku")
───── IPC (Qt Remote Objects) ─────→
CoreServiceImpl::loadModule("waku")
→ logos_core_load_module("waku", true)
→ build result JSON
←──── IPC (return value) ──────────
→ Output::printSuccess(result)
→ exit(0)
Daemon path — liblogos usage
Only the daemon path calls liblogos C API functions directly:
| Daemon operation | liblogos functions |
|---|---|
| Start core | logos_core_init, logos_core_add_modules_dir, logos_core_start |
| Register core_service | LogosAPI, LogosAPIProvider::registerObject (Qt host runtime, logos-qt-host) |
| Run event loop | logos_core_exec |
| Shutdown | logos_core_cleanup |
Client path — core_service method mapping
Client commands call core_service methods, which delegate to liblogos internally:
| CLI command | core_service method | liblogos function called internally |
|---|---|---|
load-module |
loadModule(name) |
logos_core_load_module(name, true) |
unload-module |
unloadModule(name, withDependents) |
logos_core_unload_module(name, withDependents) — liblogos does the leaves-first cascade |
reload-module |
reloadModule(name) |
logos_core_unload_module(name, false) + logos_core_load_module(name, true) |
list-modules |
listModules(filter) |
logos_core_get_known_modules, logos_core_get_loaded_modules |
status |
getStatus() |
reads daemon state + listModules |
module-info |
getModuleInfo(name) |
plugin metadata + methods introspection |
call |
callModuleMethod(module, method, args) |
LogosAPIClient::invokeRemoteMethod (proxied to target module) |
watch |
watchModuleEvents(module, event) |
LogosAPIClient::onEvent (forwarded) |
stats |
getModuleStats() |
logos_core_get_module_stats |
stop |
shutdown() |
QTimer::singleShot(200, ..., &QCoreApplication::quit) |
info |
alias for module-info |
— |
Build
Nix
nix build
# The logosctl binary is at:
./result/bin/logosctl
# Run daemon
./result/bin/logosctl daemon start -m /path/to/modules
Examples
Basic Usage
# Start the daemon with module directories
logosctl daemon start --detach &
# Check daemon health
logosctl daemon status
# Load modules
logosctl module load waku
logosctl module load chat
# List loaded modules (with status and uptime)
logosctl module ls --loaded
# Get detailed module info
logosctl module show chat
# Call a method
logosctl call chat send_message "hello world"
# Reload a crashed module
logosctl module reload chat
# Watch events
logosctl watch chat --event chat-message
# Get stats
logosctl stats
# Stop daemon
logosctl daemon stop
Agent / Script Usage
# Start daemon
logosctl daemon start --detach &
sleep 2
# Preflight: verify daemon is running
logosctl daemon status --json | jq -e '.daemon.status == "running"' > /dev/null
# Check what's available and their state
logosctl module ls --json
# [
# {"name":"waku","version":"0.1.0","status":"not_loaded"},
# {"name":"chat","version":"0.2.0","status":"not_loaded"}
# ]
# Load modules (JSON output for parsing)
logosctl module load waku --json
# {"status":"ok","module":"waku","version":"0.1.0","dependencies_loaded":["store"]}
logosctl module load chat --json
# {"status":"ok","module":"chat","version":"0.2.0","dependencies_loaded":[]}
# Discover methods before calling
logosctl module show chat --json | jq '.methods[].name'
# "send_message"
# "get_history"
# "get_status"
# Call method and parse result
RESULT=$(logosctl call chat send_message "hello" --json)
echo "$RESULT" | jq -r '.result'
# Handle crashed modules
MODULE_STATUS=$(logosctl daemon status --json | jq -r '.modules[] | select(.name=="chat") | .status')
if [ "$MODULE_STATUS" = "crashed" ]; then
logosctl module show chat --json | jq '{exit_code, crash_signal, restart_count}'
logosctl module reload chat --json
fi
# Stream events to log file
logosctl watch chat --event chat-message --json >> events.log &
WATCH_PID=$!
# Check overall health before cleanup
logosctl daemon status --json | jq '.modules_summary'
# {"loaded": 3, "crashed": 0, "not_loaded": 0}
# Cleanup
kill $WATCH_PID
logosctl daemon stop
Using Environment Variables for Auth
# Set token via environment
export LOGOSCTL_TOKEN=xyz123
# Or inline per-command
LOGOSCTL_TOKEN=xyz123 logosctl module load waku
# Or via the client/ tree (point client/config.json's token_file at a JSON
# file the daemon emitted — useful for remote clients). The file is a
# {"version":1,"name":"alice","token":"<raw>","issued_at":"<iso>"}
# object that `issue-token --name alice` writes to
# <daemon-host>/.logosctl/daemon/tokens/alice.json. Copy it across
# (scp / ansible / cloud-secret-fetch) and reference it from
# client/config.json's token_file:
mkdir -p ~/.logosctl/client
scp daemon-host:~/.logosctl/daemon/tokens/alice.json ~/.logosctl/client/
# then ensure ~/.logosctl/client/config.json's token_file = "alice.json"
logosctl module load waku
Piping and Composition
# Filter loaded modules
logosctl module ls --json | jq '[.[] | select(.status == "loaded")]'
# Find crashed modules
logosctl module ls --json | jq '[.[] | select(.status == "crashed")]'
# Watch events and filter
logosctl watch chat --event chat-message --json | jq 'select(.data.from == "alice")'
# Monitor module health with status dashboard
watch -n 5 'logosctl daemon status --json | jq "{daemon: .daemon.status, modules: .modules_summary}"'
# Monitor resource usage
watch -n 5 'logosctl stats --json | jq ".[] | {name, cpu_percent, memory_mb}"'
# Auto-reload crashed modules
logosctl module ls --json | jq -r '.[] | select(.status == "crashed") | .name' | while read mod; do
logosctl module reload "$mod" --json
done
Tests
| Test File | Coverage |
|---|---|
test_commands.cpp |
All subcommand implementations via mock client: load/unload/reload module, list-modules, status, module-info, call, stats, watch, stop. Tests both success and error paths, JSON and human output modes. |
test_mode_detection.cpp |
Mode detection (daemon/client/help/version), known subcommands list, argument parsing. |
test_output.cpp |
Output formatter (human/JSON), TTY detection, printSuccess/printError/printRaw. |
test_daemon_state.cpp |
Round-trip daemon/state.json — instance_id, pid, modulesDirs, per-module transports entries (local/tcp/tcp_ssl, codec defaulting), and the tokens array (name, hash, issued_at, expires_at, local_only). fileOk is independent of the pid (it's a parse check, not liveness). |
test_token_store.cpp |
Token issuance (including --expires and --local-only), duplicate-name rejection (unless --replace), revocation, list, persistence round-trip. Confirms tokens.json["tokens"] stores hashes only; plaintext lives in daemon/tokens/<name>.json. Fail-closed invariants: an empty token never authenticates, issueToken Ok implies a non-empty token, a failed --replace preserves the prior raw token, and issuing against an unsupported-schema-version file refuses instead of clobbering it. |
test_config.cpp |
Token resolution order (env var → client/<token_file>); client/config.json parsing; clientTokenPath accepts plain filenames and rejects path-traversal (../, absolute, sub-dirs). |
test_port_allocator.cpp |
Ephemeral-port allocation: bad host returns 0, an IPv6 any-address (::) allocates a port, consecutive allocations are distinct. |
test_access_policy_arg.cpp |
--access-policy resolution: the enforce alias expands to the deny-by-default document, the alias beats the file branch, inline JSON and file paths pass through unchanged, and a bad path / malformed JSON fails with a reason rather than degrading to "no policy". |
test_log_sink.cpp |
Pipe-based stdout/stderr capture into the rotating daemon log. |
test_paths.cpp |
Executable / bundle-relative path resolution (paths.h). |
test_cli.cpp |
End-to-end CLI tests: help, version, no-args, client commands without daemon, daemon startup with --verbose; rejection of an invalid --module-transport port, an invalid --client-codec, and a --token-file that carries no usable token. |
test_integration.cpp |
Daemon-backed integration: a real logosctl daemon against a real module directory, driven through the client subcommands — error paths, the full test_basic_module API surface, event subscription via watch, and many simultaneous clients on one daemon. |
test_cli_logoscore.cpp / test_integration_logoscore.cpp |
The same two suites frozen against logoscore's surface, so shared-runtime changes can't regress the tool people actually use. They get deleted with the binary. |
Known Issues
-
Event forwarding — The
watchcommand requirescore_serviceto forward events from target modules to CLI clients. The approach is:core_service.watchModuleEvents()registers a listener on the target module viaLogosAPIClient::onEvent(), then re-emits received events throughCoreServiceImpl::emitEvent— thestd::functionhook the runtime installs viasetEventListenerStd(), emitted under the namemodule_event. The CLI client subscribes tocore_serviceevents. This creates a relay chain (target module → core_service → CLI client) which adds latency. An alternative would be having the CLI client connect directly to the target module, but that bypasses the core_service gateway pattern. -
Stale state file — If the daemon crashes without removing
<configDir>/daemon/state.json(and the auto-emittedclient/tree), the files stay on disk. Clients no longer pre-probe PID liveness (that only works for local daemons); instead the first RPC fails with a connect error and thestatuscommand turns that into a "not running" report. The only cost of a stale file is that the first attempt after a crash wastes one RPC timeout; in practice that's fine. -
Crash tracking — The daemon needs to track module crash metadata (exit code, signal, timestamp, restart count, last log line) so that
listModulesandgetModuleInfoon core_service can report it. This may require extending liblogos to expose crash info, or core_service could track it independently by monitoringQProcesssignals. -
callModuleMethod proxy — When
core_serviceproxies calls to target modules viaLogosAPIClient, it needs the target module's auth token. The daemon's TokenManager has all tokens, but core_service must obtain them. This may require core_service to have a privileged token or to be pre-authorized for all modules.
Future Improvements
- Tab completion — Shell completion scripts for bash/zsh/fish.
- TUI mode — Interactive terminal UI with autocomplete (like Obsidian CLI).
- Batch mode — Execute multiple commands from a file (
logosctl batch commands.txt). module-logscommand — Stream or tail module process logs (logosctl module-logs chat --tail 50). Referenced by error messages but not yet specified.- Extract core_service — If core_service grows, it could be extracted into a standalone plugin loaded from disk rather than statically linked. It already implements the plain
LogosProviderObjectinterface, so the extraction is mostly adding a plugin entry point and a module build. - Capability-scoped tokens — Today all tokens are admin-equivalent. Named tokens (
issue-token --name …) create separate identities but each one is still fully authorised against the daemon. A scope/capability system would let e.g. a read-only token calllist-modules/statusbut rejectload-module/stop. - Client-cert TLS — The
tcp_ssltransport today authenticates the daemon to the client (server cert); mutual TLS + client-cert auth would be a natural extension once we have scoped tokens, and subsumes the token-file distribution problem for many deployments.