mirror of
https://github.com/logos-co/logos-view-module-runtime.git
synced 2026-08-31 13:01:08 +00:00
* feat(ui-host): adopt the parent's credential through the shared verb
ui-host is handed its parent's per-spawn credential on stdin and installs
it under both bootstrap keys. That was already the right shape — it is the
one place in the system that seeded a store with its OWN identity's
credential rather than the host's — but it spelled the bootstrap key set
out itself, making it the fifth such site.
It now goes through logos::adoptConsumerCredential, so
TokenManager::bootstrapKeys() owns the set. No behaviour change here: this
image's store IS the process ring, which is exactly the case that verb is
narrowed to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(lock): logos-plugin-qt -> master for logos_consumer.h, protocol to the rev master implements
WHAT MOVED
logos-plugin-qt ef11c210 -> 7cad5ec3 (master, #27 merged)
logos-protocol 79894727 -> b37a2e9f (0.5.0 -> 0.7.0)
WHY plugin-qt HAD TO MOVE
This branch's ui-host calls logos::adoptConsumerCredential and the bridge
takes a logos::ConsumerIdentity, both declared in cpp/logos_consumer.h. That
header did not exist at ef11c210, so every build here died at
LogosQmlBridge.h:17:10: fatal error: logos_consumer.h: No such file or directory
logos-plugin-qt#27 ("logos::admitConsumer -- one home for admitting a
non-module") merged as 7cad5ec3 and ships logos_consumer.h / .cpp. Relocking
that input is the whole fix for the reported red.
WHY protocol MOVED TO 0.7.0 AND NOT TO MASTER
logos-plugin-qt's logos-protocol input `follows` OURS, so this repo -- not
plugin-qt's own lock -- decides which protocol logos-qt-host is compiled
against. The first attempt took protocol to master (42460e5b, 0.8.0). It does
not build, and it does not fail in this repo:
logos-plugin-qt/cpp/logos_consumer.h:68:4: error: #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."
That is a deliberate upper bound in plugin-qt master:
#if defined(LOGOS_PROTOCOL_VERSION_MINOR) \
&& (LOGOS_PROTOCOL_VERSION_MAJOR > 0 \
|| (LOGOS_PROTOCOL_VERSION_MAJOR == 0 && LOGOS_PROTOCOL_VERSION_MINOR > 7))
Every other version guard in the fleet is a `>=` floor; this one is a ceiling,
written precisely so a protocol bump cannot outrun the consumer-admission
contract silently. protocol 42460e5b (0.8.0, the INBOUND/OUTBOUND direction
split) merged at 14:16:33Z and logos-plugin-qt#27 merged at 14:16:41Z, eight
seconds later, on top of 0.7 -- plugin-qt master's own flake.lock pins
logos-protocol b37a2e9f. So plugin-qt master implements the <= 0.7 contract and
no branch anywhere raises the bound yet.
b37a2e9f is therefore not a compromise, it is the rev plugin-qt master itself
locks: this closure is the one plugin-qt's own CI validates, and there is no
protocol split. Verified in the built closure:
logos-view-module-runtime compiles against logos-protocol 0.7.0
logos-qt-host-0.1.0 links logos-protocol-lib-0.7.0
logos-cpp-sdk contributes no protocol path at all
(header-only Qt-free types; its own 0.2.0
logos-protocol lock node never reaches the build)
Nothing in this repo references a 0.8-only symbol (no saveInboundToken, no
adoptCredential/adoptCredentialFor, no LOGOS_PROTOCOL_VERSION guard), so 0.8
buys this branch nothing today. flake.nix is untouched and the lock's
`original` for logos-protocol stays master-tracking -- once plugin-qt raises
the bound, a plain `nix flake lock --update-input logos-protocol` carries this
repo to 0.8 with no edit.
CHECKS -- every target the flake exposes for this builder, built individually
with --print-out-paths, upstream cache only (cache.nix.logos.co is 502), so
these are real from-source builds:
packages.x86_64-linux.default OK
/nix/store/gajjq3050m46sprbiqqz0c32mhi95vb7-logos-view-module-runtime-1.0.0
checks.x86_64-linux.default OK
/nix/store/9a4wvnp6b226s2lam54dgxmzm86r9j58-logos-view-module-runtime-check-1.0.0
packages.x86_64-linux.tests OK (same derivation as the check)
/nix/store/9a4wvnp6b226s2lam54dgxmzm86r9j58-logos-view-module-runtime-check-1.0.0
checks.x86_64-windows.default OK (cross to mingw; compile only, nix
forces doCheck off for a cross build,
so no ctest ran there)
/nix/store/csp4n8hn0xrf2j2lxflv6k8affbn180v-logos-view-module-runtime-check-x86_64-w64-mingw32-1.0.0
packages.x86_64-windows.default OK (bin/ui-host.exe is a real PE32+)
/nix/store/s8y4s43nfl8932hnffg645bzn9dqy5fs-logos-view-module-runtime-x86_64-w64-mingw32-1.0.0
checks.x86_64-linux.default runs the suite in its checkPhase:
Start 1: LogosQmlBridgeUnitTests Start 6: BridgeIdentityTests
Start 2: LogosQmlBridgeE2ETests Start 7: LogosQmlBridgeDeferredTests
Start 3: LogosQmlBridgeGuiTests Start 8: LogosQmlBridgeCallsTests
Start 4: LogosQmlBridgeResultTests Start 9: LogosQmlBridgeReplayTests
Start 5: LogosQmlBridgeHandshakeTests Start 10: UiHostUnloadTests
100% tests passed, 0 tests failed out of 10
Total Test time (real) = 39.36 sec
BridgeIdentityTests is the suite this branch adds, so admitConsumer /
adoptConsumerCredential are exercised, not merely linked.
NOT VERIFIED: the aarch64-* and *-darwin outputs. This builder is x86_64-linux
and there is no darwin remote; CI's macos-latest leg covers them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(lock): protocol 0.8 + plugin-qt master, and un-collapse the fixture's two token stores
WHAT MOVED
logos-protocol b37a2e9f (0.7.0) -> 42460e5b (0.8.0, "separate INBOUND from OUTBOUND")
logos-plugin-qt 7cad5ec3 -> 048152f2 (plugin-qt#26)
Six lines of flake.lock; the lock stays at 14 nodes. logos-plugin-qt's
logos-protocol input `follows` OURS, so this repo decides which protocol
logos-qt-host is compiled against, and both halves have to move together.
THIS SUPERSEDES e370ce5's REASONING, WHICH WAS CORRECT WHEN WRITTEN
e370ce5 took protocol to 0.7 rather than master and gave the reason: plugin-qt
master carried a CEILING, not a floor, in cpp/logos_consumer.h --
#if ... || (LOGOS_PROTOCOL_VERSION_MAJOR == 0 && LOGOS_PROTOCOL_VERSION_MINOR > 7))
#error "logos-protocol is newer than the consumer-admission contract this
file implements ... Review adoptCredentialFor / bootstrapKeys, then
raise this bound."
-- and no branch anywhere had raised it. logos-plugin-qt#26 has since merged as
048152f2 and performed exactly the review that text demands. Read from GitHub at
both revs rather than inferred:
7cad5ec3 cpp/logos_consumer.h:67 ... VERSION_MINOR > 7))
048152f2 cpp/logos_consumer.h:90 ... VERSION_MINOR > 8))
#26 also makes 0.8 mandatory rather than merely permitted: cpp/logos_provider_
object.cpp and cpp/qt_provider_object.cpp call TokenManager::saveInboundToken
unguarded, which does not exist before 0.8. plugin-qt master and protocol 0.8
are now one step, and 0.7 is no longer reachable from this branch.
Verified in the built closure rather than in the lock -- the version literal in
the header that actually reached the compiler, on both targets:
LOGOS_PROTOCOL_VERSION_MAJOR 0 / LOGOS_PROTOCOL_VERSION_MINOR 8
and exactly ONE logos-qt-host and ONE logos-protocol derivation in the .drv
graph of every output (the runtime closure cannot see either -- logos_qt_host is
linked statically -- so this was read from `nix-store -qR` over the DERIVATION,
which is the instrument that finds a split host):
x86_64-linux logos-qt-host-0.1.0.drv x1
logos-protocol{,-lib,-headers}-0.8.0.drv one derivation
x86_64-windows logos-qt-host-x86_64-w64-mingw32-0.1.0.drv x1
logos-protocol-*-x86_64-w64-mingw32-0.8.0.drv one derivation
── THE ONE TEST THAT MOVED, AND WHY IT IS THE FIXTURE AND NOT THE PRODUCT ─────
At 0.8 this branch built clean and ran 10/11, with case 1 red:
FAIL! : TestBridgeIdentity::aHostIdentityBridgeReachesAnUndeclaredBackend
WithNoHandshake() '!payloadIsError(payload)' returned FALSE.
({"error":"Module source unavailable",
"message":"call to 'backend_module' rejected: token not recognized
(re-exchange failed)"})
Case 1 is the deliberately-GREEN ambient-ring escalation control, and its own
comment says that if it goes red the ambient ring changed shape and every
conclusion in the file needs re-deriving. So this had to be decided, not
silenced. Two readings were live:
(A) the fixture seeds the ring with saveToken -- the OUTBOUND door -- and 0.8
authorizes against the INBOUND view, so the fixture stopped modelling
production and the TEST should change;
(B) any host that seeds a peer's token with saveToken loses in-process
authorization at 0.8, and the PRODUCT should change.
It is (A), but NOT for the reason (A) states, and the difference decides what
the replacement asserts. The fixture's ambient seed is still a faithful model of
logos-liblogos/src/logos_core/module_manager.cpp:336. What the fixture got wrong
is that it COLLAPSED TWO STORES INTO ONE: both ModuleProxys took the defaulted
store, so the "module's" credential was whatever the host's ambient ring held --
the capability bootstrap kCapToken -- instead of the module's own root token.
In production those are two objects in two images, holding ONE value:
logos-liblogos/src/logos_core/module_manager.cpp:329-336
the HOST mints authToken, sendToken()s it to the module, and caches it
OUTBOUND under the module's name.
logos-module-loader-qt/src/host/module_initializer.cpp:169-170
the MODULE's image writes that same value under both bootstrapKeys(),
which is what TokenManager::credential() is derived from.
0.8 closed the OUTBOUND-MAP route into a provider. It left the ANCHOR route
open, deliberately, and logos-protocol pins that at master in
tests/protocol/test_token_direction.cpp:221
(TokenDirection.TheOwnCredentialStillAuthorizesAndStillGatesPushes): a module
authorizes anyone presenting ITS OWN credential. Since the ambient ring's entry
for a module IS that module's credential, the escalation case 1 records is STILL
LIVE at 0.8 -- which is why the fix is to give the backend the store a real
module has, and emphatically NOT to make the call fail.
THE REMEDY NOT TAKEN. Routing the seed through informModuleToken /
saveInboundToken -- reading (A) as literally stated -- files a caller GRANT.
Case 1 would then assert "a caller that was granted may call": trivially true,
green forever, and blind to the escalation the file exists to record. That is
worse than red, and it is the trap this fleet keeps hitting.
WHAT CHANGED IN tests/test_bridge_identity.cpp
* backendImageStore() -- the backend module gets its OWN TokenManager
(isolateIdentity + forIdentity + adoptCredential(kBackendRootToken)), the
same three steps logos-protocol's own ModuleImage helper uses, and
backendProxy is constructed with the 3-arg ModuleProxy ctor against it.
* the ambient-ring seed is UNCHANGED -- it was never the thing that was wrong.
* capability_module DELIBERATELY stays on the ambient ring: its credential is
the host's kCapToken, which is what lets logos::admitConsumer's
informModuleToken push clear the trusted-channel gate. Isolating it too
breaks cases 3-8 for reasons unrelated to any of them.
* case 1 now pins the MECHANISM, not only the outcome:
QCOMPARE(TokenManager::instance().getToken("backend_module"),
fx.backendStore->credential()); // one secret, two stores
QVERIFY(fx.backendStore->inbound().keys().isEmpty()); // nothing granted
plus a null/identity check on the store, because a null would have silently
defaulted the proxy back onto the ambient ring and made the whole case
theatre.
── THE CONTROLS. A CONTROL THAT CANNOT FAIL IS NOT A CONTROL ─────────────────
Case 1 asserts that an undeclared backend IS reachable, so "reachable" is its
GREEN state and it cannot be made red by making the backend reachable. What CAN
be shown -- and was, by mutating one thing at a time in the built tree and
re-running -- is that it goes red on every way the ambient ring's shape can
change, and that the tautology substitution is blocked. Five mutations, five
reds, each in a different assertion:
NULL unmutated 11 passed, 0 failed
A ring caches a value no image holds 9/2 -- QCOMPARE (mechanism 1)
(module_manager.cpp:336 changes shape) + case 5's ring assert
B the two stores collapsed back into one 10/1 -- '!payloadIsError'
(literally today's fixture) with the exact 0.8
"token not recognized
(re-exchange failed)"
C target INFORMED of the caller instead 10/1 -- inbound().keys()
(the tautology substitution; the call .isEmpty()
still succeeds, and the test refuses
to call that a pass)
D module image never adopts a credential 10/1 -- QCOMPARE (mechanism 1)
(module_initializer.cpp:169-170 changes)
E case 5's admitted consumer is HANDED the 10/1 -- 'payloadIsError'
ambient entry, so an undeclared backend ("undeclared backend
IS reachable with no handshake was REACHED: hello")
E is the literal "make it reachable, show RED", answered by the assertion that
owns that claim: case 5 is the polarity case 1 is not. The file carries both, so
it cannot pass vacuously in either direction.
── PRODUCT: NOTHING TO CHANGE HERE ───────────────────────────────────────────
This repo has no saveToken call in production source at all. ui-host/main.cpp:221
adopts its OWN per-spawn credential through logos::adoptConsumerCredential -- the
value the parent minted FOR IT, not a peer's token through the outbound door --
which is the correct direction and is what this branch already did. The one
shipped (B) in the fleet is logos-logoscore-cli's daemon
(src/daemon/daemon.cpp:629, saveToken("cli_client", ...)), fixed separately in
logoscore-cli#104.
── CHECKS, EVERY OUTPUT INDIVIDUALLY, --print-out-paths ──────────────────────
packages.x86_64-linux.default 34vfy1yshm3hqk06ngsl3dycfwqlqssj-…-1.0.0
packages.x86_64-linux.tests 38md8bjl93ga42z7d94nxrqjqq8qldx6-…-check-1.0.0
checks.x86_64-linux.default 38md8bjl93ga42z7d94nxrqjqq8qldx6-…-check-1.0.0
packages.x86_64-windows.default kyncplgbrryb40sa8p8dg5037k3qrslq-…-mingw32-1.0.0
packages.x86_64-windows.tests jdpgmgi4w7qaxhxzhl5s0kpixbncj7j6-…-check-mingw32
checks.x86_64-windows.default jdpgmgi4w7qaxhxzhl5s0kpixbncj7j6-…-check-mingw32
checks.x86_64-linux.default runs the suite in its checkPhase:
1/10 LogosQmlBridgeUnitTests 6/10 BridgeIdentityTests Passed
2/10 LogosQmlBridgeResultTests 7/10 LogosQmlBridgeGuiTests
3/10 LogosQmlBridgeE2ETests 8/10 LogosQmlBridgeDeferredTests
4/10 LogosQmlBridgeReplayTests 9/10 UiHostUnloadTests
5/10 LogosQmlBridgeHandshakeTests 10/10 LogosQmlBridgeCallsTests
100% tests passed, 0 tests failed out of 10 (38.96 sec)
BridgeIdentityTests itself: 11 passed, 0 failed, 0 skipped. The cross target
compiles tests/test_bridge_identity.exe but nix forces doCheck off for a cross
build, so no ctest ran there.
NOT BUILT: aarch64-linux and both darwin. This builder is x86_64-linux and there
is no darwin remote; all nine of those outputs EVALUATE (drvPath resolved for
packages.default, packages.tests and checks.default on each), which is the part
that catches the pseudo-system eval traps. CI's macos-latest leg covers the rest.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
604 lines
30 KiB
C++
604 lines
30 KiB
C++
// ── The per-plugin identity acceptance test ─────────────────────────────────
|
|
//
|
|
// This reproduces, and then closes, the measured escalation:
|
|
//
|
|
// A pure-QML module runs with the host's AMBIENT TOKEN RING, not merely the
|
|
// host's name. logos-liblogos' module_manager writes `name -> that module's
|
|
// root auth token` into the process-global TokenManager::instance() for
|
|
// EVERY loaded module. On the hot path a client asserts no identity at all —
|
|
// LogosAPIClient::invokeRemoteMethod reads the store first and only mints on
|
|
// a miss — so a QML view handed the HOST's LogosAPI finds the target's own
|
|
// root token already sitting there, presents it, and the provider — for which
|
|
// that value is its OWN CREDENTIAL, the trust anchor ModuleProxy::authorize
|
|
// accepts by definition — authorises. No `requestModule` appears anywhere, so
|
|
// no policy is ever consulted.
|
|
//
|
|
// The fixture below is that world, built out of real objects: two published
|
|
// providers over real QtRO transports, an ambient ring pre-seeded exactly the
|
|
// way module_manager pre-seeds it, a backend module holding its own credential
|
|
// exactly the way a module image holds it, and a capability_module that mints
|
|
// only for declared (origin, target) pairs.
|
|
//
|
|
// Every one of these tests would pass just as happily if `origin` were fixed
|
|
// and nothing else — which is the trap this work exists to avoid — EXCEPT that
|
|
// the assertions are about the handshake COUNT and the STORE CONTENTS, never
|
|
// about the name a call carries.
|
|
//
|
|
// ── AND THE STORE CONTENTS HAVE TO BE THE IDENTITY'S OWN ─────────────────────
|
|
//
|
|
// The isolated store used to be born holding a COPY of the host's
|
|
// "core"/"capability_module" tokens, and case 3 below asserted exactly that —
|
|
// it PINNED the elevation. A view holding the host's capability token
|
|
// authorizes as the host at every callee (ModuleProxy::authorize answers
|
|
// {"kind":"host"}) and satisfies informModuleToken's trusted-channel gate,
|
|
// which is a write into another module's token map.
|
|
//
|
|
// A private store is now born EMPTY, and the host puts the identity's OWN
|
|
// minted-and-registered credential in it — logos::admitConsumer, one operation
|
|
// where this file's fixture and two applications each hand-rolled three steps.
|
|
//
|
|
// VALIDATED BY RUNNING THIS FILE AGAINST A logos-plugin-qt WITH THE ADOPT STEP
|
|
// REMOVED from admitConsumer — mint, register, drop the credential on the
|
|
// floor, which is precisely what every hand-rolled site did. 4 of 11 FAILED:
|
|
//
|
|
// anAdmittedIdentitysStoreCarriesItsOwnCredentialAndNotTheHosts
|
|
// the store holds no credential at all
|
|
// anIdentityBridgeHandshakesForADeclaredTarget
|
|
// {"error":"Module source unavailable", "call to 'backend_module'
|
|
// rejected: token not recognized (re-exchange failed)"}
|
|
// anIdentityBridgeIsRefusedForAnUndeclaredBackend
|
|
// requestModuleCalls == 0: it never even got to ask, so the refusal
|
|
// under test never happened
|
|
// twoIdentitiesInOneProcessDoNotShareAToken
|
|
// alpha cannot reach its own declared target
|
|
//
|
|
// On master those four are green only because the copied host anchor is doing
|
|
// the work the credential should be doing. That is the bug, stated as a
|
|
// measurement.
|
|
#include "LogosQmlBridge.h"
|
|
|
|
#include "logos_api.h"
|
|
#include "logos_consumer.h"
|
|
#include "logos_instance.h"
|
|
#include "logos_mode.h"
|
|
#include "logos_provider_interface.h"
|
|
#include "module_proxy.h"
|
|
#include "remote_transport.h"
|
|
#include "token_manager.h"
|
|
|
|
#include <QCoreApplication>
|
|
#include <QJsonArray>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QSet>
|
|
#include <QString>
|
|
#include <QTest>
|
|
#include <QVariant>
|
|
#include <QVariantList>
|
|
|
|
namespace {
|
|
|
|
constexpr const char* kBackendRootToken = "backend-root-token-0001";
|
|
constexpr const char* kCapToken = "cap-token-0001";
|
|
|
|
// A perfectly ordinary backend module. It declares no opinion about who may
|
|
// call it — the point being that the refusal, when it comes, is capability
|
|
// policy and not something this object did.
|
|
class BackendProvider : public LogosProviderObject {
|
|
public:
|
|
int calls = 0;
|
|
QVariant callMethod(const QString& method, const QVariantList& args) override {
|
|
if (method == QLatin1String("echo") && !args.isEmpty()) {
|
|
++calls;
|
|
return args.first();
|
|
}
|
|
return QVariant();
|
|
}
|
|
bool informModuleToken(const QString&, const QString&) override { return true; }
|
|
QJsonArray getMethods() override { return QJsonArray{}; }
|
|
void setEventListener(EventCallback) override {}
|
|
void init(void*) override {}
|
|
QString providerName() const override { return QStringLiteral("backend_module"); }
|
|
QString providerVersion() const override { return QStringLiteral("1.0.0"); }
|
|
};
|
|
|
|
// capability_module with the two gates that matter here:
|
|
// * a known-caller gate, standing in for the real one's tokenKeys() lookup;
|
|
// * an access policy — `declared` is the (origin -> targets) set a module
|
|
// actually declared as dependencies.
|
|
// A refusal is an empty mint, exactly as the real module returns {}.
|
|
class CapabilityProvider : public LogosProviderObject {
|
|
public:
|
|
ModuleProxy* backendProxy = nullptr;
|
|
QSet<QString> knownCallers; // origins the host has registered
|
|
QHash<QString, QString> callerTokens; // origin -> the credential it presents
|
|
QHash<QString, QSet<QString>> declared; // origin -> declared targets
|
|
int requestModuleCalls = 0;
|
|
int informCalls = 0;
|
|
QStringList refusedOrigins;
|
|
|
|
QVariant callMethod(const QString& method, const QVariantList& args) override {
|
|
if (method == QLatin1String("requestModule") && args.size() >= 2) {
|
|
++requestModuleCalls;
|
|
const QString origin = args[0].toString();
|
|
const QString target = args[1].toString();
|
|
if (!knownCallers.contains(origin)) {
|
|
refusedOrigins << origin;
|
|
return QString(); // unknown caller — fail closed
|
|
}
|
|
if (!declared.value(origin).contains(target)) {
|
|
refusedOrigins << origin;
|
|
return QString(); // undeclared target — policy denies
|
|
}
|
|
const QString mint = QStringLiteral("minted-for-%1").arg(origin);
|
|
if (backendProxy) backendProxy->saveToken(origin, mint);
|
|
return mint;
|
|
}
|
|
return QVariant();
|
|
}
|
|
// A CALLER BECOMES KNOWN BY BEING REGISTERED, which is what the real
|
|
// capability_module does: informModuleToken is the trust root learning
|
|
// (name, token). The fixture used to have the test poke knownCallers
|
|
// directly, which made every case here silently independent of whether the
|
|
// host ever registered anything — the exact defect the pure-QML path had.
|
|
bool informModuleToken(const QString& moduleName, const QString& token) override {
|
|
++informCalls;
|
|
knownCallers.insert(moduleName);
|
|
callerTokens[moduleName] = token;
|
|
return true;
|
|
}
|
|
QJsonArray getMethods() override { return QJsonArray{}; }
|
|
void setEventListener(EventCallback) override {}
|
|
void init(void*) override {}
|
|
QString providerName() const override { return QStringLiteral("capability_module"); }
|
|
QString providerVersion() const override { return QStringLiteral("1.0.0"); }
|
|
};
|
|
|
|
// ── THE BACKEND MODULE'S OWN IMAGE, AND WHY IT IS A SECOND STORE ───────────
|
|
//
|
|
// A loaded module is a different IMAGE with a different TokenManager from the
|
|
// host's ambient ring, and its store carries the module's OWN host-issued
|
|
// credential. ONE value, TWO stores, written by two lines in two repos:
|
|
//
|
|
// logos-liblogos/src/logos_core/module_manager.cpp:329-336
|
|
// the HOST mints authToken, sendToken()s it to the module, and caches it
|
|
// OUTBOUND under the module's name — the ambient-ring seed below.
|
|
// logos-module-loader-qt/src/host/module_initializer.cpp:169-170
|
|
// the MODULE's image writes that same value under both bootstrapKeys(),
|
|
// which is what TokenManager::credential() is derived from.
|
|
//
|
|
// This fixture used to COLLAPSE those two stores into one: both ModuleProxys
|
|
// took the defaulted store, so the "module's" credential was whatever the
|
|
// ambient ring happened to hold — the capability bootstrap, kCapToken. At
|
|
// protocol 0.7 the collapse was invisible, because ModuleProxy::authorize
|
|
// walked the flat token map and the host's own OUTBOUND entry authorized
|
|
// directly. Protocol 0.8 split that map by direction: the scan is handed an
|
|
// InboundView plus the store's credential and can no longer name the outbound
|
|
// half at all. The collapsed fixture then presented kBackendRootToken to a
|
|
// store whose credential was kCapToken, and case 1 went red with
|
|
// {"error":"Module source unavailable",
|
|
// "message":"call to 'backend_module' rejected: token not recognized
|
|
// (re-exchange failed)"}
|
|
//
|
|
// THAT WAS THE FIXTURE, NOT THE PRODUCT, and the difference is measurable
|
|
// rather than argued. logos-protocol pins the surviving property at 0.8 in
|
|
// tests/protocol/test_token_direction.cpp
|
|
// (TheOwnCredentialStillAuthorizesAndStillGatesPushes): a module still
|
|
// authorizes anyone presenting ITS OWN credential — which is exactly the value
|
|
// liblogos caches in the ambient ring for every module it loads. 0.8 closed the
|
|
// OUTBOUND-MAP route into a provider; it left the ANCHOR route open by design.
|
|
// So case 1 stays green, for the reason it always documented, once the backend
|
|
// is given the store a real module has.
|
|
//
|
|
// THE REMEDY THAT WAS NOT TAKEN: routing the ambient seed through
|
|
// informModuleToken / saveInboundToken. That files a caller GRANT, and case 1
|
|
// would then assert "a caller that was granted may call" — trivially true, and
|
|
// blind to the escalation this file exists to record. The inbound-is-empty
|
|
// assertion in case 1 is there so that substitution cannot be made quietly.
|
|
TokenManager* backendImageStore()
|
|
{
|
|
// isolateIdentity is process-global and one-shot while HostFixture is
|
|
// rebuilt per case, so isolate once and re-adopt every time rather than
|
|
// inheriting whatever the previous case left in the store.
|
|
static TokenManager* const store = []() -> TokenManager* {
|
|
if (!TokenManager::isolateIdentity(QStringLiteral("backend_module")))
|
|
return nullptr;
|
|
TokenManager& s = TokenManager::forIdentity(QStringLiteral("backend_module"));
|
|
// Never hand back the ambient ring under the guise of a module store:
|
|
// that is the collapse this function exists to undo.
|
|
return (&s == &TokenManager::instance()) ? nullptr : &s;
|
|
}();
|
|
if (store)
|
|
store->adoptCredential(QString::fromLatin1(kBackendRootToken));
|
|
return store;
|
|
}
|
|
|
|
// The host process: two live providers plus the ambient ring a real host has.
|
|
struct HostFixture {
|
|
RemoteTransportHost backendHost;
|
|
BackendProvider backend;
|
|
// The backend module's OWN store — see backendImageStore(). Asserted
|
|
// non-null and distinct from the ambient ring in case 1, because a null
|
|
// here would silently default backendProxy back onto instance() and
|
|
// re-create the collapse.
|
|
TokenManager* backendStore;
|
|
ModuleProxy backendProxy;
|
|
|
|
RemoteTransportHost capHost;
|
|
CapabilityProvider cap;
|
|
ModuleProxy capProxy;
|
|
|
|
HostFixture()
|
|
: backendHost(LogosInstance::id("backend_module"))
|
|
, backendStore(backendImageStore())
|
|
, backendProxy(&backend, nullptr, backendStore)
|
|
// capability_module DELIBERATELY stays on the ambient ring: its
|
|
// credential is the host's kCapToken, which is what lets
|
|
// logos::admitConsumer's informModuleToken push clear
|
|
// ModuleProxy::informModuleToken's trusted-channel gate. Isolating it
|
|
// too would break cases 3 through 8 for reasons unrelated to any of
|
|
// them.
|
|
, capHost(LogosInstance::id("capability_module"))
|
|
, capProxy(&cap)
|
|
, hostApiObject(nextHostName())
|
|
{
|
|
LogosModeConfig::setMode(LogosMode::Remote);
|
|
cap.backendProxy = &backendProxy;
|
|
|
|
backendHost.publishObject(QStringLiteral("backend_module"), &backendProxy);
|
|
capHost.publishObject(QStringLiteral("capability_module"), &capProxy);
|
|
|
|
// THE AMBIENT RING. This is the line the whole task is about:
|
|
// module_manager.cpp does exactly this for every module it loads, and
|
|
// it is what a host-identity caller finds when it looks up a target.
|
|
TokenManager::instance().saveToken(QStringLiteral("backend_module"),
|
|
QString::fromLatin1(kBackendRootToken));
|
|
// The bootstrap token every host pre-seeds before any module loads.
|
|
TokenManager::instance().saveToken(QStringLiteral("capability_module"),
|
|
QString::fromLatin1(kCapToken));
|
|
}
|
|
|
|
// The POLICY half only: which targets this origin declared as dependencies.
|
|
// Becoming a KNOWN caller is no longer something a test can arrange behind
|
|
// the host's back — that happens when logos::admitConsumer registers the
|
|
// identity's credential, which is how it happens in production.
|
|
void declare(const QString& identity, const QStringList& declaredTargets)
|
|
{
|
|
cap.declared[identity] = QSet<QString>(declaredTargets.constBegin(),
|
|
declaredTargets.constEnd());
|
|
}
|
|
|
|
// The HOST's LogosAPI: the trusted channel logos::admitConsumer registers
|
|
// over. Its store is the ambient ring, which is where the fixture put the
|
|
// capability_module bootstrap token, so it IS the trusted channel exactly as
|
|
// basecamp's "core" LogosAPI is.
|
|
//
|
|
// ONE PER FIXTURE, UNDER A NAME NO OTHER FIXTURE USES, and both halves are
|
|
// load-bearing. Per fixture because a LogosAPI caches its LogosAPIClient
|
|
// per target, and this fixture tears down and rebuilds capability_module's
|
|
// QtRO host between tests — a client that outlives its provider holds a
|
|
// replica pointing at a dead endpoint, and the push then fails for reasons
|
|
// that have nothing to do with what the test is asserting (measured: every
|
|
// OTHER admission failed). Under a unique name because a provider binds a
|
|
// registry URL derived from its module name, and rebinding the same one
|
|
// while the previous host is still closing is its own flake.
|
|
LogosAPI hostApiObject;
|
|
LogosAPI* hostApi() { return &hostApiObject; }
|
|
|
|
static QString nextHostName()
|
|
{
|
|
static int n = 0;
|
|
return QStringLiteral("host_admitter_%1").arg(++n);
|
|
}
|
|
|
|
// Admit a consumer and give it a bridge — the whole of what a host does.
|
|
// Returns nullptr if either half failed, which is what a caller must treat
|
|
// as fatal for the view.
|
|
LogosQmlBridge* admitBridge(const QString& identity)
|
|
{
|
|
logos::ConsumerIdentity consumer = logos::admitConsumer(identity, hostApi());
|
|
lastCredential = consumer.credential;
|
|
return LogosQmlBridge::forConsumer(consumer);
|
|
}
|
|
|
|
QString lastCredential;
|
|
|
|
void pump(int rounds = 40)
|
|
{
|
|
for (int i = 0; i < rounds; ++i)
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 5);
|
|
}
|
|
};
|
|
|
|
bool payloadIsError(const QString& json)
|
|
{
|
|
return QJsonDocument::fromJson(json.toUtf8()).object().contains(QStringLiteral("error"));
|
|
}
|
|
|
|
} // namespace
|
|
|
|
class TestBridgeIdentity : public QObject {
|
|
Q_OBJECT
|
|
private slots:
|
|
|
|
// ── 1. The escalation, stated as a fact about the OLD wiring ────────────
|
|
//
|
|
// A bridge built on the host's LogosAPI reaches a backend it never
|
|
// declared, and capability_module is not consulted even once. Kept green
|
|
// deliberately: it is not the bug being fixed, it is the REASON the bridge
|
|
// must not be given the host's LogosAPI. If this ever goes red the ambient
|
|
// ring changed shape and every conclusion below needs re-deriving.
|
|
//
|
|
// STILL TRUE AT PROTOCOL 0.8, and that is the whole point of the direction
|
|
// split's blast radius being smaller than it looks. 0.8 stopped an OUTBOUND
|
|
// cache entry from authorizing an INBOUND call; it did not stop a store
|
|
// from authorizing its OWN credential, and the ambient ring's entry for a
|
|
// module IS that module's credential. The two assertions below say so
|
|
// MECHANICALLY, and they are here because outcome-only assertions let this
|
|
// case pass for the wrong reason: seed the ring with a value nobody holds
|
|
// and the call fails; seed the target with a caller GRANT instead and the
|
|
// call succeeds while asserting nothing at all.
|
|
void aHostIdentityBridgeReachesAnUndeclaredBackendWithNoHandshake()
|
|
{
|
|
HostFixture fx;
|
|
// Note what is NOT here: no registerIdentity, no declared targets.
|
|
LogosAPI hostApi(QStringLiteral("basecamp_host"));
|
|
LogosQmlBridge bridge(&hostApi);
|
|
|
|
// The backend really is a separate image, or the rest is theatre: a
|
|
// null store would have defaulted the proxy back onto the ambient ring
|
|
// and made the host's own entry trivially reachable.
|
|
QVERIFY(fx.backendStore != nullptr);
|
|
QVERIFY(fx.backendStore != &TokenManager::instance());
|
|
|
|
// MECHANISM 1. The value the host ring caches for a module IS that
|
|
// module's own credential — one secret in two stores, written by
|
|
// module_manager.cpp:336 and module_initializer.cpp:169-170. This is
|
|
// WHY the call below needs no handshake.
|
|
QCOMPARE(TokenManager::instance().getToken(QStringLiteral("backend_module")),
|
|
fx.backendStore->credential());
|
|
// MECHANISM 2. Nothing was ever GRANTED to anybody. The reachability
|
|
// below is the anchor arm of ModuleProxy::authorize, not an inbound
|
|
// record — so this case cannot be "fixed" by informing the target of a
|
|
// caller, which would make it a tautology.
|
|
QVERIFY(fx.backendStore->inbound().keys().isEmpty());
|
|
|
|
const QString payload = bridge.callModule(
|
|
QStringLiteral("backend_module"), QStringLiteral("echo"),
|
|
QVariantList() << QStringLiteral("hello"));
|
|
|
|
QVERIFY2(!payloadIsError(payload), qPrintable(payload));
|
|
QCOMPARE(fx.backend.calls, 1);
|
|
QCOMPARE(fx.cap.requestModuleCalls, 0); // ← no handshake at all
|
|
// Still no grant AFTER the call either: the successful call did not
|
|
// create the record that would explain it.
|
|
QVERIFY(fx.backendStore->inbound().keys().isEmpty());
|
|
}
|
|
|
|
// ── 2. The isolated store does not hold the target's root token ─────────
|
|
//
|
|
// The escalation in one line, and the sharpest single reading in this file.
|
|
void anIsolatedIdentitysStoreLacksTheTargetsRootToken()
|
|
{
|
|
HostFixture fx;
|
|
LogosQmlBridge* bridge = fx.admitBridge(QStringLiteral("view_store"));
|
|
QVERIFY(bridge != nullptr);
|
|
QCOMPARE(bridge->identity(), QStringLiteral("view_store"));
|
|
|
|
QVERIFY(TokenManager::isIsolated(QStringLiteral("view_store")));
|
|
// Assert on the store the BRIDGE actually presents from, not on the
|
|
// registry: a bridge can be labelled with an isolated identity and
|
|
// still hold &TokenManager::instance(), which is precisely the
|
|
// "looks fixed, isn't" shape.
|
|
QVERIFY(bridge->tokenStore() != nullptr);
|
|
QVERIFY(bridge->tokenStore() != &TokenManager::instance());
|
|
// The host still has it — this is not "the ring was cleared".
|
|
QVERIFY(TokenManager::instance().hasToken(QStringLiteral("backend_module")));
|
|
// The identity does not.
|
|
QVERIFY(!bridge->tokenStore()->hasToken(QStringLiteral("backend_module")));
|
|
delete bridge;
|
|
}
|
|
|
|
// ── 3. …but it does carry a credential, or it could never ask ──────────
|
|
//
|
|
// AND THE CREDENTIAL IS ITS OWN, not the host's. This assertion is the
|
|
// whole of task 1 as seen from the view side. It used to read
|
|
//
|
|
// QCOMPARE(store->getToken("capability_module"), kCapToken);
|
|
//
|
|
// — i.e. it PINNED the elevation: the isolated view holding the host's own
|
|
// capability token, which authorizes as the host at every callee and
|
|
// satisfies ModuleProxy::informModuleToken's trusted-channel gate. A
|
|
// private store is now born empty and carries only what the host minted
|
|
// FOR THIS IDENTITY and registered before handing over.
|
|
void anAdmittedIdentitysStoreCarriesItsOwnCredentialAndNotTheHosts()
|
|
{
|
|
HostFixture fx;
|
|
LogosQmlBridge* bridge = fx.admitBridge(QStringLiteral("view_bootstrap"));
|
|
QVERIFY(bridge != nullptr);
|
|
|
|
TokenManager* store = bridge->tokenStore();
|
|
QVERIFY(store != nullptr);
|
|
QVERIFY(store != &TokenManager::instance());
|
|
|
|
QVERIFY(!fx.lastCredential.isEmpty());
|
|
QCOMPARE(store->getToken(QStringLiteral("capability_module")), fx.lastCredential);
|
|
QCOMPARE(store->getToken(QStringLiteral("core")), fx.lastCredential);
|
|
QVERIFY(store->getToken(QStringLiteral("capability_module"))
|
|
!= QString::fromLatin1(kCapToken));
|
|
// The host still holds its own, so this is not "the ring was cleared".
|
|
QCOMPARE(TokenManager::instance().getToken(QStringLiteral("capability_module")),
|
|
QString::fromLatin1(kCapToken));
|
|
// No isolated identity anywhere in this process holds a value of the
|
|
// host's — the diagnostic a host's CI asserts on.
|
|
QVERIFY(TokenManager::identitiesSharingHostAnchor().isEmpty());
|
|
|
|
// Credential ONLY: backend_module — which the ambient ring does hold —
|
|
// is absent, and nothing else was installed.
|
|
QCOMPARE(store->tokenCount(), TokenManager::bootstrapKeys().size());
|
|
|
|
// And capability_module was told about it, with the value the view is
|
|
// actually presenting. Registered UNDER THIS NAME, not under the host's.
|
|
QVERIFY(fx.cap.knownCallers.contains(QStringLiteral("view_bootstrap")));
|
|
QCOMPARE(fx.cap.callerTokens.value(QStringLiteral("view_bootstrap")),
|
|
fx.lastCredential);
|
|
delete bridge;
|
|
}
|
|
|
|
// ── 3b. HALF an identity is inert, not powerful ────────────────────────
|
|
//
|
|
// LogosAPI::forIdentity on its own — an isolated store and nothing else —
|
|
// is what a host doing only the first hand-rolled step produces. It must be
|
|
// unable to reach anything, rather than reaching everything on the host's
|
|
// inherited anchor. This is the case that was RED before the store stopped
|
|
// being seeded from instance().
|
|
void anUnadmittedIdentityCanReachNothing()
|
|
{
|
|
HostFixture fx;
|
|
fx.declare(QStringLiteral("view_unadmitted"), {QStringLiteral("backend_module")});
|
|
|
|
LogosAPI* api = LogosAPI::forIdentity(QStringLiteral("view_unadmitted"));
|
|
QVERIFY(api != nullptr);
|
|
LogosQmlBridge bridge(api);
|
|
|
|
QVERIFY(api->getTokenManager() != &TokenManager::instance());
|
|
QVERIFY(api->getTokenManager()->getToken(QStringLiteral("capability_module")).isEmpty());
|
|
|
|
const QString payload = bridge.callModule(
|
|
QStringLiteral("backend_module"), QStringLiteral("echo"),
|
|
QVariantList() << QStringLiteral("hello"));
|
|
|
|
QVERIFY2(payloadIsError(payload),
|
|
qPrintable(QStringLiteral("an unadmitted identity REACHED a backend: ")
|
|
+ payload));
|
|
QCOMPARE(fx.backend.calls, 0);
|
|
// It never became a known caller either, because nobody registered it.
|
|
QVERIFY(!fx.cap.knownCallers.contains(QStringLiteral("view_unadmitted")));
|
|
delete api;
|
|
}
|
|
|
|
// ── 4. A declared target now costs a real handshake ────────────────────
|
|
//
|
|
// Same call, same providers, same process. The only thing that changed is
|
|
// which store the bridge presents tokens from, and now capability_module
|
|
// is in the path.
|
|
void anIdentityBridgeHandshakesForADeclaredTarget()
|
|
{
|
|
HostFixture fx;
|
|
fx.declare(QStringLiteral("view_declared"), {QStringLiteral("backend_module")});
|
|
|
|
LogosQmlBridge* bridge = fx.admitBridge(QStringLiteral("view_declared"));
|
|
QVERIFY(bridge != nullptr);
|
|
|
|
const QString payload = bridge->callModule(
|
|
QStringLiteral("backend_module"), QStringLiteral("echo"),
|
|
QVariantList() << QStringLiteral("hello"));
|
|
|
|
QVERIFY2(!payloadIsError(payload), qPrintable(payload));
|
|
QCOMPARE(fx.backend.calls, 1);
|
|
QCOMPARE(fx.cap.requestModuleCalls, 1); // ← the handshake happened
|
|
// And the token it presented is the MINTED one, not the root token.
|
|
QCOMPARE(bridge->tokenStore()->getToken(QStringLiteral("backend_module")),
|
|
QStringLiteral("minted-for-view_declared"));
|
|
delete bridge;
|
|
}
|
|
|
|
// ── 5. THE ACCEPTANCE TEST ─────────────────────────────────────────────
|
|
//
|
|
// An UNDECLARED backend is refused. Test 1 is the same call from the same
|
|
// process against the same providers and it succeeded; the difference is
|
|
// entirely the store.
|
|
void anIdentityBridgeIsRefusedForAnUndeclaredBackend()
|
|
{
|
|
HostFixture fx;
|
|
// Declared NOTHING, but admitted — so this is a POLICY refusal, not
|
|
// "who are you". Admission is what makes it a known caller.
|
|
fx.declare(QStringLiteral("view_undeclared"), {});
|
|
|
|
LogosQmlBridge* bridge = fx.admitBridge(QStringLiteral("view_undeclared"));
|
|
QVERIFY(bridge != nullptr);
|
|
QVERIFY(fx.cap.knownCallers.contains(QStringLiteral("view_undeclared")));
|
|
|
|
const QString payload = bridge->callModule(
|
|
QStringLiteral("backend_module"), QStringLiteral("echo"),
|
|
QVariantList() << QStringLiteral("hello"));
|
|
|
|
QVERIFY2(payloadIsError(payload),
|
|
qPrintable(QStringLiteral("undeclared backend was REACHED: ") + payload));
|
|
QCOMPARE(fx.backend.calls, 0); // the method never ran
|
|
QVERIFY(fx.cap.requestModuleCalls >= 1); // it had to ask, and was told no
|
|
QVERIFY(fx.cap.refusedOrigins.contains(QStringLiteral("view_undeclared")));
|
|
// The ambient ring is untouched: the refusal is about WHO ASKED, not
|
|
// about the token having gone missing globally.
|
|
QCOMPARE(TokenManager::instance().getToken(QStringLiteral("backend_module")),
|
|
QString::fromLatin1(kBackendRootToken));
|
|
delete bridge;
|
|
}
|
|
|
|
// ── 6. Two identities in one process do not share authority ────────────
|
|
void twoIdentitiesInOneProcessDoNotShareAToken()
|
|
{
|
|
HostFixture fx;
|
|
fx.declare(QStringLiteral("view_alpha"), {QStringLiteral("backend_module")});
|
|
fx.declare(QStringLiteral("view_beta"), {});
|
|
|
|
LogosQmlBridge* alpha = fx.admitBridge(QStringLiteral("view_alpha"));
|
|
const QString alphaCredential = fx.lastCredential;
|
|
LogosQmlBridge* beta = fx.admitBridge(QStringLiteral("view_beta"));
|
|
const QString betaCredential = fx.lastCredential;
|
|
QVERIFY(alpha && beta);
|
|
// Two admissions, two DIFFERENT credentials. One shared secret would
|
|
// make them the same caller at every provider they reach.
|
|
QVERIFY(!alphaCredential.isEmpty());
|
|
QVERIFY(alphaCredential != betaCredential);
|
|
|
|
QVERIFY(!payloadIsError(alpha->callModule(
|
|
QStringLiteral("backend_module"), QStringLiteral("echo"),
|
|
QVariantList() << QStringLiteral("a"))));
|
|
|
|
// alpha now holds a working token for backend_module. beta must not
|
|
// see it — under the old wiring both read the same object.
|
|
QVERIFY(alpha->tokenStore()->hasToken(QStringLiteral("backend_module")));
|
|
QVERIFY(alpha->tokenStore() != beta->tokenStore());
|
|
QVERIFY(!beta->tokenStore()->hasToken(QStringLiteral("backend_module")));
|
|
|
|
QVERIFY2(payloadIsError(beta->callModule(
|
|
QStringLiteral("backend_module"), QStringLiteral("echo"),
|
|
QVariantList() << QStringLiteral("b"))),
|
|
"beta rode alpha's token");
|
|
delete alpha;
|
|
delete beta;
|
|
}
|
|
|
|
// ── 7. A half-isolated identity is refused outright ────────────────────
|
|
//
|
|
// Once a client for a name has captured the shared store by raw pointer,
|
|
// isolating that name would leave one client on the ambient ring and one on
|
|
// the private store. forIdentity must return nullptr so the caller fails
|
|
// the load rather than shipping a plugin that only looks contained.
|
|
void admitConsumerRefusesANameAlreadyOnTheSharedStore()
|
|
{
|
|
HostFixture fx;
|
|
// A plain LogosAPI vends the shared store under this name.
|
|
LogosAPI ambient(QStringLiteral("view_too_late"));
|
|
QCOMPARE(ambient.getTokenManager(), &TokenManager::instance());
|
|
|
|
QCOMPARE(fx.admitBridge(QStringLiteral("view_too_late")),
|
|
static_cast<LogosQmlBridge*>(nullptr));
|
|
QVERIFY(!TokenManager::isIsolated(QStringLiteral("view_too_late")));
|
|
// Nothing was registered either: a refusal that still told the trust
|
|
// root about a credential would leave a phantom caller behind.
|
|
QVERIFY(!fx.cap.knownCallers.contains(QStringLiteral("view_too_late")));
|
|
}
|
|
|
|
// ── 8. Nothing changed for a caller that never opts in ─────────────────
|
|
void anUnisolatedIdentityStillGetsTheImageStore()
|
|
{
|
|
LogosAPI plain(QStringLiteral("view_never_isolated"));
|
|
QCOMPARE(plain.getTokenManager(), &TokenManager::instance());
|
|
}
|
|
};
|
|
|
|
QTEST_MAIN(TestBridgeIdentity)
|
|
#include "test_bridge_identity.moc"
|