Files
logos-plugin-qt/cpp/logos_consumer.h
T
Dario LipicarandClaude Opus 5 048152f2a1 fix(glue): route informModuleToken through the INBOUND door (#26)
* fix(tokens): follow logos-protocol's inbound/outbound store split

TokenManager now keeps INBOUND (caller -> what I issued them) and OUTBOUND
(callee -> what I present) in separate maps, with the trust anchor as a
scalar credential rather than a map entry. Call sites here move to the
accessor that names the direction they meant.

The pre-split single map made a grant one way a grant BOTH ways: a token
minted so M could call B was found by B's client when B called M, so
requestModule was skipped and the access policy never ran. See
logos-protocol's companion change.

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

* fix(glue): route informModuleToken through the INBOUND door

The emitted <Provider>::informModuleToken wrote the same value through both
doors: LogosProviderBase::informModuleToken (inbound, correct) and
logos_module_accept_token (which is the OUTBOUND door). The value is a
CALLER's token — capability_module saying "moduleName may call you" — so the
second write filed a caller's inbound token as an outbound credential inside
the cdylib's own protocol copy. That is the one-way-grant bypass, reproduced
one image deeper.

It now calls logos_module_accept_inbound_token (protocol 0.8). onInit's anchor
seeding keeps logos_module_accept_token, because THAT one is genuinely
outbound: it is the module's own credential for calling core and
capability_module. The comment says so on both sides — the two paths look
interchangeable and are not.

Below 0.8 the old write stays in an #else: dropping it would break the
module's outbound calls to that peer, which is a regression, not a fix. Guards
are expanded MAJOR-aware arithmetic, unifdef-resolvable.

Requires logos-protocol fix/token-direction-key-namespace (59b27ef).

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

* chore(deps): relock logos-protocol to 0.8, and raise the consumer bound with it

WHAT MOVED. logos-protocol b37a2e9f -> 42460e5b (0.7 -> 0.8), the single
node in flake.lock; nothing else in the lock changed.

WHY IT HAD TO. This branch emits the informModuleToken glue through
logos_module_accept_inbound_token, which joins the module-impl C ABI at
protocol 0.8 and does not exist before it (21 -> 23 logos_module_* lines in
logos_module_impl.h). The emission sits behind an expanded MINOR >= 8 guard,
so at the old pin the door was simply compiled away: the qt-host-generator
check's `grep -q logos_module_accept_inbound_token inform-0.8` had nothing to
find. The lock was the whole of the failure -- no code defect underneath it.

Note the pin this moves is b37a2e9f and not the 6c24fcb1 this branch forked
from: master merged #27 in between, and its lock had already moved. That merge
is the commit below this one. Without it, the relocked flake.lock conflicts
with master on the same three lines and CI -- which builds the PR MERGE ref --
cannot check the branch out at all.

THE BOUND. #27 shipped cpp/logos_consumer.h with the fleet's only UPPER bound,
`MINOR > 7` spelled as an #error, precisely so that a protocol bump past the
consumer-admission contract stops the build instead of silently emptying every
isolated identity's token store. Relocking to 0.8 fires it by design. The
review it asks for, carried out rather than assumed:

  * bootstrapKeys(), adoptCredential() and adoptCredentialFor() are signature-
    and semantics-identical across b37a2e9f -> 42460e5b. 0.8 moved direction
    into the KEY NAMESPACE -- inbound a reserved-prefix key, outbound the bare
    peer name -- and deliberately left TokenManager's layout byte-identical.
  * credential() became DERIVED from bootstrapKeys() rather than cached, which
    strengthens this path: a cached field read empty on a store another image
    wrote and then refused every push.
  * 0.8's own adoptCredential() contract documents both halves admitConsumer
    depends on -- outbound, capability_module's proxy resolves the presented
    credential from the caller-keyed INBOUND record rather than an anchor key,
    so the caller is named as the identity and not as the host; inbound,
    capability_module pushes with getToken(moduleName), which IS that
    credential, so informModuleToken's trusted-channel gate still passes.

So the bound is raised 7 -> 8, with that reasoning recorded at the guard. The
oracle is the consumer-admission check and not the argument: it runs a real
ModuleProxy in Local mode, and its "NO LOCKOUT: the consumer's own credential
authorizes at capability_module" / "and it is NAMED as itself, not as the host"
assertions are exactly the failure the #error exists to prevent. Both pass at
0.8. It is negative-validated upstream (removing the adopt step fails 5 checks,
swapping the order fails 2), so its green is worth something.

ALL 12 CHECKS BUILT INDIVIDUALLY, x86_64-linux, from source against
cache.nixos.org only (cache.nix.logos.co is returning 502):

  PASS  vanilla-plugin           /nix/store/ab6l4a0czh4nd4h153i8hz82qayb4ah4-logos-plugin-qt-vanilla-test-0.0.1
  PASS  header-generator-guard   /nix/store/1dxrvzk4d1r3babyfx6hfnv9va1im5v6-logos-plugin-qt-header-generator-guard-test
  PASS  headers-emitter-routing  /nix/store/yrjzx24bq010m3z0xm0djvmr3n1wn1fx-logos-plugin-qt-headers-emitter-routing-test
  PASS  consumer-api-style-gate  /nix/store/r5h073ygr1zfy763dmhxssgw9mcl96y8-logos-plugin-qt-consumer-api-style-gate-test
  PASS  qt-host                  /nix/store/mndrdrxcad6kq056jcrxf946iycp5yqg-logos-qt-host-0.1.0
  PASS  shared-runtime-layering  /nix/store/npzah7n5zj7gdlbs1d59ry0idd9vp6si-logos-qt-host-shared-runtime-layering
  PASS  qt-host-generator        /nix/store/xqlhpbz7bnfvz7c17x1aanfassglmrq6-logos-qt-host-generator-test
  PASS  unload-contract          /nix/store/2y64h1im2biyqpbmg0bi591rznl860yx-logos-qt-host-unload-contract-test
  PASS  caller-contract          /nix/store/w1lz78kfy91xcyfd35i277f030jjf0ag-logos-qt-host-caller-contract-test
  PASS  caller-invokable         /nix/store/68b83pvv90xsqszihgshpb5g3fikfmj4-logos-qt-host-caller-invokable-test-0.1.0
  PASS  consumer-admission       /nix/store/hiwnlafxhh5gz0f1pkdi53glw66qm3rq-logos-qt-host-consumer-admission-test-0.1.0
  PASS  glue-compiles            /nix/store/bx9qfp520yazhmmc1in37frsscs6iii8-logos-qt-host-glue-compiles-test-0.1.0

The qt-host closure references logos-protocol-lib-0.8.0, so the relock is in
the artefact and not merely in the lock file. CI itself runs only 4 of these 12.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 14:24:03 -03:00

195 lines
9.9 KiB
C++

#ifndef LOGOS_CONSUMER_H
#define LOGOS_CONSUMER_H
#include "logos_protocol.h"
#include "logos_shared_api.h"
#include <QString>
class LogosAPI;
class QObject;
/**
* @file logos_consumer.h
* @brief Admitting a NON-MODULE CONSUMER to a running Logos system.
*
* WHAT A CONSUMER IS, and why it needed a name of its own. A module is loaded,
* published to the registry, callable by anyone who can get a token for it, and
* lives in --modules-dir. A CONSUMER is none of those things: it is a QML view,
* an in-process widget plugin, or a co-process view host that only ever CALLS
* out. It still needs an identity, because every outbound call presents a token
* and something has to decide which tokens it may present.
*
* WHY THIS EXISTS AT ALL. Two applications hand-rolled the same three steps
* independently — isolate a token store, mint a UUID, register that UUID with
* capability_module — and the pure-QML identity bug was exactly one of them
* getting the ORDER wrong: logos-basecamp did the registration inside its
* has-a-backend branch, below an early return, so pure-QML plugins registered
* nothing at all. It went unnoticed because the view was calling on the host's
* ambient token ring, where every token already existed and no handshake ever
* happened. A convention repeated in two places is a convention that will
* differ in two places.
*
* WHY logos-plugin-qt AND NOT logos-liblogos. Both hand-rolled sites call
* LogosAPI, which lives here; so does ui-host in logos-view-module-runtime,
* which links this library and Qt but NOT liblogos. liblogos DEPENDS on this
* library, so putting the operation there would put it above two images that
* need it and cannot reach it. It also could not return the thing every caller
* actually needs — a LogosAPI* for the identity — across the logos_core_* C
* boundary, so a liblogos home would unify the TOKEN half and leave
* LogosAPI::forIdentity hand-rolled at each site: the two halves split across a
* repo boundary, which is precisely the split the ordering bug lived in.
*
* A SEPARATE HEADER, not a static on LogosAPI, because LogosAPI is the object
* handed to every module and plugin, and this is a verb only a HOST may say.
*/
// ── the wave order, made a build failure ────────────────────────────────────
//
// A private token store is created EMPTY as of protocol 0.7. Everything that
// makes that survivable lives HERE and in the hosts: admitConsumer mints,
// registers and installs an identity's own credential. Bump logos-protocol
// past this repo and every isolated identity gets an empty store — the
// outbound handshake dies at ModuleProxy's `authToken.isEmpty()` and every
// in-process consumer is refused.
//
// Nothing would stop that build. Every LOGOS_PROTOCOL_VERSION_MINOR guard in
// the fleet is `>=`, so a newer protocol satisfies all of them, and in
// basecamp's ui_qml path the ui-host half keeps working — the co-process
// adopts its credential on stdin — so the integration tests can stay GREEN
// while every in-process bridge is refused.
//
// So the ordering constraint is spelled as a compile error rather than left to
// a reviewer. If this fires, the fix is to update logos-plugin-qt and the
// hosts in the same wave as the protocol bump, then raise the bound.
//
// RAISED 7 -> 8 for logos-protocol 0.8 (the INBOUND/OUTBOUND direction split),
// which is the wave THIS repo moves in: the glue emitted here now routes
// informModuleToken through logos_module_accept_inbound_token. The review the
// error above asks for, carried out against protocol 42460e5b:
//
// * bootstrapKeys(), adoptCredential() and adoptCredentialFor() are
// signature- and semantics-identical to 0.7. 0.8 changed the KEY NAMESPACE
// (inbound is a reserved-prefix key, outbound stays the bare peer name),
// not how a store is seeded, and TokenManager's layout is byte-identical.
// * credential() became DERIVED from bootstrapKeys() rather than cached.
// That STRENGTHENS this path: a cached field read empty on a store another
// image wrote and then refused every push.
// * 0.8's own adoptCredential() contract spells out both halves of what
// admitConsumer needs -- OUTBOUND, the identity presents its credential and
// capability_module's proxy resolves it from the caller-keyed inbound
// record rather than an anchor key, so the caller is named as the identity
// and not as the host; INBOUND, capability_module pushes with
// getToken(moduleName), which IS that credential, so informModuleToken's
// trusted-channel gate still passes.
//
// The consumer-admission check is the oracle, not this comment: it runs a real
// ModuleProxy in Local mode and asserts the consumer authorizes AS ITSELF.
#if defined(LOGOS_PROTOCOL_VERSION_MINOR) \
&& (LOGOS_PROTOCOL_VERSION_MAJOR > 0 \
|| (LOGOS_PROTOCOL_VERSION_MAJOR == 0 && LOGOS_PROTOCOL_VERSION_MINOR > 8))
# error "logos-protocol is newer than the consumer-admission contract this file implements. \
A private token store is created empty; if the protocol changed how a consumer is seeded, \
this file and the hosts calling logos::admitConsumer must move in the SAME wave. Review \
adoptCredentialFor / bootstrapKeys, then raise this bound."
#endif
namespace logos {
/**
* @brief What a host gets back for an admitted consumer.
*
* `api` speaks AS the consumer, on the consumer's own isolated token store.
* Hand it to the view / widget / bridge; it is parented to whatever `parent`
* was passed to admitConsumer.
*
* `credential` is that identity's own token. Give it to a CO-PROCESS of the
* same identity — ViewModuleHost::spawn hands it to ui-host, which adopts it
* into its own image's store — and to nothing else. It is not a capability, it
* is a name: anything holding it can speak as this consumer.
*/
struct ConsumerIdentity {
LogosAPI* api = nullptr;
QString credential;
explicit operator bool() const noexcept { return api != nullptr; }
};
/**
* @brief Admit a non-module consumer under `identity`.
*
* ONE SENTENCE: gives a name a private token store, mints its credential, tells
* capability_module about it, and puts it in that store — so the identity can
* ask for capabilities, and can be NAMED when it does.
*
* The four steps, in this order, and the order is the mechanism:
*
* 1. TokenManager::isolateIdentity(identity) — must precede any client for
* the name, because LogosAPIClient captures its store as a raw pointer at
* construction. LogosAPI::forIdentity does this and then constructs.
* 2. The LogosAPI is created on a store that is now EMPTY. It cannot call
* anything yet, and that is deliberate.
* 3. REGISTER FIRST: informModuleToken(identity, credential) over `hostApi`'s
* trusted channel — synchronous, so it completes before this returns.
* 4. ADOPT SECOND: the credential goes into the identity's own store.
*
* Register-before-adopt makes the bad window IMPOSSIBLE rather than merely
* short: at no instant does the consumer hold a credential capability_module
* has not already accepted. And all four steps complete before the caller
* creates the bridge or widget, which closes the other race both hosts
* documented — plugin constructors routinely schedule their first IPC via
* QTimer::singleShot(0, ...), which fires the moment the event loop turns.
*
* `hostApi` is the HOST's LogosAPI (basecamp's "core", standalone's
* "standalone"): informModuleToken is accepted only from the trusted
* core/capability channel, and the host is that channel.
*
* Returns a falsy ConsumerIdentity on ANY failure, and every one of them is
* fatal for the load rather than something to continue past:
* * the name could not be isolated (a client for it already exists on the
* ambient ring — half an identity is worse than none);
* * there is no capability_module client, or the host holds no
* capability_module token;
* * capability_module refused the registration.
* Falling back to the host's own LogosAPI on any of these is what produced the
* elevation this whole surface replaces.
*/
LOGOS_QT_HOST_API ConsumerIdentity admitConsumer(const QString& identity,
LogosAPI* hostApi,
QObject* parent = nullptr);
/**
* @brief Rotate an already-admitted consumer's credential — a reload.
*
* Mint, register, RESET the identity's store, adopt. The reset is not
* housekeeping: a reload re-registers, and ModuleProxy::saveToken overwrites
* m_tokens[name], so the previous credential is dead at the target the instant
* the new one is accepted. A store left holding the stale credential is a
* locked-out reload that looks like a live one. The reset also drops per-target
* tokens minted for the previous incarnation, which are stale anyway.
*
* Returns the new credential, or an empty string on failure — which is fatal
* for the reload, for the same reasons admitConsumer's failures are.
*/
LOGOS_QT_HOST_API QString reissueConsumerCredential(LogosAPI* consumerApi,
LogosAPI* hostApi);
/**
* @brief Adopt a credential that was minted and registered ELSEWHERE.
*
* For a co-process of an already-admitted identity: ui-host is handed its
* parent's per-spawn credential on stdin and must install it in its own image's
* token store. No isolation and no registration — the parent did both, and
* doing either again from here would be wrong (a second registration would
* invalidate the credential the parent is still holding).
*
* This exists so the bootstrap key set stops being spelled out in a fifth
* place; TokenManager::bootstrapKeys() owns it.
*/
LOGOS_QT_HOST_API void adoptConsumerCredential(LogosAPI* consumerApi,
const QString& credential);
} // namespace logos
#endif // LOGOS_CONSUMER_H