Dario LipicarandClaude Opus 5 7d2192c888 fix(origin): a module announces its OWN name, not "core" (#51)
* fix(origin): a module announces its OWN name, not "core"

plugin.rs hardcoded `let origin = CString::new("core")` when building a
module's outbound client, so every Rust module announced itself as "core" —
a TokenManager::bootstrapKeys() ANCHOR name — unprompted, in every handshake.

That is not a misattribution bug. "core" is an anchor name, so on a pre-0.8
module the provider's saveToken("core", T) lands in the OUTBOUND namespace,
which is exactly where the credential check reads. An ordinary
capability-minted pair token therefore BECOMES the provider's own credential.
Driven end to end: the caller's currentCaller() at the victim reports
caller_kind=host — a module authorizing AS THE HOST — and the victim's own
anchor is destroyed, locking the host out of that module. It also silently
widened any access policy whose allowedCallers contained "core".

Why the name had to be baked at generation time: the SDK genuinely cannot
learn it at runtime. The module-impl C ABI declares no self-name export, and
set_context's instance_id is a per-INSTANCE id derived from the persistence
path — often absent entirely. It IS known to lidl-gen as module.name, the same
string the derived name() method already uses and the same fact the C++
umbrella already bakes. So lidl-gen emits LOGOS_MODULE_NAME and the generated
ensure_ready() latches it into a set-once OnceLock ahead of the install hook.

Unset yields an EMPTY string, never a guess. Empty is fail-closed by name at
two independent gates — capability_module::requestModule rejects an empty
module name, and ModuleProxy rejects an empty caller — so a module that
somehow reaches the wire unlatched is refused rather than silently wearing
somebody else's identity.

The client cache is keyed (origin, target) rather than target: a client
carries its origin for life, so one constructed before the latch can never be
handed back after it. The construction still happens OUTSIDE the map lock —
outbound_origin() reads the OnceLock before the lock is taken — because on a
Qt-affine transport lp_client_create ends in runOnQtMainThread and holding the
lock there deadlocks.

ipc-test now asserts the announced origin across process boundaries: what the
caller announced, what capability_module admitted, the key the provider was
told to file under, the negative that no module may announce core or
capability_module, and an anti-vacuity check that a handshake actually
happened.

Also defines logos_module_accept_inbound_token (protocol 0.8). The binding is
declared inside the guarded emitted block rather than in api.rs: sharing an
rlib object with save_token pulled an undefined lp_token_save_inbound into
EVERY Rust module at EVERY protocol version, which ipc-test caught as a
dlopen failure.

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 — the ABI check was passing vacuously

  logos-protocol  480f40ff (0.5.0)  ->  42460e5b (0.8.0, master)

WHAT MOVED, AND WHY IT HAD TO.

The direct `logos-protocol` input is read at exactly one place in this flake:
checks.module-impl-abi's ABI_MANIFEST (flake.nix:245). Everything else —
protocolVersion, lib.callerBuildSupport, and both fixture modules — reaches
protocol through logos-module-builder.inputs.logos-protocol instead.

So that one input decides the entire content of the ABI check, and at 480f40ff
it decided it to nothing. 480f40ff is protocol 0.5.0, whose declared module-impl
export list is TEN names; logos_module_accept_inbound_token is not among them,
and the accept_inbound_token_block this branch adds is gated on >= 0.8, so it
emitted nothing. The check compared ten against ten and passed — as did CI on
3179ff36, all four jobs green. The definition this branch exists to add was
never looked at by anything.

That is now fixed by the lock alone: no source change was needed or made.

  module-impl C ABI check passed at protocol 0.8.0
  Declared by logos-protocol (12 exports):
    ... logos_module_accept_inbound_token ...
    ... logos_module_set_call_caller ...
  Defined by the Rust provider scaffold in all 4 configurations

Both 0.6's set_call_caller and 0.8's accept_inbound_token were invisible to the
10-name list; the relock brings both under the check in one step.

NON-VACUITY, MEASURED THREE WAYS.

Probe: move accept_inbound_token_block's gate from (0,8) to an unreachable
(99,0) so the export is not emitted, and build the check.

  * at 42460e5b (0.8.0), lidl-gen doCheck off to isolate the check itself:
      FAIL: [logos-rust-sdk provider scaffold (no-trait,multi), protocol 0.8.0]
        DECLARED by logos-protocol but NOT DEFINED by this backend:
            - logos_module_accept_inbound_token
      module-impl ABI check FAILED: incomplete module-impl C ABI in
      4 of 4 configuration(s)
    RED, naming the symbol, in every emitter configuration.

  * the SAME missing export at 480f40ff (0.5.0):
      module-impl C ABI check passed at protocol 0.5.0
      Declared by logos-protocol (10 exports): ...
    GREEN. That is the vacuity, reproduced on demand: the old lock cannot fail
    this check no matter what the emitter does with the 0.8 block.

  * with lidl-gen's own doCheck left on, the crate unit tests also go red:
      protocol_0_8_emits_the_inbound_token_export
      the_0_8_gate_is_major_aware_not_minor_alone
      the_outbound_door_stays_outbound_at_0_8
      test result: FAILED. 50 passed; 3 failed
    These pass explicit version strings and are lock-independent — they were
    live before this relock; the nix check was not.

Gate restored to (0,8); the probes were separate clones and none of them is in
this commit.

CHECKS BUILT INDIVIDUALLY, x86_64-linux, upstream cache only
(cache.nix.logos.co is 502ing, so these are source builds):

  .#checks.x86_64-linux.module-impl-abi
    /nix/store/y2y3lj4qfi72s4w0pki5w5x65j7gpq3d-rust-sdk-module-impl-abi
  .#checks.x86_64-linux.sdk-unit-tests
    /nix/store/5f654q9i9hi8dl0wm1pxiw92risc2sm4-logos-rust-sdk-unit-tests-0.3.0
  .#checks.x86_64-linux.ipc-test
    /nix/store/0f28x0hv3r26am7g5raxxgjlyqfdka95-rust-sdk-ipc-test
  path:./tests#checks.x86_64-linux.ipc-test  (the CI job, with
      --override-input logos-rust-sdk path:.)
    /nix/store/ccrbbv3gsv767a2lghch2b5a84q4xyjy-rust-sdk-ipc-test

