fix(windows): stop carrying JSON across the command line

Windows' CommandLineToArgvW treats `"` as a quoting delimiter and CONSUMES it,
so a JSON argv element arrives at the child stripped of its quotes and no longer
parses. POSIX exec() passes argv through untouched, which is why this was
invisible on Linux and macOS. Two arguments were affected.

--host-services. hostServicesFor() emitted `["token_registry","token_delivery"]`;
capability_module's process received `[token_registry,token_delivery]`, nlohmann
discarded it, and lp_grant_host_services rejects an unparseable list WHOLESALE
by design. Measured on a real Windows run: the host logged the correct JSON, the
module process logged the stripped form, and the impl answered

    [capability_module] host services refused: "[token_registry,token_delivery]"

which was the only visible symptom — the trust root silently lost both services
while the run otherwise looked healthy (0 refused calls, 3 modules loaded).

Fixed by carrying a BARE COMMA-SEPARATED LIST over argv and re-serialising it to
a JSON array in module_initializer before the property is stamped. Service names
are a closed set of [a-z_]+ identifiers, so nothing needs quoting. The two places
that genuinely require JSON — the `hostServices` property the cdylib glue reads,
and lp_grant_host_services itself — are unchanged, so logos-plugin-qt and
logos-protocol need no edit.

--transport-set. Same defect, not yet observed only because moduleTransportsMap()
was empty for the modules under test; any module with an explicit transport entry
would hit it. The payload here is arbitrary nested JSON (endpoints, ports, TLS
paths), so it cannot be flattened into an identifier list — it is base64-encoded
instead. The alphabet is [A-Za-z0-9+/=], with no quote, space or backslash, so it
survives any command-line reconstruction.

The receiver accepts both forms and the discrimination is exact rather than
heuristic: a JSON transport set always begins with `{` or `[`, and neither
character is in the base64 alphabet. An older daemon paired with a newer
logos_host therefore keeps working. A payload that is neither is passed through
unchanged so the error surfaces in transportSetFromJsonString, where it is
diagnosable, rather than as a silently empty transport set.

base64Encode is hand-rolled because qt_plugin_format_loader.cpp is deliberately
Qt-free (boost + spdlog + std); the decode side is already a Qt TU and uses
QByteArray::fromBase64 with AbortOnBase64DecodingErrors.

