Files
logos-protocol/cpp/logos_module_impl.h
T
Dario Gabriel LipicarandClaude Opus 5 42460e5b2a fix(tokens): separate INBOUND from OUTBOUND, without moving a single byte
A grant one way was a grant both ways. TokenManager was ONE flat map with no
direction tag, written from both sides of every relationship: LogosAPIClient
stored the token it will PRESENT to a callee under the CALLEE's name, and a
token RECEIVED from a caller was stored under the CALLER's name. Same key
namespace, last write wins.

Measured on the shipped fleet with two ordinary modules doing nothing unusual:
one grant A -> B leaves the SAME token value under both opposite-meaning keys,
and the never-granted B -> A call then succeeds. Silently.

  A.callOther(B, ping)   CALL_OK
  T1 A holds token for B?   val=7685c776-...
  T1 B holds token for A?   val=7685c776-...      <-- one value, two meanings
  B.callOther(A, ping)   CALL_OK                  <-- never granted

WHY THE LAYOUT COULD NOT CHANGE. TokenManager's layout is a cross-package ABI:
the host ALLOCATES the object and module/UI-plugin images MUTATE it through
their own statically-linked accessors — and host and modules ship as separate
.lgx that mix versions at runtime by design. The header's ABI-safety note is
about ALLOCATION ("no consumer allocates one, none needs sizeof"); the hazard
is MUTATION.

Splitting into three members took sizeof 32 -> 64 and moved m_mutex 24 -> 56.
QMutex::fastTryLock() compare-exchanges at this+24, which in that layout is
m_inbound's QHash d-pointer. Empty, the old code silently borrows the hash's
pointer slot as a mutex and puts it back, so it LOOKS fine; non-empty, the
exchange fails and lockInternal() interprets the QHash Data* as a
QMutexPrivate* and futex-waits on it — hung forever, inside a token-store
write, on the module host's Qt main thread. No crash, no log line, no timeout
that recovers. Reproduced by calling the shipped 0.6 plugin's own saveToken on
a 0.7 object: exit=124.

So direction lives in the KEY NAMESPACE instead. Outbound is the bare peer name
(byte-identical to master); inbound is "\x01in\x01" + caller. m_tokens@16,
m_mutex@24, sizeof 32 — measured identical to master in every shipped image,
pinned by a static_assert against a reference struct that fires if a member is
added.

Two things a key namespace forces that separate members did not: every door
REFUSES a key carrying the namespace character, or a wire-supplied caller name
could forge across the direction boundary; and credential() is DERIVED from
bootstrapKeys() rather than cached, because a cached field reads empty on a
store another image wrote and then refuses every push.

AN ANCHOR KEY IS NO LONGER SPELLED AS A MODULE NAME. scanIssuedTokens' m_tokens
loop offered every matched key unconditionally while the m_store loop
deliberately never offers, so "an anchor must never name a caller" was enforced
on one side only. A module announcing itself as "core" — which logos-rust-sdk
did unprompted — therefore authorized as kind:module name:core. The rule
generalises: a store may only name a caller with a key it alone can write.
Implemented as a masked operand, so the comparison count is unchanged;
RefusingToNameAnAnchorKeyCostsNoComparison pins that via
logos::tokenComparisonCount().

lp_token_save / lp_token_save_for now return LP_ERR_INVALID_ARG on a reserved
key instead of LP_OK. Only the return code was wrong; saveToken already refused.

PROTOCOL 0.8: logos_module_accept_inbound_token joins the module-impl C ABI
(12 exports). onInit keeps logos_module_accept_token for the module's own
anchor — that one IS outbound, and merging the two paths is what reintroduces
the bug.

Supersedes the field-split approach; the semantics are unchanged from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 11:16:33 -03:00

302 lines
16 KiB
C