Both ipc-test derivations end with the assertion this branch is really about:

  Origin passed: the caller announced "sdk_test_caller_module",
  not a bootstrap anchor

WHAT THIS RELOCK DOES NOT REACH, SAID PLAINLY.

ipc-test's fixtures build through logos-module-builder, whose own
logos-protocol is still 480f40ff (0.5.0), and their provider_gen.rs scaffolds
are checked in, generated at 0.5. So the origin assertions above are proven at
protocol 0.5.0, and logos_module_accept_inbound_token is NOT exercised at
runtime by any check here — module-impl-abi covers it at codegen level only.
Confirmed against the built plugin rather than assumed:

  nm -D --defined-only sdk_test_provider_module_plugin.so | grep logos_module_
    -> the ten 0.5 exports, no accept_inbound_token, no set_call_caller
  nm -D --undefined-only ... | grep '^lp_'   -> nothing at all

which is also the positive check on the known trap: the lp_token_save_inbound
binding stayed inside the guarded emitted block, so it did not leak an
undefined symbol into a module built at 0.5. Had it been moved to api.rs, that
grep would have printed it and the plugin would have died at dlopen().

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

---------

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

logos-rust-sdk

A Rust SDK for calling other Logos modules from within a Logos module. It consumes the language-neutral lp_* C ABI from logos-protocol directly, and — together with logos-module-builder and its logos-lidl-gen code generator — gives you typed, generated clients for every module you depend on: typed sync/async calls and typed event subscriptions, with no hand-written IPC.

Overview

When you write a Logos module in Rust, everything that crosses the module boundary is generated from your .lidl contracts: the module-impl C ABI scaffold your code plugs into, and a typed client for each module you depend on. This SDK is the runtime those generated clients are built on — it binds the lp_* C ABI, serializes parameters, drives the callback trampolines, and delivers results over channels.

In day-to-day module code you rarely name a type from this crate directly. You call modules().<dep>.<method>(...) (or its _async twin), subscribe with on_<event>(), and read your module's context with context() — all generated. The SDK is what makes those calls work.

