Files
logos-plugin-qt/cpp/logos_consumer.cpp
Dario LipicarandClaude Opus 5 7cad5ec381 feat(consumer): logos::admitConsumer — one home for admitting a non-module (#27)
* feat(consumer): logos::admitConsumer — one home for admitting a non-module

Two hosts hand-rolled the same three steps independently — isolate an
identity, mint a credential, register it with capability_module — and the
duplication had already produced a bug: basecamp did the registration
inside the has-a-backend branch, below an early return, so pure-QML plugins
registered nothing. It survived only because those calls went out on the
host's ambient ring, where every token already existed and the handshake
was never reached. Remove the ambient ring (logos-protocol #71) and that
becomes a hard failure.

So the operation gets one home. admitConsumer isolates, mints, registers
and installs the credential as a single step, and hands back a
ConsumerIdentity. Both hosts lose their private copies: basecamp -16 net
lines, standalone-app -14.

It lives here rather than in logos-liblogos, which is where it was first
proposed. Both hand-rolled sites call LogosAPI, which is this repo's, and
the operation needs a capability_module client to register through — going
via liblogos's C API would add a hop for hosts already holding the Qt
object. logos-liblogos needs no change at all.

Named for what it does: a CONSUMER is admitted, not registered as a module.
It is never published to the registry, never callable, never in
--modules-dir.

Two guards, because the sharp edges here are silent ones:

  * adoptConsumerCredential REFUSES an isolated store. It exists for a
    co-process adopting its parent's credential into its own process ring,
    where the store IS that ring. Pointed at an in-process private store it
    would install whatever it was handed — including the host anchor —
    straight past adoptCredentialFor's refusal, putting the elevation #71
    removes one call away again.

  * A #error when logos-protocol is newer than the contract this file
    implements. The wave order is mandatory and was unenforced: bump the
    protocol alone and every isolated identity gets an empty store, while
    nothing fails to build (every MINOR guard in the fleet is >=) and the
    integration tests stay green (the ui-host half keeps working off its
    stdin credential). The bound fired on its first build — this change
    ships protocol 0.7 and it was set to 6 — and was proven non-inert by
    going red at 0.8.

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

* chore(deps): relock logos-protocol onto master, which now carries #71

admitConsumer calls TokenManager::adoptCredentialFor and adoptCredential.
Both arrived with logos-protocol#71 ("a private store is created EMPTY"), so
the lock had to move past it — this branch still pinned 6c24fcb1, which is
0.6.0 and has neither:

  logos_consumer.cpp:89:  error: 'adoptCredentialFor' is not a member of 'TokenManager'
  logos_consumer.cpp:155: error: 'class TokenManager' has no member named 'adoptCredential'

Now b37a2e9f, protocol master with #71 merged.

This is the wave order the header documents, seen from the other side: the
empty-store change lands in protocol, and everything that makes it survivable
lands here. The guard in logos_consumer.h fires when this repo is BEHIND the
protocol; this commit is the ordinary case of catching up to it.

Verified on x86_64-linux with the relocked input:
  consumer-admission  PASS
  qt-host             PASS
  caller-contract     PASS
  glue-compiles       PASS

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

---------

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

157 lines
6.5 KiB
C++

#include "logos_consumer.h"
#include "logos_api.h"
#include "logos_api_client.h"
#include "token_manager.h"
#include <QDebug>
#include <QUuid>
namespace {
// A per-admission secret. UUID rather than anything derived from the identity
// name: the value must not be guessable from public information, because
// holding it IS being that consumer.
QString mintCredential()
{
return QUuid::createUuid().toString(QUuid::WithoutBraces);
}
// Make (identity, credential) a known caller AT capability_module.
//
// Deliberately over the HOST's client and the HOST's token: informModuleToken
// is accepted only from a caller presenting the trusted core/capability
// channel's token (ModuleProxy::informModuleToken), and the host is that
// channel. The consumer cannot register itself — that is the entire point.
bool registerAtCapability(LogosAPI* hostApi, const QString& identity,
const QString& credential)
{
if (!hostApi) {
qWarning() << "logos::admitConsumer: no host LogosAPI - identity" << identity
<< "cannot be registered, so every call it makes would be refused";
return false;
}
LogosAPIClient* cap = hostApi->getClient(QStringLiteral("capability_module"));
if (!cap) {
qWarning() << "logos::admitConsumer: no capability_module client - identity"
<< identity << "will not be registered";
return false;
}
TokenManager* hostStore = hostApi->getTokenManager();
const QString hostCapToken =
hostStore ? hostStore->getToken(QStringLiteral("capability_module")) : QString();
if (hostCapToken.isEmpty()) {
qWarning() << "logos::admitConsumer: the host holds no capability_module token,"
" so it is not the trusted channel; identity" << identity
<< "will not be registered";
return false;
}
if (!cap->informModuleToken(hostCapToken, identity, credential)) {
qWarning() << "logos::admitConsumer: capability_module.informModuleToken failed"
" for identity" << identity;
return false;
}
return true;
}
} // namespace
logos::ConsumerIdentity logos::admitConsumer(const QString& identity,
LogosAPI* hostApi,
QObject* parent)
{
if (identity.isEmpty()) {
qWarning() << "logos::admitConsumer: refusing to admit an unnamed consumer";
return {};
}
// (1) + (2): isolate, then construct. LogosAPI::forIdentity does both in
// that order and returns nullptr if the name was already vended on the
// ambient ring — which must fail the load rather than fall back to the
// host's own LogosAPI.
LogosAPI* api = LogosAPI::forIdentity(identity, parent);
if (!api) {
qWarning() << "logos::admitConsumer: could not give" << identity
<< "a token store of its own - refusing to run it with the"
" host's authority";
return {};
}
const QString credential = mintCredential();
// (3) REGISTER, and only then (4) ADOPT. See the header: this order is what
// makes the window in which the consumer holds an unknown credential not
// merely short but nonexistent.
if (!registerAtCapability(hostApi, identity, credential)) {
delete api;
return {};
}
if (!TokenManager::adoptCredentialFor(identity, credential)) {
// Reachable in exactly two ways, and both are bugs here rather than
// conditions to survive: the identity is not isolated (impossible, step
// 1 succeeded) or the minted credential collided with the host's own
// anchor (impossible with a UUID). Fail loudly.
qWarning() << "logos::admitConsumer: could not install" << identity
<< "'s own credential in its store - it would be locked out";
delete api;
return {};
}
return ConsumerIdentity{api, credential};
}
QString logos::reissueConsumerCredential(LogosAPI* consumerApi, LogosAPI* hostApi)
{
if (!consumerApi) return {};
const QString identity = consumerApi->moduleName();
if (identity.isEmpty()) return {};
if (!TokenManager::isIsolated(identity)) {
qWarning() << "logos::reissueConsumerCredential:" << identity
<< "was never admitted (its store is the ambient ring);"
" refusing to rotate a credential it does not have";
return {};
}
const QString credential = mintCredential();
if (!registerAtCapability(hostApi, identity, credential)) return {};
// Reset AFTER the registration lands: between the two, the identity holds a
// credential that is already dead at the target, and the reset is what
// removes it. Doing the reset first would widen that window rather than
// close it.
TokenManager::resetIdentity(identity);
if (!TokenManager::adoptCredentialFor(identity, credential)) {
qWarning() << "logos::reissueConsumerCredential: could not install the new"
" credential for" << identity << "- it is now locked out";
return {};
}
return credential;
}
void logos::adoptConsumerCredential(LogosAPI* consumerApi, const QString& credential)
{
if (!consumerApi || credential.isEmpty()) return;
TokenManager* store = consumerApi->getTokenManager();
if (!store) return;
// Refused against an ISOLATED store, and this is the whole reason the
// function is narrow. adoptCredential checks neither isolation nor
// anchor-equality — correct for the co-process case it exists for, where
// the store IS the process ring and the credential came in on stdin. Point
// it at an in-process private store and it becomes a public verb that will
// install whatever it is handed, including the host's anchor, straight past
// adoptCredentialFor's refusal. The elevation this change removed would be
// one call away again.
if (store != &TokenManager::instance()) {
qWarning() << "logos::adoptConsumerCredential: refusing an ISOLATED store."
<< "This verb is for a co-process adopting its parent's"
<< "credential into its own process ring. An in-process"
<< "identity is admitted with logos::admitConsumer, which"
<< "mints, registers and installs as one operation --"
<< "TokenManager::adoptCredentialFor is the primitive under"
<< "it and refuses the host anchor.";
return;
}
store->adoptCredential(credential);
}