Files
logos-plugin-qt/cpp/logos_api.h
T
Dario Gabriel LipicarandClaude Opus 5 c459e47787 feat(glue): pull the caller from the host image and push it across the C ABI
Everything under this has merged — the protocol resolves the caller and
opens a scope (#70), both backends define logos_module_set_call_caller
(cpp-sdk #147, rust-sdk #47) — and nothing connected the two, so
currentCaller() returned Unknown everywhere. This is the wire.

Per dispatch, all on the dispatch thread: pull the caller document back out
of the HOST image, push it into the module image, dispatch, pop.

WHY THE PULL IS AN invokeMethod AND NOT A CALL
The host binary and the module plugin EACH define LogosAPI — meta-object
included — with their own statics at distinct addresses and no undefined
reference to the other's. Mach-O is TWOLEVEL, PE has no interposition. A
direct logosAPI()->currentCallerJson() binds to the PLUGIN copy and reads
the PLUGIN thread-local: empty, forever, silently, on macOS and Windows.
invokeMethod resolves through metaObject()/qt_metacall, which are virtual
and whose vptr the HOST constructor wrote, so it lands in host code on the
calling thread. Same channel initLogos and aboutToUnload already use.

IN MULTI THE PULL IS BEFORE THE CAPTURE
callMethod is entered on the dispatch thread and captures by value into
QThread::create, so the pull happens there and the JSON rides along; the
push/pop triple moves verbatim into the worker. Pulling inside the worker
also compiles, and every multi call would read Unknown because that thread
never had a scope. A test asserts the source ORDER, and it was driven red
by making exactly that mistake.

The invokable's name was not chosen here: logos-protocol master already
names currentCallerJson in three places, including a CMakeLists comment
saying the header ships so this file can answer it.

Two checks beyond the wire, because the by-name test alone left gaps:

  * test-glue-compiles — NOTHING in this repo compiled the emitted glue.
    test-qt-host-generator.cpp documents a bug that escaped through exactly
    that hole. Both branches now build as a real Qt plugin with the C ABI
    stubbed and -Wl,--no-undefined, so a missing symbol fails at link
    rather than at a user's dlopen.
  * test-caller-invokable — six runtime assertions, including that a scope
    open on one thread is INVISIBLE on another, which is the actual
    justification for the multi placement.

The contract test strips comments before its negative assertions (both
files legitimately NAME the forbidden calls in prose) and guards that with
a positive control, so the negatives cannot pass vacuously.

Guard is MAJOR-aware expanded arithmetic. Dropping only the MAJOR > 0 arm
still passes a grep, so the test resolves at 1.0 and asserts the call
survives; that mutation was driven red too.

11 checks green on x86_64-linux, the three new ones green on
aarch64-darwin, and the Windows mingw cross builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:46:35 -03:00

386 lines
17 KiB
C++

#ifndef LOGOS_API_H
#define LOGOS_API_H
#include "logos_mode.h"
#include "logos_shared_api.h"
#include "logos_transport_config.h"
#include "logos_types.h"
#include <QHash>
#include <QHashFunctions>
#include <QObject>
#include <QString>
#include <functional>
#include <optional>
#include <string>
class LogosAPIClient;
class LogosAPIProvider;
class TokenManager;
// qHash for LogosTransportConfig — combined with operator== from
// logos_transport_config.h, this lets QHash use it as a key. Lives here
// rather than in logos_transport_config.h so that header stays Qt-free
// (the SDK is being de-Qt'd; only Qt-using consumers like the cache
// here pull in the QHash adapter).
//
// Every field that distinguishes one explicit-transport client from
// another contributes to the hash; otherwise two callers with different
// TLS or codec settings could land on the same bucket and cache-alias
// onto a single client.
inline size_t qHash(const LogosTransportConfig& cfg, size_t seed = 0) noexcept
{
return qHashMulti(seed,
static_cast<int>(cfg.protocol),
std::hash<std::string>{}(cfg.host),
cfg.port,
std::hash<std::string>{}(cfg.caFile),
std::hash<std::string>{}(cfg.certFile),
std::hash<std::string>{}(cfg.keyFile),
cfg.verifyPeer,
static_cast<int>(cfg.codec));
}
// LogosAPIClient cache key. Mirrors the factory's transport-resolution
// rule so two callers that would observe the same connection share a
// cached client:
//
// - Mock / Local mode → transport is ignored at construction; key
// ignores it too. Switching mode changes the
// key (so cached clients don't bleed across
// mode switches in tests).
// - Remote mode → cfg picks the wire endpoint; key includes
// the full LogosTransportConfig.
//
// Without the mode-aware comparison, calling
// `getClient(x, tcp)` and `getClient(x, tcp_ssl)` in Mock mode would
// allocate two clients pointing at functionally identical
// MockTransportConnections.
struct LogosAPIClientCacheKey {
QString target;
LogosMode mode;
LogosTransportConfig transport; // only compared when mode == Remote
};
inline bool operator==(const LogosAPIClientCacheKey& a,
const LogosAPIClientCacheKey& b) noexcept
{
if (a.target != b.target) return false;
if (a.mode != b.mode) return false;
return a.mode == LogosMode::Remote ? a.transport == b.transport : true;
}
inline size_t qHash(const LogosAPIClientCacheKey& k, size_t seed = 0) noexcept
{
if (k.mode == LogosMode::Remote) {
return qHashMulti(seed, k.target, static_cast<int>(k.mode), k.transport);
}
// Mock / Local: transport is irrelevant — leave it out of the hash
// so it can't bias which bucket the key lands in.
return qHashMulti(seed, k.target, static_cast<int>(k.mode));
}
/**
* @brief LogosAPI provides a unified interface to the Logos SDK
*
* This class initializes and keeps instances of the client provider and token manager.
*
* Marked because this is the object handed across the DLL boundary: the host
* constructs a LogosAPI and passes the pointer to the UI plugin through
* PluginInterface::logosAPI. Its constructor caches `&TokenManager::instance()`,
* so an image that links its own copy of logos_api.cpp caches a DIFFERENT
* singleton than the one the host wrote the token into. Importing instead of
* re-linking is what makes the two agree.
*
* LOGOS_QT_HOST_API, not LOGOS_SHARED_API, because LogosAPI is owned by THIS
* library while TokenManager and LogosAPIClient are owned by logos-protocol.
* While building logos_qt_host_shared the first must be EXPORTED and the second
* two IMPORTED, and one macro cannot say both in the same translation unit. Off
* Windows the distinction is moot -- both resolve to default visibility -- which
* is exactly why getting it wrong would go unnoticed until a Windows build.
* See logos_shared_api.h in logos-protocol.
*/
class LOGOS_QT_HOST_API LogosAPI : public QObject
{
Q_OBJECT
public:
/**
* @brief Construct a new LogosAPI instance
* @param module_name The name of this module
* @param parent Parent QObject
*/
explicit LogosAPI(const QString& module_name, QObject *parent = nullptr);
/**
* @brief Construct a new LogosAPI with an explicit transport set.
*
* `transports` is empty ⇒ use the process-global default (back-compat).
* Non-empty ⇒ provider publishes on every configured transport
* (e.g. a daemon listing both LocalSocket and TCP+SSL so the CLI has
* a fast in-process path *and* remote clients have a secure path).
*/
LogosAPI(const QString& module_name,
LogosTransportSet transports,
QObject *parent = nullptr);
/**
* @brief Construct a new LogosAPI instance (const char* overload — resolves ambiguity)
*/
explicit LogosAPI(const char* module_name, QObject *parent = nullptr)
: LogosAPI(QString(module_name), parent) {}
/**
* @brief Construct with const char* and explicit transport set.
*/
LogosAPI(const char* module_name, LogosTransportSet transports, QObject *parent = nullptr)
: LogosAPI(QString(module_name), std::move(transports), parent) {}
/**
* @brief Construct a new LogosAPI instance (std::string overload)
*/
explicit LogosAPI(const std::string& module_name, QObject *parent = nullptr);
/**
* @brief Construct with std::string and explicit transport set.
*/
LogosAPI(const std::string& module_name, LogosTransportSet transports, QObject *parent = nullptr)
: LogosAPI(QString::fromStdString(module_name), std::move(transports), parent) {}
/* ── per-plugin identity ────────────────────────────────────────────────
*
* The ctors above bind this LogosAPI to `TokenManager::forIdentity(name)`,
* which is `TokenManager::instance()` — the image's ambient ring — unless
* the name has been isolated. That default is byte-for-byte the old
* behaviour, and deliberately so: nothing changes for any existing caller.
*
* The ctors below are how a HOST that loads several plugins into ONE
* process gives each of them its own authority. Giving a plugin its own
* origin STRING does nothing on its own — origin is never consulted on the
* hot path, which reads the store first and only mints on a miss — so what
* has to differ is the STORE the plugin presents tokens from.
*/
/**
* @brief Construct bound to an EXPLICIT token store.
*
* `token_store` is normally `&TokenManager::forIdentity(module_name)` after
* a successful `TokenManager::isolateIdentity(module_name)`; see
* LogosAPI::forIdentity(), which packages exactly that.
*
* nullptr means "the store for the identity I said I am" —
* `TokenManager::forIdentity(module_name)` — NOT the ambient singleton, so
* an isolated identity is honoured even when the caller passes nothing.
*
* `parent` is deliberately NOT defaulted here: with a default it would make
* the existing two-argument call `LogosAPI(name, nullptr)` (basecamp's
* app/main.cpp does exactly that) ambiguous against
* LogosAPI(const QString&, QObject*).
*/
LogosAPI(const QString& module_name,
TokenManager* token_store,
LogosTransportSet transports,
QObject* parent);
/** @brief Explicit token store, default transport set. */
LogosAPI(const QString& module_name, TokenManager* token_store, QObject* parent)
: LogosAPI(module_name, token_store, LogosTransportSet{}, parent) {}
/**
* @brief A LogosAPI that speaks AS `identity`, from an ISOLATED store.
*
* Isolates `identity` (idempotent) and binds the returned object to that
* identity's private token store — seeded with the bootstrap keys and
* nothing else, so its first call to any target must go through
* `capability_module.requestModule` instead of finding the target's root
* token lying in the host's ambient ring.
*
* Returns NULLPTR when the identity cannot be isolated, which happens only
* if a client for that exact name was already handed the shared store. That
* is fatal for the identity and must not be papered over by falling back to
* the host's LogosAPI: one client on the ambient ring and one on the
* private store is the "looks fixed, isn't" outcome this whole mechanism
* exists to avoid. Fail the load instead.
*
* Isolating the store is only half of an identity. The host must also make
* the name a KNOWN CALLER by registering an auth token for it with
* capability_module (`informModuleToken`), or the very first
* `requestModule` is refused by the known-caller gate.
*/
static LogosAPI* forIdentity(const QString& identity, QObject* parent = nullptr);
/**
* @brief Destructor
*/
~LogosAPI();
/**
* @brief The name of the module this LogosAPI belongs to.
*
* Every outbound call already carries it (LogosAPIClient's `origin`), it
* was just never readable from the outside. Generated consumer wrappers
* need it: a wrapper that reaches the transport through the lp C ABI must
* name its origin at client-creation time, and the only handle it is given
* is this object. Reading it here keeps the wrapper's constructor
* signature — `(LogosAPI*)` / `(LogosAPI*, const QString&)` — unchanged,
* so no call site moves.
*/
QString moduleName() const { return m_module_name; }
/**
* @brief WHO is calling the dispatch currently running ON THIS THREAD, as
* the logos-protocol caller document.
*
* Returns the JSON object logos-protocol 0.6 defines (normatively in
* logos_module_impl.h, above logos_module_set_call_caller): a mandatory
* "kind" of "unknown" | "host" | "module" | "derived" | "operator".
*
* Q_INVOKABLE, AND THAT IS THE ENTIRE POINT — this is not a convenience
* accessor. ModuleProxy::callRemoteMethod opens a logos::CallerScope in the
* HOST image, into a thread-local that lives in the host's copy of
* logos-protocol. The generated cdylib glue runs in the MODULE image, which
* links its OWN copy of both this class and that thread-local, at distinct
* addresses and with no undefined reference to the host's (Mach-O is
* TWOLEVEL, PE has no interposition at all; only ELF's flat namespace
* collapses the two). So the glue cannot call this directly: a direct call
* binds to the plugin's copy and reads a slot no CallerScope ever wrote —
* silently empty forever on macOS and Windows, and correct on Linux, which
* is exactly the asymmetry that let two prior ABI breaks through.
*
* QMetaObject::invokeMethod does land in the host image, because it
* resolves through metaObject()/qt_metacall, which are VIRTUAL and whose
* vptr was written by the HOST's constructor. It is the same cross-image
* safe channel initLogos, aboutToUnload and the authToken / hostServices /
* modulePath properties already use. LogosProviderBase::currentCallerJson()
* is the one place that performs that call; nothing else should.
*
* A RETURN VALUE, not a dynamic property, and that is not a style choice: a
* property is ONE process-global mutable slot, so two overlapping
* concurrency:"multi" dispatches from different callers would clobber each
* other's identity next to an authorization decision. The value here is
* per-thread and is read synchronously by the thread it belongs to.
*
* NEVER EMPTY. logos::currentInboundCallerJson() answers empty for "no
* dispatch is in flight on this thread", which is a third state distinct
* from {"kind":"unknown"} ("a dispatch whose caller could not be named").
* That distinction is meaningful inside the host and cannot cross the C ABI
* — on that ABI a null document is the POP — so it is collapsed exactly
* here, at the boundary, using logos-protocol's own producer rather than a
* second spelling of the same document.
*/
Q_INVOKABLE QString currentCallerJson() const;
/**
* @brief Get the client provider instance
* @return LogosAPIProvider* Pointer to the provider
*/
LogosAPIProvider* getProvider() const;
/**
* @brief Get the client instance for communicating with a module
* @param target_module The module to communicate with
* @return LogosAPIClient* Pointer to the client
*/
LogosAPIClient* getClient(const QString& target_module) const;
/**
* @brief Get the client instance — const char* overload (resolves ambiguity)
*/
LogosAPIClient* getClient(const char* target_module) const
{ return getClient(QString(target_module)); }
/**
* @brief Get the client instance for communicating with a module (std::string overload)
*/
LogosAPIClient* getClient(const std::string& target_module) const;
/**
* @brief Get a client that uses an *explicit* transport instead of
* the process-global default.
*
* Use this when the caller needs to dial one module over a
* particular protocol without side-effecting the rest of the
* process. Canonical case: a CLI that talks only to `core_service`
* over tcp_ssl — using `LogosTransportConfigGlobal::setDefault` for
* that would also flip the same process's `LogosAPIProvider` into
* trying to bind a tcp_ssl server, which the CLI has no cert for.
*
* Cached per (target_module, full LogosTransportConfig) — repeat
* calls with the same target *and* the same transport return the
* same client. The cache key covers every config field that can
* distinguish two clients (protocol, host, port, codec, all TLS
* settings), via the operator== / qHash defined alongside
* LogosTransportConfig, so two callers with different TLS or codec
* settings always get separate clients — no risk of silently
* reusing an insecure connection where a secure one was asked for.
*/
LogosAPIClient* getClient(const QString& target_module,
const LogosTransportConfig& transport) const;
/**
* @brief Get the token manager instance
* @return TokenManager* Pointer to the token manager
*/
TokenManager* getTokenManager() const;
/**
* @brief Set the transport used by the SDK's auto-`requestModule`
* token-fetch flow (inside LogosAPIClient::invokeRemoteMethod{,Async}).
*
* That flow always dials `capability_module` to fetch a per-target
* token, regardless of which module is the actual call target.
* Without an explicit transport it falls through to
* LogosTransportConfigGlobal::getDefault() (LocalSocket), which
* times out 20 s when capability_module is reachable only on TCP
* (e.g. CLI on host, daemon in container).
*
* Callers that have read the daemon's per-module advertised
* transports (e.g. from logoscore's daemon.json) should register
* capability_module's transport here so getClient builds each
* LogosAPIClient with the right capability_consumer.
*
* The setting only affects clients constructed *after* this call
* — clients already in the cache keep whatever capability transport
* they were built with.
*/
void setCapabilityModuleTransport(const LogosTransportConfig& transport);
using QObject::setProperty;
/**
* @brief Set a dynamic property from a UTF-8 std::string (delegates to QVariant + QString).
*/
bool setProperty(const char* name, const std::string& value);
private:
QString m_module_name;
LogosAPIProvider* m_provider;
// Single cache for both getClient overloads. Keyed by a
// mode-aware composite (LogosAPIClientCacheKey above) so that:
// - Mock/Local mode buckets ignore transport (the factory does too)
// - Remote mode keys include the full LogosTransportConfig
// - the no-transport overload resolves to the same key as an
// explicit caller passing LogosTransportConfigGlobal::getDefault()
// - mode switches don't return stale clients from the previous mode
mutable QHash<LogosAPIClientCacheKey, LogosAPIClient*> m_clients;
TokenManager* m_token_manager;
// ABI note: this private layout is consumed by every plugin that
// statically links the host runtime (the `logos_qt_host` archive this
// directory builds; it was called libsdk before the SDK split, and the
// same note still stands in logos-protocol's logos_api_client.h).
// Inserting a field above m_token_manager
// shifts its offset and SILENTLY breaks plugins compiled before
// the change — they read garbage where m_token_manager used to
// live, getClient() then constructs LogosAPIClients with a bogus
// TokenManager*, and the first cross-process call segfaults.
// Append new private members at the END only. (Long-term cure:
// pimpl this class so sizeof / offsets become opaque.)
//
// Optional override for the capability_module transport used by
// each LogosAPIClient's pre-built m_capability_consumer. Set via
// setCapabilityModuleTransport(). nullopt = use the global default.
std::optional<LogosTransportConfig> m_capabilityModuleTransport;
};
#endif // LOGOS_API_H