The SDK is a pure Rust rlib with no build-time C dependency. On the standard authoring path (a cdylib module built by logos-module-builder, interface = "cdylib") the lp_* symbols resolve against the logos-protocol archive already linked into the plugin — one protocol stack shared by the generated Qt glue and your Rust code.

The authoring model — no build.rs

You don't add this SDK to a Cargo.toml by hand or write a build.rs. A Rust module is:

  • a .lidl contract (or a Rust trait the builder derives one from),
  • an impl of the generated trait, plus a one-line logos_module_install() hook,
  • a metadata.json whose codegen.rust block tells the builder to run the Rust generator and compile your crate.

logos-module-builder then supplies this SDK as your crate's logos-rust-sdk dependency (matched to the generator it ran, so there is no version skew), runs logos-lidl-gen to emit the scaffold (generated/provider_gen.rs), compiles the crate to a static archive, and links it into the plugin. Your flake.nix and CMakeLists.txt end up as small as a C++ module's.

The complete, runnable walkthrough — a Rust provider, a C++ provider, and a Rust consumer wired together — is the executable doc-test doctests/cross-language-composition.test.yaml.

Calling other modules

Everything in this section is generated by the builder from the contracts and emitted into your crate. This is the entire cross-module surface you write against — every call is typed; there is no string-keyed dispatch in module code.

Concrete dependencies — modules()

A module you list in dependencies gets a typed client on the modules() aggregate, reached by its module name. The host auto-loads it with you.

impl MyModule for MyImpl {
    // Synchronous typed call.
    fn total(&mut self) -> i64 {
        modules().counter_module.increment(1).unwrap_or(-1)
    }

    // Asynchronous twin: returns immediately; the typed result is delivered to
    // the callback on the module's event loop, after this method returns.
    fn bump_async(&mut self) {
        modules().counter_module.increment_async(1, |res| {
            if let Ok(v) = res { /* stash v; read it back from a later method */ }
        });
    }
}

Interface dependencies — Client::bind(name)

Code against a contract shape (declared in interface_dependencies) and bind it to a concrete provider chosen at runtime — no build-time coupling to any particular module:

fn hello(&mut self, name: String) -> String {
    greeter::GreeterClient::bind("cpp_greeter_module")
        .greet(&name)
        .unwrap_or_else(|e| format!("greet failed: {}", e))
}

Per-call timeouts — <method>_with_timeout(...)

Every call has a timeout; by default it is the protocol's own, 20 seconds. To bound one call more tightly, call its _with_timeout twin. Each generated method has one, on both surfaces:

pub fn fetch(&self, sym: &str) -> Result<Quote, LogosError>;
pub fn fetch_with_timeout(&self, sym: &str, timeout: Duration) -> Result<Quote, LogosError>;
pub fn fetch_async<F>(&self, sym: &str, callback: F);
pub fn fetch_async_with_timeout<F>(&self, sym: &str, timeout: Duration, callback: F);

The bound belongs to the call, not to the client, so one client can serve calls with entirely different expectations — which is the normal case, since how long is too long is a fact about the method:

fn refresh(&mut self) -> String {
    let oracle = modules().price_oracle;

    let spot  = oracle.fetch_with_timeout("ETH", Duration::from_millis(500));  // ~500ms
    let hist  = oracle.backfill_with_timeout(30, Duration::from_secs(10));     // ~10s
    let other = oracle.fetch("BTC");                                           // 20s default

    format!("{:?} {:?} {:?}", spot, hist, other)
}

Nothing is stored: the duration is threaded down to lp_invoke's timeout_ms argument for that one call, so a bound cannot leak into a later call that did not ask for one.

The Duration is converted to the ABI's millisecond c_int at the boundary and refused rather than clamped if it does not fit — sub-millisecond (which the ABI would read as "use the default", i.e. 20s) and anything past c_int::MAX ms (~24.8 days) both yield LogosError::InvalidTimeout at the point the bad value was supplied. On the async twins that error is delivered to the callback, the way every other undispatchable async call is reported.

