Skip to content

Commit 2920bbc

Browse files
Let the module resolve a backend session, so proxied Composio can run in it
Composio sync has two credential paths and only one of them worked inside a loaded module. The direct branch reads its API key through `ComposioHost::api_key`, a seam the host answers per call. The proxied branch read `Config::session_token`, which inside a module is a load-time snapshot carrying no bearer — so `EngineRuntimeConfig` refused it by design and every proxied user fell out of the branch. The consequence was quiet and large. A host whose Composio mode is backend — which is OpenHuman's default — got a periodic sync loop that never started, because `composio_sync_can_run` gated on direct mode, and neither the host nor the module reported it, since neither believed it was responsible. `ComposioHost` gains `session_bearer`, beside the `api_key` that already works. `composio_config`'s proxied branch consults the seam first and falls back to the config exactly as before, so a host running the engine in-process behaves identically — no seam is installed there, the accessor answers `None`, and the old config read still happens. ## Why a seam rather than a field on ModuleConfig The bearer is an app-session JWT the host refreshes. A value captured at module load works until it expires and then makes every sync fail with an auth error that reads as the user being signed out — the silent-staleness failure this whole migration keeps having to design against. Asking per call means the answer is always the one that is valid now. It is the same reasoning `api_key` already carries, and the reason that member is fetched per call too. ## What the gate now excludes Both modes qualify: direct resolves a key, backend resolves a bearer. What is still refused is a host that resolved to *neither* — an empty or unrecognised mode string, which has no credential path at all, so starting the loop would fail on every tick and append a failed audit row each time. The trait member is defaulted to `None`, so a host that predates it compiles unchanged and falls back to the config read it always did. `session_bearer` deliberately does NOT copy `is_available`'s optimism. That probe answers `true` when it cannot reach the host, because a wrong `false` there reads as "not signed in" and hides a broken sync. A credential is not something to be optimistic about: an unreachable host yields `None`, which lets `composio_config` refuse by name rather than send an empty bearer at the backend.
1 parent 4020ab5 commit 2920bbc

6 files changed

Lines changed: 129 additions & 21 deletions

File tree

