6 Commits
Author SHA1 Message Date
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
Dario Gabriel LipicarandClaude Opus 5 6c24fcb132 feat(caller): resolve who is calling, and declare the export that carries it
A module can now learn which module is calling it. Not via the LIDL — this
is not part of any module's interface, and the callee already has the
identity from the token the call carried; the only question was surfacing
it. So it is ambient: logos::currentCaller(), no declared parameter, no
contract change, no per-method opt-in.

WHAT THIS PR CONTAINS
  * LogosCaller — Unknown | HostAnchor | Module{name, instance?} |
    Derived{parent, leaf} | Operator{name} — std-typed and Qt-free.
  * CallerScope, an RAII save/restore around a thread-local STACK. Not a
    slot: A calling B calling back into A on one thread must nest, and an
    exception thrown from a handler must still pop.
  * resolveCaller, replacing the bool fold in ModuleProxy. It reads the
    INBOUND store #69 made direction-pure — the only store that may
    legitimately name a caller.
  * logos_module_set_call_caller DECLARED, and MINOR 5 -> 6.

WHY AMBIENT, AND WHY IT MUST CROSS AN IMAGE BOUNDARY
LogosProviderObject::callMethod is a vtable slot, and this codebase avoids
vtable changes on purpose. But the deeper reason is measured, not stylistic:
nm on real binaries shows the host and the module plugin EACH define
ModuleProxy::callRemoteMethod and TokenManager::instance, each with its own
function-local static at a distinct address, and neither with a single
undefined reference to the other's. Mach-O is TWOLEVEL; PE has no
interposition. A thread_local opened host-side is NOT the one a handler
reads. Since --backend qt is now refused outright, every module is a cdylib
and the C ABI push is the only path, not a fallback.

The pull is only safe through QMetaObject::invokeMethod on the host's
LogosAPI, because metaObject()/qt_metacall are virtual and the vptr was
written by the host's constructor — LogosAPI is duplicated across images
too, meta-object included, so a direct call would bind to the plugin's copy
and read the plugin's TLS, silently empty forever. A dynamic property
cannot carry it either: one process-global slot, so two overlapping
concurrency:"multi" calls from different callers would clobber each other.

Nothing here is spelled "verified". capability_module checks only that an
asserted name EXISTS as a key, so the strongest honest word is token-bound.
HostAnchor carries no name because core and capability_module hold one
token VALUE under two keys by construction. Unknown is the fail-closed
value and is always in-band, never spelled by absence.

The constant-time fold survives: the matched key is accumulated into a
fixed-width buffer with no data-dependent branch, verified at the
instruction level (csel, not a branch) with the comparison count invariant.

THE BUMP IS SAFE BECAUSE THE BACKENDS WENT FIRST
logos-protocol only DECLARES this ABI; every backend owes the definition,
and that gap shipped twice. logos-cpp-sdk#147 and logos-rust-sdk#47 already
define logos_module_set_call_caller, gated on >= 0.6 and therefore inert
until this lands. Verified on x86_64-linux: with this tree as the protocol,
BOTH backends at master pass their ABI checks and define the export;
manifest reports 0.6.0 with 11 exports. No repo is red at any point.

Rule 6 is now normative on a point the two backends had silently diverged
on — a present-but-unreadable "instance" is dropped and the module still
identified — each having pinned its own answer with a passing test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:28:00 -03:00
Dario Gabriel LipicarandClaude Opus 5 d872024847 fix(proxy): authorize against a store that can only hold INBOUND tokens
TokenManager is one flat QHash<QString,QString> with no direction tag, and
both directions write into it under a bare module name: the client saves
the OUTBOUND token under the CALLEE's name, the provider saves the INBOUND
token under the CALLER's name. Last write wins. So a reverse lookup there
can name a module we CALL as the module CALLING us — affirmatively wrong,
and worse than answering "unknown".

The per-identity work did not close this. `forIdentity` splits by CALLING
IDENTITY, a different axis, and it returns `&instance()` for every identity
until `isolateIdentity` runs — whose only production caller is
LogosQmlBridge, on the consumer side. For providers the mechanism is inert.

What has kept it from being an auth hole is TOPOLOGY, by accident rather
than design: with a module as a cdylib in its own host process, the glue
splits the directions across two IMAGES — informModuleToken reaches the
host's store, logos_module_accept_token the cdylib's. Any single-image
configuration puts them back in one map: the in-process plugin host that
Basecamp already uses, local/mobile mode, the shared-runtime migration, and
this repo's own test suite.