#ifndef LOGOS_MODULE_IMPL_H
#define LOGOS_MODULE_IMPL_H
/* ===========================================================================
* logos_module_impl.h — the COMMON module-impl C ABI.
*
* ONE contract for module implementations in every language: a Logos module
* compiles to a cdylib exporting exactly these symbols. The C++ SDK emits
* this wrapper around a universal C++ impl class; the Rust SDK emits it
* around a Rust impl. The uniform generated Qt-plugin glue (and, later, a
* no-Qt host) talks to the cdylib ONLY through this ABI — the glue is
* identical regardless of the module's source language, which is what makes
* the eventual Qt-glue removal a host swap instead of a per-language change.
*
* Data model mirrors the lp_* consumer ABI (logos_protocol.h):
* - method args / event payloads: JSON array (UTF-8 const char*)
* - results: JSON value
* - bytes: the canonical {"_bytes":"<base64url>"} tagged form
* - errors from dispatch: NULL return, or a canonical error object
* {"code","message","origin"} returned as the result of a failed call
* when the implementation prefers structured errors.
*
* Ownership: every char* RETURNED by the module is heap-allocated and the
* CALLER frees it with logos_module_string_free (exported by the module so
* allocator domains never mix). Every const char* passed IN is borrowed.
*
* Threading: the host serializes dispatch calls (one at a time) unless a
* future capability negotiates otherwise. The emit callback may be invoked
* from any module thread; the host marshals.
*
* Versioning: logos_module_get_protocol_version() returns the
* logos-protocol semver the module was COMPILED against (forwarded from
* LOGOS_PROTOCOL_VERSION_STRING, never minted). Hosts apply the same rule
* as the metadata stamp: equal MAJOR ⇔ compatible. This runtime handshake
* complements the build-time metadata stamp and is what a no-Qt host (no
* Qt plugin metadata) negotiates with.
* =========================================================================== */
#ifdef __cplusplus
extern "C" {
#endif
#if defined(_WIN32)
#define LOGOS_MODULE_IMPL_EXPORT __declspec(dllexport)
#else
#define LOGOS_MODULE_IMPL_EXPORT __attribute__((visibility("default")))
#endif
/* Event-emission callback installed by the host/glue. `data_json` is a JSON
* array payload, borrowed for the duration of the call. */
typedef void (*logos_module_emit_cb)(const char* event_name,
const char* data_json,
void* user_data);
/* ---------------------------------------------------------------------------
* Exported by every module cdylib (generated by the SDK of the module's
* language; module authors never write these by hand).
* ------------------------------------------------------------------------- */
/* Dispatch a method call. Returns the result JSON value as a heap string
* (free with logos_module_string_free), or NULL when the method is unknown
* or dispatch failed structurally. */
LOGOS_MODULE_IMPL_EXPORT char* logos_module_dispatch(const char* method,
const char* args_json);
/* The module's method/event metadata as a JSON array — same shape as
* LogosProviderObject::getMethods() (entries tagged "method"/"event"). */
LOGOS_MODULE_IMPL_EXPORT char* logos_module_get_methods(void);
/* Module identity/context, stamped by the host before the first dispatch:
* module path, instance id, per-instance persistence path. Mirrors
* LogosModuleContext / RustModuleContext. Any argument may be NULL. */
LOGOS_MODULE_IMPL_EXPORT void logos_module_set_context(
const char* module_path,
const char* instance_id,
const char* instance_persistence_path);
/* Install the host's event-emission callback. The module keeps (cb,
* user_data) and invokes cb once per emitted event. Passing NULL clears it;
* after the clearing call returns, the module must not invoke the old cb. */
LOGOS_MODULE_IMPL_EXPORT void logos_module_set_emit_callback(
logos_module_emit_cb cb, void* user_data);
/* THE OUTBOUND DOOR. Deliver the token this module will PRESENT when it CALLS
* `module_name`. Returns 0 on acceptance.
*
* ONE MEANING ONLY, and that is the change: the glue used to call this from two
* places with two opposite meanings -- seeding the module's own anchor in
* onInit (outbound, correct) and forwarding a CALLER's token from
* informModuleToken (inbound, filed as an outbound credential). The second
* write is what let a module present, to a peer, the very token that peer had
* been issued to call IT -- the direction collision, one image deeper than the
* one ModuleProxy::authorize sees. The caller path now goes through
* logos_module_accept_inbound_token below; this one is the anchor seeding and
* nothing else. Do not merge them back together. */
LOGOS_MODULE_IMPL_EXPORT int logos_module_accept_token(const char* module_name,
const char* token);
/* THE INBOUND DOOR. Record that `caller` may present `token` when it calls THIS
* module. Returns 0 on acceptance.
*
* WHY IT IS A SECOND SYMBOL RATHER THAN A FLAG ON THE FIRST. A parameter is a
* value a caller can get wrong, and default; a separate name either resolves or
* does not. The two doors write two disjoint key namespaces in the image's
* TokenManager (logos-protocol cpp/token_manager.h), and nothing reachable from
* one can read the other -- which is the property that makes a grant one way
* stop being a grant the other way.
*
* WHAT A TOKEN REGISTRY GETS INSTEAD, said here because it is the one thing
* about this door that surprises. To an ordinary provider `informModuleToken`
* means "this caller may call you". To the module holding the token registry
* (capability_module) the SAME wire message means "here is module X's token,
* present it when you call X" -- outbound. So lp_token_save_inbound, which this
* forwards to, ALSO writes the outbound half when, and only when, the image has
* been granted the "token_registry" host service. Without that carve-out
* capability_module's roster (lp_token_keys) empties and every requestModule in
* the fleet is refused with "rejecting request from unknown module identity".
* The grant is the declaration of the role, so the role decides -- see
* lp_token_save_inbound in logos_protocol.h.
*
* CONDITIONAL on protocol >= 0.8, with the same teeth as every other entry
* here: the glue emits a DIRECT call, so a module generated for >= 0.8 whose
* backend omits this definition links cleanly and then fails at dlopen() on
* ELF with "undefined symbol" -- invisible on macOS. Both backends
* (logos-cpp-sdk's lidl_gen_cdylib.cpp and logos-rust-sdk's
* rustgen_provider.rs) owe it in the SAME WAVE as this declaration.
* nix/module-impl-abi.nix is what makes that fail in CI instead of at a user's
* dlopen -- but only after each backend bumps its logos-protocol lock, which is
* the real lag to watch. */
LOGOS_MODULE_IMPL_EXPORT int logos_module_accept_inbound_token(const char* caller,
const char* token);
/* Grant the module the privileged host services named in `services_json` (a
* JSON array from the closed set lp_grant_host_services documents). Returns 0
* on acceptance; the generated implementation simply forwards to
* lp_grant_host_services.
*
* Called by the host AFTER it has verified the module's identity, and only for
* the modules its policy designates as a trust root — nothing about this ABI
* decides who deserves the grant.
*
* It has to travel this way, and that is the subtle part: the host binary and
* the module cdylib each link their own copy of logos-protocol, so each has its
* own process-global grant state, exactly as each has its own TokenManager. A
* grant the host records for itself is invisible to the gate the cdylib checks.
* Pushing it in over this ABI — the same route the auth token above already
* takes — is what puts the grant in the image whose gates it must open. */
LOGOS_MODULE_IMPL_EXPORT int logos_module_grant_host_services(
const char* services_json);
/* Teardown completion callback, installed by the glue before it asks the module
* to unload. May be invoked from any module thread. */
typedef void (*logos_module_unload_done_cb)(void* user_data);
/* Install the teardown-completion callback. Called before
* logos_module_about_to_unload(); a NULL cb clears it. */
LOGOS_MODULE_IMPL_EXPORT void logos_module_set_unload_done_callback(
logos_module_unload_done_cb cb, void* user_data);
/* Ask the module to prepare for teardown. Returns 0 when it is already
* quiescent, 1 when it has work to finish and will invoke the callback
* installed above exactly once when done.
*
* The wait is BOUNDED by the host: returning 1 buys a grace period, not a veto.
* A module that never signals delays every teardown by that period and is torn
* down anyway, so the deadline is real rather than a courtesy.
*
* CONDITIONAL on the protocol version, which is not the same as optional at
* load time. The glue emits a DIRECT call — no dlsym, no null check — so a
* module generated for >= 0.5 whose backend omits the definition links cleanly
* and then fails at dlopen(), on ELF, with "undefined symbol". The pair is
* skippable only where the CALLER was generated below 0.5 and emitted no call.
*
* An earlier version of this comment argued the arrangement was safe because
* "the glue is generated alongside the module". That does not follow, and the
* ABI has now been broken twice on the strength of it. Being generated in the
* same build makes the two agree on the protocol VERSION; it says nothing about
* which SYMBOLS a given language backend's emitter writes for that version,
* because each backend implements this ABI independently. Both breakages —
* grant_host_services at 0.3 and this pair at 0.5 — happened at perfect version
* agreement, and both were invisible on macOS (plugins link
* -undefined dynamic_lookup) and fatal on Linux (nixpkgs' -Wl,-z,now binds
* eagerly).
*
* So every backend owes a build-time check that its generated scaffold defines
* everything declared here. nix/module-impl-abi.nix publishes this file's
* export list as data for exactly that purpose. */
LOGOS_MODULE_IMPL_EXPORT int logos_module_about_to_unload(void);
/* ---------------------------------------------------------------------------
* THE CALLER OF A DISPATCH.
*
* Push the identity of whoever is making the call that is about to run
* (`caller_json` non-NULL), or pop it (`caller_json` NULL). The glue wraps
* exactly one logos_module_dispatch() in one push/pop pair, on the thread that
* dispatch runs on; the module's language binding surfaces the innermost value
* to the handler as an AMBIENT accessor — logos::currentCaller() in C++,
* logos_rust_sdk::current_caller() in Rust.
*
* NOT A LIDL PARAMETER, and that is a requirement rather than an implementation
* detail. Who is calling is not part of a module's interface: the callee already
* possesses that identity, because the caller had to present a token this module
* itself issued in order to get here at all. This ABI only surfaces what
* authorization already established. Nothing about it is per-method, opt-in, or
* visible in a .lidl file.
*
* A PER-THREAD STACK, not a single slot. A handler that makes an outbound call
* spins a nested event loop, and a second inbound call can be delivered on the
* same thread inside it; a plain set/clear pair would have the inner call's pop
* erase the outer call's caller. So: non-NULL pushes, NULL pops the innermost,
* a pop with nothing pushed is a no-op, and each thread has its own stack. One
* symbol, because one symbol is what the export list checks for.
*
* VALID ONLY DURING A DISPATCH, ON THE DISPATCHING THREAD. A worker the module
* spawned, a timer callback, a context-ready hook and an event emission all read
* Unknown — correctly, since none of them has a caller. A handler that needs the
* identity beyond its own frame copies it at the top.
*
* ── THE DOCUMENT ────────────────────────────────────────────────────────────
*
* `caller_json` is a JSON OBJECT. This is its normative definition; the C++ and
* Rust types that parse it are per-language, exactly as the {"_bytes":...} form
* and the {"code","message","origin"} error object are.
*
* {"kind":"unknown"}
* {"kind":"host"}
* {"kind":"module","name":"chat_module"}
* {"kind":"module","name":"chat_module","instance":"a41f"}
* {"kind":"derived","parent":"wallet_module","leaf":"wallet_ui"}
* {"kind":"operator","name":"ops-readonly"}
*
* Rules, in the order a reader applies them:
*
* 1. "kind" is MANDATORY. Missing, non-string, an unparseable document, or
* empty input ⇒ unknown.
* 2. An UNRECOGNISED "kind" ⇒ unknown. Never a closest match, never dropped.
* This is the only safe direction: adding an arm can turn an old reader's
* is_module(x) from true to false, never the reverse. A permissive fallback
* would do the opposite and silently WIDEN a predicate that sits next to
* authorization decisions.
* 3. Unrecognised FIELDS inside a known arm are ignored, so an arm can gain a
* field without a version bump.
* 4. A known arm missing a required field ⇒ unknown, not a partial value.
* 5. "host" carries NO name and must not gain one. "core" and
* "capability_module" hold the same token VALUE under two keys by
* construction, so a name there would be a coin flip presented as a fact.
* 6. "instance" is optional FROM DAY ONE and is_module(name) deliberately
* ignores it. The draft spec's authenticated invoker is a module instance
* address rather than a bare name; shipping the field later would have
* silently reinterpreted every call site the moment instance addressing
* arrived. A caller that must distinguish instances compares the full
* identity.
*
* An "instance" that is PRESENT but not a string is DROPPED, and the
* module is still identified by its name. This is normative because the
* two backends disagreed about it — C++ dropped it, Rust returned
* "unknown" — each with a passing test pinning its own answer, so neither
* suite could see the divergence. The rule is decided here rather than in
* either of them.
*
* Dropping is the correct arm on two grounds. "name" comes from the host's
* own token resolution and is not made less trustworthy by a malformed
* sibling field; and is_module(name), which is the path essentially every
* caller takes, ignores "instance" entirely. Failing the whole identity
* would turn a cosmetic wire defect into a fleet-wide authorization change,
* and would mean a later protocol emitting a richer "instance" silently
* stops one language's modules recognising callers the other still does.
* A backend that needs the distinction reads the field itself.
*
* Nothing here is spelled "verified". capability_module checks only that an
* asserted name EXISTS as a key, so the strongest honest word for a named module
* is token-bound.
*
* PRODUCERS TODAY: "unknown", "host", and "module" WITHOUT "instance". The
* "derived" and "operator" arms are specified and must be parsed, but nothing
* emits them yet — said here rather than left for someone to discover.
* "derived" is reserved for the isolated per-plugin identity
* (LogosAPI::forIdentity); "operator" needs ModuleProxy::TokenValidator widened
* from bool to also yield a name, which is a logos-logoscore-cli change.
*
* CONDITIONAL on protocol >= 0.6, with the same teeth as the teardown pair
* above: the glue emits a DIRECT call, so a module generated for >= 0.6 whose
* backend omits this definition links cleanly and then fails at dlopen() on ELF
* with "undefined symbol". Both backends owe it in the same wave as this
* declaration; nix/module-impl-abi.nix is what makes that fail loudly instead
* of at a user's dlopen.
* ------------------------------------------------------------------------- */
LOGOS_MODULE_IMPL_EXPORT void logos_module_set_call_caller(const char* caller_json);
/* The logos-protocol semver this module was compiled against. Static
* string — do NOT free. */
LOGOS_MODULE_IMPL_EXPORT const char* logos_module_get_protocol_version(void);
/* Free a string returned by this module. Safe on NULL. */
LOGOS_MODULE_IMPL_EXPORT void logos_module_string_free(char* s);
#ifdef __cplusplus
}
#endif
#endif /* LOGOS_MODULE_IMPL_H */