Verified on framework.lan: x86_64-linux logos-basecamp and
logos-basecamp--host-services-test, and x86_64-windows
logos-basecamp--bin-bundle-dir, all EXIT=0. Then re-run on real Windows with the
same 100s script as the failing run: the module now logs the proper JSON array,
the "host services refused" line is gone, REFUSING = 0, "rejecting unauthorized
call" = 0, 3 modules loaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Gabriel Lipicar
2026-08-17 10:27:36 -03:00
co-authored by Claude Opus 5
parent 06134bfcb7
commit b4529e0d3c
3 changed files with 138 additions and 6 deletions
+2 -1
View File
@@ -16,7 +16,8 @@ ModuleArgs parseCommandLineArgs(int argc, char *argv[])
app.add_option("--instance-persistence-path", result.instancePersistencePath,
"Instance persistence directory for the module");
app.add_option("--transport-set", result.transportSetJson,
"Per-module transport set as JSON (logos-cpp-sdk shape); empty = global default");
"Per-module transport set, base64-encoded JSON (logos-cpp-sdk shape); raw "
"JSON is still accepted; empty = global default");
app.add_option("--token-source", result.tokenSource,
"Where to read the auth token from: stdin (default), fd:<n>, or file:<path>");
app.add_option("--host-services", result.hostServices,
+68 -3
View File
@@ -1,7 +1,13 @@
#include "module_initializer.h"
#include <QByteArray>
#include <QObject>
#include <spdlog/spdlog.h>
#include <filesystem>
// Explicit: the host-services grant arrives as a bare comma-separated list over
// argv and is re-serialised to a JSON array here. logos_transport_config_json.h
// deliberately keeps its nlohmann include off the fast path, so do not rely on
// picking it up transitively from there.
#include <nlohmann/json.hpp>
#include "interface.h"
#include "logos_api.h"
#include "logos_api_provider.h"
@@ -14,6 +20,38 @@ namespace fs = std::filesystem;
using namespace ModuleLib;
namespace {
// `--transport-set` carries base64 so its JSON survives the command line: on
// Windows, CommandLineToArgvW consumes `"` as a quoting delimiter, so raw JSON
// arrives unparseable. See base64Encode() in qt_plugin_format_loader.cpp, which
// is the only emitter.
//
// Both forms are accepted, and the discrimination is exact rather than
// heuristic: a JSON transport set always begins with `{` or `[`, and neither
// character is in the base64 alphabet. That keeps an older daemon paired with a
// newer logos_host working.
//
// A payload that is neither valid base64 nor JSON is returned unchanged, so the
// error surfaces where it is diagnosable — in transportSetFromJsonString —
// rather than as a silently empty transport set here.
std::string decodeTransportSetArg(const std::string& arg)
{
if (!arg.empty() && (arg.front() == '{' || arg.front() == '[')) {
spdlog::debug("transport set supplied as raw JSON (pre-base64 emitter)");
return arg;
}
const QByteArray decoded = QByteArray::fromBase64(
QByteArray::fromStdString(arg), QByteArray::AbortOnBase64DecodingErrors);
if (decoded.isEmpty()) {
spdlog::warn("transport set is neither JSON nor valid base64; passing through unchanged");
return arg;
}
return decoded.toStdString();
}
} // namespace
LogosModule loadModule(const std::string& modulePath, const std::string& expectedName)
{
std::string errorString;
@@ -61,7 +99,7 @@ LogosAPI* initializeLogosAPI(const std::string& moduleName, QObject* module,
LogosAPI* logos_api = nullptr;
if (!transportSetJson.empty()) {
LogosTransportSet set =
logos::transportSetFromJsonString(transportSetJson);
logos::transportSetFromJsonString(decodeTransportSetArg(transportSetJson));
logos_api = new LogosAPI(QString::fromStdString(moduleName),
std::move(set), module);
} else {
@@ -94,9 +132,36 @@ LogosAPI* initializeLogosAPI(const std::string& moduleName, QObject* module,
// loadModule refuses a plugin whose own name() disagrees with the trusted
// registry key the parent passed), so by here the identity the grant is
// bound to has already been verified against the binary.
//
// The flag arrives as a BARE COMMA-SEPARATED LIST and is re-serialised to a
// JSON array here. It is not carried as JSON across the command line
// because Windows' CommandLineToArgvW consumes `"` as a quoting delimiter:
// `["token_registry","token_delivery"]` reached this process as
// `[token_registry,token_delivery]`, which nlohmann discards, and
// lp_grant_host_services rejects an unparseable list WHOLESALE. The symptom
// was Windows-only and silent apart from capability_module's own
// `host services refused` line. See hostServicesFor() in
// qt_plugin_format_loader.cpp.
//
// The property and the C ABI both still speak JSON; only the argv hop
// changed. Serialise through nlohmann rather than by string concatenation so
// a name that ever needs escaping is escaped.
if (!hostServices.empty()) {
spdlog::info("Granting host services to {}: {}", moduleName, hostServices);
logos_api->setProperty("hostServices", QString::fromStdString(hostServices));
nlohmann::json services = nlohmann::json::array();
std::size_t start = 0;
while (start <= hostServices.size()) {
const std::size_t comma = hostServices.find(',', start);
const std::size_t end = (comma == std::string::npos) ? hostServices.size() : comma;
std::string name = hostServices.substr(start, end - start);
if (!name.empty())
services.push_back(name);
if (comma == std::string::npos)
break;
start = comma + 1;
}
const std::string servicesJson = services.dump();
spdlog::info("Granting host services to {}: {}", moduleName, servicesJson);
logos_api->setProperty("hostServices", QString::fromStdString(servicesJson));
}
bool success = logos_api->getProvider()->registerObject(basePlugin->name(), module);
+68 -2
View File
@@ -37,13 +37,75 @@ constexpr const char* kExeSuffix = "";
// it belongs with the per-module access policy the daemon already applies,
// alongside allowedCallers — not in a table that exists to keep a list at two
// entries.
//
// The value is a BARE COMMA-SEPARATED LIST, not JSON, because it crosses a
// command line. This used to be `["token_registry","token_delivery"]` and it
// broke on Windows only: CommandLineToArgvW treats `"` as a quoting delimiter
// and CONSUMES it, so the child received `[token_registry,token_delivery]`,
// nlohmann's parser discarded it, and lp_grant_host_services returned
// LP_ERR_INVALID_ARG — rejecting the whole list by design. Measured on a real
// Windows run: the host logged the correct JSON, the module process logged the
// quote-stripped form, and the impl reported `host services refused`. POSIX
// exec() passes argv through untouched, which is why Linux and macOS never saw
// it and the basecamp host-services check stayed green.
//
// Service names are a closed set of `[a-z_]+` identifiers, so a comma-separated
// list needs no quoting, no escaping and no brackets, and survives any
// command-line reconstruction. module_initializer turns it back into the JSON
// array that the `hostServices` property and lp_grant_host_services both
// require, so neither of those contracts changes.
const char* hostServicesFor(const std::string& moduleName)
{
if (moduleName == "capability_module")
return R"(["token_registry","token_delivery"])";
return "token_registry,token_delivery";
return nullptr;
}
// Standard base64, so a JSON payload can cross a command line intact.
//
// The transport set is arbitrary nested JSON — endpoints, ports, TLS paths —
// so unlike the host-services grant it cannot be flattened into a bare
// identifier list, and it hits the same Windows defect: CommandLineToArgvW
// consumes every `"`, and the child receives an unparseable string. The base64
// alphabet is [A-Za-z0-9+/=] with no quote, space or backslash, so it survives
// any command-line reconstruction on every platform.
//
// Hand-rolled rather than QByteArray::toBase64 because this translation unit is
// deliberately Qt-free (boost + spdlog + std only) and this is the only place
// that needs it; module_initializer, which is already a Qt TU, decodes with
// QByteArray::fromBase64.
std::string base64Encode(const std::string& in)
{
static constexpr char kAlphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve(((in.size() + 2) / 3) * 4);
auto byte = [&in](std::size_t i) { return static_cast<unsigned>(static_cast<unsigned char>(in[i])); };
std::size_t i = 0;
for (; i + 2 < in.size(); i += 3) {
const unsigned v = (byte(i) << 16) | (byte(i + 1) << 8) | byte(i + 2);
out += kAlphabet[(v >> 18) & 0x3F];
out += kAlphabet[(v >> 12) & 0x3F];
out += kAlphabet[(v >> 6) & 0x3F];
out += kAlphabet[v & 0x3F];
}
if (in.size() - i == 1) {
const unsigned v = byte(i) << 16;
out += kAlphabet[(v >> 18) & 0x3F];
out += kAlphabet[(v >> 12) & 0x3F];
out += "==";
} else if (in.size() - i == 2) {
const unsigned v = (byte(i) << 16) | (byte(i + 1) << 8);
out += kAlphabet[(v >> 18) & 0x3F];
out += kAlphabet[(v >> 12) & 0x3F];
out += kAlphabet[(v >> 6) & 0x3F];
out += '=';
}
return out;
}
fs::path findInDir(const fs::path& dir) {
for (const auto& name : {"logos_host_qt", "logos_host"}) {
auto candidate = (dir / (std::string(name) + kExeSuffix)).lexically_normal();
@@ -111,8 +173,12 @@ std::vector<std::string> QtPluginFormatLoader::buildArguments(const LogosCore::M
}
if (!desc.transportSetJson.empty()) {
// Base64, not raw JSON — see base64Encode above. The receiver detects
// which form it got: JSON starts with `{` or `[`, neither of which is
// in the base64 alphabet, so an older emitter talking to a newer host
// still works.
args.push_back("--transport-set");
args.push_back(desc.transportSetJson);
args.push_back(base64Encode(desc.transportSetJson));
}
// Privileged modules carry their grant on the command line, so it is in