So ModuleProxy now takes an optional token store and authorizes against
THAT, defaulting to &instance() — every existing two-argument construction
is byte-identical. Its own m_tokens becomes the inbound record and is
documented as the only store that may name a caller, which is the property
caller-identity recovery will need.

Two things found on the way, both worth their own attention:

* m_tokens had ZERO production writers. Its only feeder is
  LogosAPIProvider::saveToken, which nothing in 43 repos calls, so the
  store was permanently empty in production.
* The isAuthorized/getTokenManager split breaks BOTH ways under isolation:
  privately seeded tokens are invisible AND every ambient token is still
  accepted, re-opening the escalation isolation exists to close. The glue
  comment asserts the opposite.

A test changed the design. The first draft recorded the token BEFORE
forwarding to the provider, justified by a re-entrancy window. That test
went red, and lp_module_accept_token turned out to reach module code only
as far as a store write — it calls nothing back. No window, so the record
moved after the provider's verdict.

Proven red-then-green in three builds: with neither mechanism 3 of 7 fail;
with the record but the scan still on instance() exactly one survives, and
that survivor is what makes it a detector for the SCAN rather than the
record; with the anchor read reverted, the anchor test alone fails. Three
tests are pins that hold on both sides and are labelled as such.

The constant-time fold is preserved: only the receiver object changed.