crates/tinymemory-core/src/composio_host.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,27 @@ pub trait ComposioHost: Send + Sync + std::fmt::Debug {
7575
/// `None` when direct mode is not configured.
7676
fn api_key(&self, config: &Config) -> Option<String>;
7777

78+
/// The OpenHuman backend bearer for proxied ("backend") mode.
79+
///
80+
/// A seam rather than a config field, and that is the whole point of it.
81+
/// The bearer is an app-session JWT the host refreshes; a value captured
82+
/// once — at module load, say — works until it expires and then makes every
83+
/// sync fail with an auth error that reads as the user being signed out.
84+
/// Asking per call means the answer is always the one that is valid now.
85+
///
86+
/// `None` means the host has no session to lend, which is a signed-out user
87+
/// rather than a broken one. The caller must not read that as "nothing to
88+
/// sync": [`composio_config`](crate::sync::pipelines::host::composio_config)
89+
/// turns it into a named refusal instead.
90+
///
91+
/// Defaulted to `None` so a host that predates this member still compiles
92+
/// and simply falls back to whatever `Config::session_token` answers, which
93+
/// is exactly the behaviour it had before the member existed.
94+
fn session_bearer(&self, config: &Config) -> Option<String> {
95+
let _ = config;
96+
None
97+
}
98+
7899
/// Whether *some* viable client resolves for the current config.
79100
///
80101
/// The sync layer uses this as its "is the user signed in?" probe. It must
@@ -146,6 +167,17 @@ pub fn api_key(config: &Config) -> Option<String> {
146167
composio_host()?.api_key(config)
147168
}
148169

170+
/// The backend bearer from the installed host, or `None` when no host is
171+
/// installed or the host has no session.
172+
///
173+
/// The two are deliberately not distinguished here: both mean "this process
174+
/// cannot authenticate a proxied Composio call right now", and the caller's
175+
/// fallback and error message are the same either way.
176+
#[must_use]
177+
pub fn session_bearer(config: &Config) -> Option<String> {
178+
composio_host()?.session_bearer(config)
179+
}
180+
149181
/// Whether a viable Composio client resolves. `false` when unwired.
150182
#[must_use]
151183
pub fn is_available(config: &Config) -> bool {

crates/tinymemory-core/src/sync/pipelines/host.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,14 @@ pub fn composio_config(config: &Config) -> Result<ComposioSyncConfig, String> {
259259
entity_id: Some(config.composio().entity_id.clone()),
260260
})
261261
} else {
262-
let bearer = config
263-
.session_token()?
262+
// The seam first, the config second — the mirror of the direct branch
263+
// above. Inside a loaded module `session_token` cannot answer (the
264+
// module holds a load-time snapshot with no bearer in it), so without
265+
// the seam this branch refuses for every proxied user. Outside a module
266+
// no host is installed, the seam answers `None`, and this falls through
267+
// to exactly the config read it always did.
268+
let bearer = crate::composio_host::session_bearer(config)
269+
.or_else(|| config.session_token().ok().flatten())
264270
.ok_or_else(|| "OpenHuman backend bearer token is not configured".to_string())?;
265271
Ok(ComposioSyncConfig {
266272
mode: ComposioMode::Proxied,

crates/tinymemory-module/src/composio.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ pub const API_KEY_METHOD: &str = "ApiKey";
8484
/// Whether *some* viable Composio client resolves host-side right now.
8585
pub const IS_AVAILABLE_METHOD: &str = "IsAvailable";
8686

87+
/// The OpenHuman backend bearer for proxied mode, or `None` when signed out.
88+
///
89+
/// Asked per call rather than carried in `ModuleConfig` because it is a session
90+
/// JWT the host refreshes: a snapshot works until it expires and then reads as
91+
/// a signed-out user on every subsequent sync.
92+
pub const SESSION_BEARER_METHOD: &str = "SessionBearer";
93+
8794
/// Latched so the gap is reported once per process rather than once per sync
8895
/// tick — the periodic scheduler consults this seam on every tick, and an
8996
/// unlatched report would page on every one of them. Same guard the scheduler
@@ -353,6 +360,18 @@ impl ComposioHost for BusComposioHost {
353360
self.probe::<Option<String>>(API_KEY_METHOD).flatten()
354361
}
355362

363+
/// The proxied-mode bearer, fetched per call for the reason on
364+
/// [`SESSION_BEARER_METHOD`].
365+
///
366+
/// An unreachable host flattens to `None`, which the caller turns into a
367+
/// named refusal rather than silence — the opposite of `is_available`'s
368+
/// optimistic answer below, and deliberately so: a bearer this process
369+
/// cannot obtain is not a credential it may guess at.
370+
fn session_bearer(&self, _config: &tinymemory_core::Config) -> Option<String> {
371+
self.probe::<Option<String>>(SESSION_BEARER_METHOD)
372+
.flatten()
373+
}
374+
356375
/// Whether the sync layer should treat the user as signed in.
357376
///
358377
/// # An unreachable host answers *yes*, deliberately

crates/tinymemory-module/src/composio_test.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ struct Executed {
3030
struct FakeComposioHost {
3131
executed: tokio::sync::mpsc::UnboundedSender<Executed>,
3232
api_key: Option<String>,
33+
session_bearer: Option<String>,
3334
available: bool,
3435
}
3536

@@ -76,6 +77,11 @@ impl FakeComposioHost {
7677
Ok(self.api_key.clone())
7778
}
7879

80+
async fn session_bearer(&self) -> BusResult<Option<String>> {
81+
std::future::ready(()).await;
82+
Ok(self.session_bearer.clone())
83+
}
84+
7985
async fn is_available(&self) -> BusResult<bool> {
8086
std::future::ready(()).await;
8187
Ok(self.available)
@@ -98,6 +104,7 @@ async fn bus_with_composio_host(
98104
FakeComposioHost {
99105
executed,
100106
api_key: api_key.map(str::to_string),
107+
session_bearer: Some("bearer-from-the-host".to_string()),
101108
available,
102109
},
103110
)
@@ -263,3 +270,32 @@ fn only_the_nobody_is_listening_family_reads_as_unserved() {
263270
assert!(!unserved("ai.tinyhumans.tinybus.Error.Failed"));
264271
assert!(!unserved("ai.tinyhumans.tinybus.Error.Timeout"));
265272
}
273+
274+
/// The proxied-mode bearer crosses the bus, and an unreachable host answers
275+
/// `None` rather than guessing.
276+
///
277+
/// The second half is the half worth pinning. `is_available` deliberately
278+
/// answers `true` when it cannot reach the host, because a wrong `false` there
279+
/// reads as "not signed in" and hides a sync that is actually broken. This
280+
/// member must not copy that: a credential is not something to be optimistic
281+
/// about, and `None` is what lets `composio_config` refuse by name instead of
282+
/// sending an empty bearer at the backend.
283+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
284+
async fn the_session_bearer_crosses_the_bus_and_is_never_guessed() {
285+
let (connection, _executed) = bus_with_composio_host(None, true).await;
286+
let bridge = BusComposioHost::new(connection);
287+
let config = tinymemory_tinycortex::engine::EngineRuntimeConfig::from(&ModuleConfig::default());
288+
289+
assert_eq!(
290+
bridge.session_bearer(&config).as_deref(),
291+
Some("bearer-from-the-host"),
292+
"the host's live session must reach the engine unchanged"
293+
);
294+
295+
let unserved = BusComposioHost::new(bus_without_composio_host().await);
296+
assert_eq!(
297+
unserved.session_bearer(&config),
298+
None,
299+
"an unreachable host must not be optimistic about a credential"
300+
);
301+
}

crates/tinymemory-module/src/lib.rs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -378,18 +378,25 @@ fn start_composio_periodic_sync(config: &ModuleConfig) {
378378
}
379379

380380
log::warn!(
381-
"[tinymemory:module] periodic Composio sync is NOT started: this host did not resolve \
382-
Composio to direct mode, and backend mode needs a session bearer this module holds no \
383-
field for and will not carry. Composio-connected sources will not update in this \
384-
process until the sync client routes through `ComposioHost::execute`"
381+
"[tinymemory:module] periodic Composio sync is NOT started: this host resolved Composio \
382+
to neither direct nor backend mode, so no credential can be obtained for it. \
383+
Composio-connected sources will not update in this process"
385384
);
386385
}
387386

388387
/// Whether the Composio pipelines can resolve a credential in this process.
389388
///
390-
/// True only for direct mode, which is the whole of the gate: the other branch
391-
/// of `sync::pipelines::host::composio_config` needs a backend session bearer,
392-
/// and `EngineRuntimeConfig::session_token` refuses to answer one by design.
389+
/// Both modes qualify now. Direct mode reads its API key through
390+
/// `ComposioHost::api_key`, and backend mode reads its bearer through
391+
/// `ComposioHost::session_bearer` — the seam added precisely so this gate could
392+
/// stop excluding the mode most hosts actually run. It used to be direct-only,
393+
/// which meant the loop silently did not start for a host whose default is
394+
/// backend, and neither side reported it because neither thought it was
395+
/// responsible.
396+
///
397+
/// What is still excluded is a host that resolved to *neither* — an empty or
398+
/// unrecognised mode string. There is no credential path for that, so starting
399+
/// the loop would fail on every tick and append a failed audit row each time.
393400
///
394401
/// Asked of the *same* `EngineRuntimeConfig` the loop's own ticks will be handed
395402
/// and through the same `MemoryHostConfig::composio` accessor `composio_config`
@@ -404,9 +411,11 @@ fn start_composio_periodic_sync(config: &ModuleConfig) {
404411
/// asserting it through the caller would spawn a real 20-minute tick loop into
405412
/// the test binary.
406413
pub(crate) fn composio_sync_can_run(config: &ModuleConfig) -> bool {
407-
tinymemory_tinycortex::engine::EngineRuntimeConfig::from(config)
408-
.composio()
409-
.is_direct()
414+
let composio = tinymemory_tinycortex::engine::EngineRuntimeConfig::from(config).composio();
415+
// Mirrors `composio_config`'s own branch: direct, else anything that names
416+
// a mode at all takes the proxied path. An unset mode names neither and is
417+
// the one case with no credential to reach for.
418+
composio.is_direct() || !composio.mode.trim().is_empty()
410419
}
411420

412421
/// The workspace whose queue this process's worker pool drains.

crates/tinymemory-module/src/service/test.rs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -530,18 +530,24 @@ fn the_sync_loops_are_claimed_once_and_a_foreign_workspace_is_refused() {
530530
/// The Composio gate answers for exactly the branch the pipeline would take.
531531
///
532532
/// Worth pinning because the two ways it can be wrong are both quiet. A gate
533-
/// that started the loop in backend mode would list the user's connections
534-
/// every 20 minutes and fail every due one on `session_token`'s refusal,
535-
/// appending a failed row to the sync audit each time; a gate that refused
536-
/// direct mode would leave a host that could sync perfectly well with Composio
537-
/// sources that simply stop updating, and one line at boot to explain it.
533+
/// that started the loop for a mode with no credential path would list the
534+
/// user's connections every 20 minutes and fail every due one, appending a
535+
/// failed row to the sync audit each time; a gate that refused a mode that CAN
536+
/// resolve one would leave a host whose Composio sources simply stop updating,
537+
/// with a single line at boot to explain it.
538+
///
539+
/// Backend mode moved from the second category to the first when
540+
/// `ComposioHost::session_bearer` landed. It used to be excluded because
541+
/// `EngineRuntimeConfig::session_token` refuses by design — which meant the
542+
/// loop did not start for a host whose default mode is backend, and neither the
543+
/// host nor the module reported it, because neither thought it was responsible.
538544
///
539545
/// Asserted through `composio_sync_can_run` rather than
540546
/// `start_composio_periodic_sync` for the reason the claim tests above give:
541547
/// the decision is the whole of what is worth checking, and the call after it
542548
/// spawns a real 20-minute tick loop for the life of the test binary.
543549
#[test]
544-
fn composio_periodic_sync_starts_only_when_the_host_resolved_direct_mode() {
550+
fn composio_periodic_sync_starts_for_any_mode_that_can_resolve_a_credential() {
545551
let mut config = test_config(std::path::Path::new("/tinymemory-module/composio-gate"));
546552

547553
assert!(
@@ -551,14 +557,14 @@ fn composio_periodic_sync_starts_only_when_the_host_resolved_direct_mode() {
551557

552558
config.composio_mode = tinymemory_api::host::COMPOSIO_MODE_BACKEND.to_string();
553559
assert!(
554-
!crate::composio_sync_can_run(&config),
555-
"backend mode needs a session bearer this module refuses to hold"
560+
crate::composio_sync_can_run(&config),
561+
"backend mode resolves its bearer through ComposioHost::session_bearer"
556562
);
557563

558564
config.composio_mode = tinymemory_api::host::COMPOSIO_MODE_DIRECT.to_string();
559565
assert!(
560566
crate::composio_sync_can_run(&config),
561-
"direct mode is the one branch that resolves its credential in here"
567+
"direct mode resolves its key through ComposioHost::api_key"
562568
);
563569

564570
// The pipeline's own branch test is case-insensitive. If the gate were not,

0 commit comments

Comments
 (0)