Underneath, PluginProxy offers the same shape for hand-written calls: call_with_timeout, call_with_params_with_timeout, call_sync_with_timeout, call_json_with_timeout, call_json_async_with_timeout.

This duplicated surface is a stopgap. Rust has neither overloading nor default arguments, so a timeout parameter on fetch itself would break every existing call site. A later breaking release will make the timeout a first-class parameter of the one entry point per method and drop the twins; until then the pair is deliberate, and mirrors what the C++ SDK did for fooAsyncResult.

Typed events — on_<event>() / decode_<event>()

Each event in a dependency's contract generates a typed subscription. The returned EventSubscription owns its client share, so it keeps receiving after the proxy is dropped — move it into a listener thread and iterate it:

let mut calc = modules().rust_calc_module;
if let Ok(sub) = calc.on_computed() {
    std::thread::spawn(move || {
        for ev in sub {
            if let Some(e) = rust_calc_module::RustCalcModuleClient::decode_computed(&ev) {
                // e.total — the decoded, typed payload
            }
        }
    });
}

Module context — context() / RustModuleContext

The host stamps each loaded instance with its identity. Read it through the generated context():

fn whereami(&mut self) -> String {
    match context() {
        Some(c) => format!(
            "module_path={} | instance_id={} | persistence={}",
            c.module_path, c.instance_id, c.instance_persistence_path
        ),
        None => "context not ready".to_string(),
    }
}

instance_persistence_path is a writable, per-instance directory — the place to keep a module's on-disk state.

Who is calling — current_caller()

Who made the call your handler is running. Ambient rather than a parameter: the callee already possesses the caller's identity, because the caller had to present a token this module itself issued to get here, so nothing about it is per-method or visible in a .lidl.

fn wipe(&mut self) -> bool {
    let caller = logos_rust_sdk::current_caller();
    if !caller.is_module("admin_module") {
        eprintln!("refusing wipe from {}", caller.describe_for_human());
        return false;                      // Unknown lands here too
    }
    true
}

LogosCaller is Unknown | HostAnchor | Module{name, instance} | Derived{parent, leaf} | Operator{name}, with is_module(name), is_derived(parent, leaf), identity() (stable, for a map key) and describe_for_human() (a log line). Unknown is the fail-closed answer and it is in band: an unnamed caller, a document this build cannot read, an arm from a newer protocol, and "no dispatch in flight on this thread" — a spawned worker, a timer, on_context_ready, an event emission — all read Unknown.

Valid only for the duration of one dispatch, on the dispatching thread; a handler that needs the identity later copies it at the top. Requires logos-protocol 0.6: the host pushes the identity across logos_module_set_call_caller, which the generated scaffold only exports at >= 0.6. Built against an older protocol the accessor exists and answers Unknown.

Who you are — the announced origin

The mirror of current_caller(), from the other side. Every outbound call this module makes carries an origin: the name it declares itself to be. capability_module checks that name against its known-caller roster and against the target's access policy, then pushes the minted token to the target naming it — so the origin is the key the target files the token under, and the name the target's current_caller() reports.

You do not set it. The generated scaffold carries your contract's own module name as LOGOS_MODULE_NAME and declares it to the SDK from every C-ABI entry point, before any of your code runs — the same fact the C++ generated umbrella bakes into logos::LpClient(target, origin).

It matters that the name is real. "core" and "capability_module" are not module names: they are TokenManager::bootstrapKeys() role labels, the keys a host-issued anchor lives under. A module announcing one of them authorizes as the host at every callee, and satisfies every derived access policy (which always lists "core" among its allowed callers).

Only a caller outside a module plugin — a Rust binary linking liblogos_protocol through lib.callerBuildSupport, with no generated scaffold — has to declare its own:

logos_rust_sdk::set_module_origin("my_tool");   // before the first call

Set once per process; a second, different name is refused. With none declared the origin is empty, which the capability handshake rejects by name — fail closed, never a borrowed identity.

Supporting types

The handful of SDK types that surface directly in module code:

EventSubscription and EventData

on_<event>() returns an EventSubscription: a Send handle bundling the event channel with ownership of the lp subscription and a share of the client (dropping a bare proxy would otherwise silently kill the subscription). It supports recv(), try_recv(), blocking iteration (for ev in sub), and unsubscribes on drop. Each item is an EventData, which the generated decode_<event>() turns into the typed payload struct:

pub struct EventData {
    pub event: String,            // event name
    pub data: serde_json::Value,  // raw payload as JSON — use decode_<event>() for the typed form
}

LogosError

Every typed call and subscription returns Result<_, LogosError>:

Variant Cause
PluginCallFailed Method call returned an error from the remote module
EventListenerFailed Event registration failed
InvalidTimeout A _with_timeout duration the protocol ABI cannot express (sub-millisecond, or > ~24.8 days)
InvalidString A string argument contained a null byte
JsonError Parameter serialization failed
ChannelClosed The callback channel was dropped unexpectedly
Other Miscellaneous error with a descriptive message

How symbols resolve

logos-rust-sdk declares extern "C" bindings to the lp_* protocol functions but links no C library at Rust compilation time. The crate compiles to a staticlib containing unresolved lp_* references, which resolve at the final plugin link:

librust_my_module.a       (your Rust staticlib, contains unresolved lp_* refs)
        ↓
CMake links plugin .dylib
  + logos-protocol archive          ← lp_* resolved here (one shared stack
        ↓                              with the generated Qt glue)
my_module_plugin.dylib    (complete, loadable Logos module)

The builder's cmake/LogosModule.cmake stages and links the archive from codegen.rust automatically — the author writes no link lines.

Building

The SDK itself has no standalone Nix build artifact — it is a library crate consumed by module builds. To work on it:

# Enter a dev shell with Rust toolchain
nix develop

# Run unit tests (params serialization, etc.)
cargo test

Testing

Complementary checks exercise the SDK and the Rust-module pipeline it builds on (the cross-language composition showcases — typed calls and events crossing the C++/Rust boundary in both directions — live in logos-module-builder's doctests, since they exercise the builder across both SDKs):

  • IPC integration test (tests/) — builds a minimal provider + caller module pair on the cdylib authoring path: each fixture is a .lidl contract from which lidl-gen --provider generates the Rust module-impl C ABI scaffold (logos_module_* exports, typed trait, RustModuleContext) and logos-module-builder (interface = "cdylib") generates the uniform Qt glue. The author writes the trait impl plus a #[no_mangle] fn logos_module_install() hook; the plugin links one logos-protocol stack shared by the glue and the SDK, so the host token forwarded through logos_module_accept_token authenticates the caller's outbound add() call. Verified end-to-end through logoscore:

    nix build 'path:./tests#checks.x86_64-linux.ipc-test' \
      --override-input logos-rust-sdk path:. --print-build-logs
    
  • Unit tests + module-impl ABI (checks.<system>.sdk-unit-tests, checks.<system>.module-impl-abi) — cargo test -p logos-rust-sdk (the bytes codec, the argument validator, the per-call timeout rules, the caller parser and its per-thread stack), and a check that diffs what lidl-gen --provider DEFINES against what logos-protocol DECLARES, in all four emitter configurations. The second exists because that gap has shipped twice (logos_module_grant_host_services at protocol 0.3, the teardown pair at 0.5): a missing definition links cleanly and then fails at dlopen() on Linux, invisibly on macOS.

    nix build .#checks.x86_64-linux.sdk-unit-tests .#checks.x86_64-linux.module-impl-abi
    
  • Executable doc-test (doctests/cross-language-composition.test.yaml) — a step-by-step, runnable tutorial that writes three modules from scratch on the builder-driven cdylib path (no build.rs): a Rust provider, a C++ provider, and a Rust consumer that ties them together. It packages each as an .lgx, installs them with lgpm, and drives the consumer through a logoscore daemon to exercise the whole consumer surface — module context (module_path / instance_id / instance_persistence_path), sync and async typed calls, typed event subscription, and both a concrete and an interface dependency. Run it with the shared doctest CLI:

    cd doctests && ./run.sh
    

    The rendered tutorial is committed at doctests/outputs/cross-language-composition/cross-language-composition.md.

S
Description
No description provided
Readme
43 MiB
Languages
Rust 88.7%
Shell 6.6%
Nix 3.7%
CMake 0.9%