483/483 protocol tests, the module-impl ABI check, and the mingw cross all
pass; downstream logos-qt-sdk is 239/239 with this tree overridden in
(confirmed reaching by the differing store path).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:50:15 -03:00
Dario LipicarandClaude Opus 5 c1b0a0f554 fix(startup): publish a token-only handshake surface before a module initializes (#42)
* fix(startup): publish a token-only handshake surface before a module initializes

A module's initializer is synchronous and routinely calls out — a Qt module's
initLogos, a cdylib's context-ready hook — including capability_module's
requestModule, which capability answers by pushing a token back to that same
module. The module's business object is published only once the initializer
returns, so that push had nothing to reach: capability waited for a source that
could not appear until the initializer returned, and the initializer could not
return until capability answered. On Linux this wedged UI startup until the
standalone app's 10s ui-host deadline expired and the view never rendered.

Adds a second, deliberately tiny surface — ModuleHandshakeProxy, published
under logos::handshakeObjectName(name) — carrying informModuleToken and nothing
else. It forwards to the ModuleProxy that owns the token store, so a grant
delivered early is the one the business object honours later, with the same
authorization.

The business object's publish timing is UNCHANGED, which is the point: a caller
of a real method still blocks at acquire until the module is genuinely ready,
exactly as it always has. An earlier attempt published the business object early
and refused calls during init; that quietly turned a call that used to wait and
succeed into one that returned empty, which old consumers cannot even detect.

informModuleToken_module now tries the handshake surface first (short probe) and
falls back to the business object, so modules built before this surface existed
are reached exactly as they are today. It also reuses the cached handle instead
of acquiring a fresh replica per grant, and takes a timeout (default unchanged).

No wire change, no ABI change, no reply-shape change.

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

* fix(startup): do not treat a handshake refusal as the final answer

The handshake surface is published before the target's initializer runs, so a
target whose token store is seeded BY that initializer refuses a push that
arrives first. Returning that refusal to the caller handed it an empty grant it
could not distinguish from a real denial: measured on Linux, the first
requestModule for wallet_backend_module came back empty in 29 of 34 runs, and
never once in the pre-surface baseline.

Fall through to the business object instead, which is what the caller got before
this surface existed. The business object is published only once the initializer
has returned, by which point the store is populated. The wait is bounded by the
caller's own budget -- capability_module passes 3000 ms, not the 20 s default
that made the original deadlock fatal -- so this cannot reintroduce the wedge.

The companion change in logos-qt-sdk seeds the trust anchor before publishing,
which removes the refusal at its source; this is the safety net for hosts and
modules that do not.

Also adds the regression test that would have caught this: the existing case
seeds "core" before pushing, which is exactly the state that does NOT hold in
the window the surface covers, so it asserted the surface works under a
precondition production never met.

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

* fix(startup): marshal the token push, and stop re-probing a missing handshake

Two review findings from Copilot, both verified against the code before acting.

1. Thread affinity. informModuleToken_module was one of only two entry points in
   LogosAPIClient that did not wrap in logos::runOnOwnerThread -- requestObject,
   both invokeRemoteMethod forms and onEvent all do. The missing marshal is
   inherited, but THIS change is what made it reachable: the method used to take
   an uncached requestObject() + release() and touch no shared state, and routing
   it through acquireCachedObject put it on m_objectCache, which is declared
   single-threaded and holds thread-affine QtRO handles. Now marshalled, matching
   its four siblings.

   The 3-arg informModuleToken has the same gap but still uses an uncached handle
   and predates this work, so it is deliberately left alone rather than widened
   into this fix; noted at the call site.

2. No negative cache on the handshake probe. acquireCachedObject caches successes
   only, so a module built before the handshake surface existed failed the probe
   on EVERY grant -- and on QtRO that failure is a blocking waitForSource, i.e.
   250 ms of dead time per token, forever. Remember the absence and go straight to
   the business object; cleared by clearObjectCache() so a reconnect, or a module
   reloaded from a build that has the surface, is re-probed rather than written
   off permanently.

   (The review attributed this cost to the Local/Plain adapters rejecting a
   non-ModuleProxy object. Checked per transport: plain is unaffected -- its token
   push is nameless fire-and-forget and it never had the acquire deadlock -- and
   on qt_local requestObject ignores timeoutMs entirely, so the cost there is a
   spurious warning, not 250 ms. The real cost is the missing negative cache, on
   QtRO.)

The same review's ABI-break and name-collision findings were measured and do not
apply: logos_protocol is a static archive with zero undefined imports of these
symbols anywhere in the built stack, and object names are scoped to a per-module
socket rather than a global registry. Both answered in-thread.

290/290 protocol tests.

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

* test(startup): exercise the handshake surface over a real transport

The existing handshake cases call ModuleHandshakeProxy directly, with no
transport underneath. That is what let a whole class of defect through: the
surface is only useful if a transport will PUBLISH a token-only QObject and a
consumer can ACQUIRE it by the derived name, and a direct-call test can see
neither half. The adapter survey prompted by review found qt_local silently
rejects a non-ModuleProxy on acquire while still reporting a successful publish
-- invisible to every test in the suite.

These run on the transport the production stack actually uses (QtRO, the
LogosTransportConfig default), and model the startup window honestly: the
handshake object is published and the business object deliberately is NOT,
because it does not exist until the initializer returns. That window is the
entire reason the surface exists and is the one state the direct-call tests
could never represent.

  TokenReachesAModuleWhoseBusinessObjectIsNotPublishedYet
      the pre-init window end to end: publish -> probe by derived name ->
      acquire -> push lands on the provider.
  AnUnseededAnchorRefusesEvenThoughTheSurfaceIsReachable
      the transport-level twin of the gate test: proves the refusal measured in
      production (29 of 34 app runs) is the gate rejecting the push, not the
      transport failing to deliver it -- the provider is never reached.
  ALegacyModuleFallsBackAndIsNotReProbed
      a module with no handshake surface still gets its token, and the missing
      surface is probed ONCE. Timed rather than functional, so it was falsified
      before being trusted: with the negative cache removed the suite fails on
      exactly this case and no other.

293/293 protocol tests.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:58:41 -03:00
Dario LipicarandClaude Opus 4.8 6401e30ae1 feat: group-shareable local sockets, stale-socket reaper, bind-failure detection (#20)
* feat: group-shareable local sockets, stale-socket reaper, bind-failure detection

The QtRO local transport binds each module's unix socket at 0777 & ~umask
(0755) with no way for a second OS user to reach it, discards the listen
result so a failed bind surfaces only as clients hanging, and never cleans up
the socket file — a hard-killed logos_host leaks it forever.

Add a Qt-free helper (logos_socket_paths.{h,cpp}) usable from both the qt_remote
and plain transport paths:

  - applySocketPerms(path): chgrp + chmod a bound socket per LOGOS_SOCKET_GROUP /
    LOGOS_SOCKET_MODE (chgrp-then-chmod so a half-applied policy is only ever
    too strict). No-op when unset, so default behaviour is unchanged. Connecting
    to an AF_UNIX socket needs write permission, so 0660 is what lets a group
    member in.
  - isSocketDead(path): S_ISSOCK && owned-by-us && non-blocking connect returns
    ECONNREFUSED/ENOENT. Fails closed on any other outcome, so it never reports
    a live socket or a regular file dead.
  - reapStaleSockets(dir, prefix): unlink only the dead sockets, never a regular
    file that shares the prefix (e.g. a *.lgx build artefact).

Wire it into RemoteTransportHost::publishObject and QtRemoteRegistry:
  - construct QRemoteObjectRegistryHost empty and listen via setRegistryUrl() so
    a bind failure is observed and logged (with lastError() + the socket path)
    instead of leaving a silently-broken host;
  - apply the socket-access policy to the freshly-bound local: socket.

The env-driven policy means every process in a node's tree (daemon, logos_host
subprocesses, their children) applies the same rule to every socket it binds
without threading config through each layer — the daemon exports the vars once.

Adds test_socket_paths.cpp (8 gtests): mode/group application, no-op default,
bad-mode rejection, live/dead/regular-file classification, and the reaper
keeping live sockets and regular files while removing only dead ones.

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

* review: harden socket helpers (gid overflow, socket-owner check, empty-prefix guard, dedup path)

Addressing automated review feedback on the socket helpers:

- resolveGid(): validate strtoul() errno/range so an out-of-range numeric
  LOGOS_SOCKET_GROUP is rejected instead of silently truncating to a wrong gid.
- applySocketPerms(): when a policy is requested, stat the path first and refuse
  unless it's a socket we own (S_ISSOCK + st_uid == geteuid()), so a malformed
  URL can never chmod/chown a stray file. No-op fast path when the env is unset.
- reapStaleSockets(): refuse an empty prefix (would make every dead socket the
  process owns a deletion candidate).
- Extract the duplicated `localSocketFilePath()` (Qt QLocalServer name->path
  rule) into a shared qt_remote/qt_socket_path.h so RemoteTransportHost and
  QtRemoteRegistry can't drift.

Adds tests: non-socket path refused (mode unchanged), empty-prefix reaper no-op.

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

* feat: transport-aware token validator hook on ModuleProxy (#22)

* feat: transport-aware token validator hook on ModuleProxy

Adds an injectable authorizer so a host (the logoscore daemon) can accept tokens
the built-in issued-token scan doesn't know — specifically operator-issued named
tokens validated against a persistent store — with per-token expiry and
local_only enforced against the transport the call arrived on.

- ModuleProxy::setTokenValidator(std::function<bool(token, transportProtocol)>).
  isAuthorized() consults it ONLY after the existing m_tokens + TokenManager
  scan fails, so installing a validator is purely additive: it can grant, never
  revoke, access the built-in path already allows. Empty (default) = today's
  behaviour exactly.
- callRemoteMethod() gains a defaulted `transportProtocol` ("local"). The QtRO
  local path (RemoteTransportHost) uses the default; PlainTransportHost::onCall
  passes the real wire ("tcp" | "tcp_ssl", fail-closed to non-local on an
  unexpected protocol) so a local_only token presented over the network is
  rejected. One ModuleProxy is shared across a provider's transports, so the
  transport can't be inferred — it must be threaded per call, which the defaulted
  arg does without changing the QtRO replica's 3-arg call.

The daemon backs the validator with TokenStore::lookupByToken; other modules
keep the default (no validator) and are unaffected.

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

* review: split callRemoteMethod into explicit 3-arg + 4-arg overloads; include <utility>

Addressing review feedback:

- Replace the defaulted transportProtocol argument with two explicit Q_INVOKABLE
  overloads. The Qt meta-object system matches methods by their full parameter
  list and doesn't apply C++ default arguments, so the QtRO/local 3-arg call
  must remain a real 3-arg method rather than relying on moc's reduced-arity
  generation. The 3-arg form forwards to the transport-aware 4-arg form with
  "local"; PlainTransportHost keeps calling the 4-arg form with the real wire.
- Include <utility> explicitly in module_proxy.h for std::move rather than
  relying on an indirect include.

Full protocol suite green (160/160).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:19:12 -03:00
Dario Lipicar 29afbac532 Extract the Logos protocol layer from logos-cpp-sdk (lp_* C ABI + protocol semver) (#2)
* Extract the Logos protocol layer from logos-cpp-sdk

Transports (plain TCP/TLS, qt_local, qt_remote/QRO, mock), token manager,
consumer core (LogosAPIClient/LogosAPIConsumer incl. the capability
auto-requestModule flow), ModuleProxy, the abstract LogosProviderObject
interface, and the canonical QVariant<->JSON conversion — now behind the
language-neutral lp_* C ABI (logos_protocol.h) carrying the protocol
semver (LOGOS_PROTOCOL_VERSION_*, lp_protocol_version()).

Bytes crossing the ABI use the lossless {"_bytes": base64url} tagging
(NUL-safe), matching the plain wire encoding.

Provider lp_* surface is compiled groundwork; serving lands with module
authoring.

* consumer: typed requestModule for the capability flow

Port of logos-cpp-sdk master f5a127dd ('use updated capability module',
cpp-sdk#85, Iuri Matias) — the touched files (logos_api_client.cpp,
logos_api_consumer.{h,cpp}) moved into this repo in the P1 extraction.
The capability auto-requestModule path now calls a typed std::string
helper on the consumer (which acquires the capability object directly)
instead of a stringly invokeRemoteMethod round-trip. 111/111 tests.
2026-06-12 18:59:01 -03:00