Problem
create_recall_node (integrations/langgraph/langgraph_memanto/nodes.py#L26-L130) and create_remember_node (#L132-L220) both contain an inner _do_setup(resolved_agent_id) helper that mutates the shared client.session_token attribute when the recall/remember call fails with "no active session":
# integrations/langgraph/langgraph_memanto/nodes.py:39-49 (verbatim)
def _do_setup(resolved_agent_id: str):
try:
client.create_agent(agent_id=resolved_agent_id, pattern="tool")
except Exception:
pass
try:
result = client.activate_agent(resolved_agent_id, duration_hours=6)
client.session_token = result.get("session_token") # ← mutates the
# process-wide
# shared client
except Exception:
pass
The client is a single SdkClient instance — captured by the inner node functions via closure at graph build time, then shared across every LangGraph run on that compiled graph (i.e. across every user / tenant the FastAPI server handles).
When two concurrent runs hit the "no active session" path with different agent_ids, they race on client.session_token. The last writer wins, and any concurrent recall/remember call by the OTHER agent_id then authenticates with the wrong agent's session token.
Concrete sequence:
T0 Session A: recall(agent_id="alice")
→ "no active session" exception
→ _do_setup("alice")
→ client.activate_agent("alice") → TOKEN_A
→ client.session_token = TOKEN_A
T1 Session B: recall(agent_id="bob")
→ "no active session" exception
→ _do_setup("bob")
→ client.activate_agent("bob") → TOKEN_B
→ client.session_token = TOKEN_B ← clobbers TOKEN_A
T2 Session A: retries recall(agent_id="alice")
→ client.session_token == TOKEN_B
→ API call authenticates as Bob
→ returns Bob's memories
Whether the memanto server-side enforces a strict (agent_id, session_token) binding or accepts whatever the token authenticates as determines whether this becomes a hard "auth error" failure or a silent cross-tenant data leak. Either way, the LangGraph integration violates the per-agent isolation users expect from a multi-tenant memory layer.
Reproducer
mirrors the closure + shared-mutation pattern with stand-ins for the SdkClient methods so the race manifests deterministically. Save as repro.py:
import asyncio
import uuid
class SdkClient:
"""Mirrors the real client: single instance per process, session_token
is a mutable instance attribute."""
def __init__(self):
self.session_token = None
def create_agent(self, *, agent_id, pattern):
pass
async def activate_agent(self, agent_id, duration_hours=6):
await asyncio.sleep(0.05)
return {"session_token": f"TOKEN_{agent_id}_{uuid.uuid4().hex[:6]}"}
async def recall(self, *, agent_id, query):
await asyncio.sleep(0.02)
if self.session_token is None:
raise Exception("no active session")
# Model the silent-leak case: the API authenticates by token,
# returns whichever agent the token belongs to (regardless of
# the agent_id the caller asked for).
token_agent_id = self.session_token.split("_")[1]
return {
"memories": [
{
"title": f"Memory of {token_agent_id}",
"content": f"Private data of agent {token_agent_id}",
}
],
"served_for_agent_id": token_agent_id,
"requested_agent_id": agent_id,
}
def create_recall_node(client):
async def _do_setup(resolved_agent_id):
try:
client.create_agent(agent_id=resolved_agent_id, pattern="tool")
except Exception:
pass
try:
result = await client.activate_agent(resolved_agent_id, duration_hours=6)
client.session_token = result.get("session_token")
except Exception:
pass
async def recall_node(state, config=None):
resolved_agent_id = (config or {}).get("configurable", {}).get("agent_id")
try:
result = await client.recall(agent_id=resolved_agent_id, query=state["query"])
except Exception:
await _do_setup(resolved_agent_id)
result = await client.recall(agent_id=resolved_agent_id, query=state["query"])
return {"recalled": result, "agent_id_in_request": resolved_agent_id}
return recall_node
async def main():
shared_client = SdkClient()
recall_node = create_recall_node(shared_client)
users = ["alice", "bob", "carol", "dave"]
results = await asyncio.gather(*[
recall_node({"query": "what is my favourite colour?"},
{"configurable": {"agent_id": uid}})
for uid in users
])
for r in results:
req = r["agent_id_in_request"]
served = r["recalled"]["served_for_agent_id"]
marker = "OK" if req == served else "*** LEAK ***"
print(f"user={req:<6} served_for={served:<8} {marker}")
asyncio.run(main())
Output:
user=alice served_for=dave *** LEAK ***
user=bob served_for=dave *** LEAK ***
user=carol served_for=dave *** LEAK ***
user=dave served_for=dave OK
Three out of four concurrent users received Dave's memories.
Expected behavior
Each user's recall call should return ONLY that user's memories. Concurrent runs with different agent_ids should not interfere with each other's authentication / memory namespace.
Actual behavior
The shared client.session_token is overwritten by whichever session most-recently completed _do_setup. All concurrent recall/remember calls on that client share that token until the next overwrite. Cross-tenant memory pollution.
Real-world impact
Memanto's stated purpose is storing long-term private user data: calendar preferences, response preferences, contradiction history, conversation memory. The LangGraph integration is the canonical way users consume memanto from agent workflows (see #456 and the merged Feat/langgraph integration PRs). Any production deployment of this integration that:
- Builds one compiled LangGraph graph at startup (the standard FastAPI pattern), AND
- Serves multiple users / tenants on a per-
agent_id basis (per the documented configurable agent_id_from_config pattern), AND
- Has any cold start or session-expiry where
_do_setup is invoked will hit this race. The window only needs two concurrent requests where the prior session was expired.
Problem
create_recall_node(integrations/langgraph/langgraph_memanto/nodes.py#L26-L130) andcreate_remember_node(#L132-L220) both contain an inner_do_setup(resolved_agent_id)helper that mutates the sharedclient.session_tokenattribute when the recall/remember call fails with "no active session":The
clientis a singleSdkClientinstance — captured by the inner node functions via closure at graph build time, then shared across every LangGraph run on that compiled graph (i.e. across every user / tenant the FastAPI server handles).When two concurrent runs hit the "no active session" path with different
agent_ids, they race onclient.session_token. The last writer wins, and any concurrent recall/remember call by the OTHER agent_id then authenticates with the wrong agent's session token.Concrete sequence:
Whether the memanto server-side enforces a strict
(agent_id, session_token)binding or accepts whatever the token authenticates as determines whether this becomes a hard "auth error" failure or a silent cross-tenant data leak. Either way, the LangGraph integration violates the per-agent isolation users expect from a multi-tenant memory layer.Reproducer
mirrors the closure + shared-mutation pattern with stand-ins for the SdkClient methods so the race manifests deterministically. Save as
repro.py:Output:
Three out of four concurrent users received Dave's memories.
Expected behavior
Each user's recall call should return ONLY that user's memories. Concurrent runs with different
agent_ids should not interfere with each other's authentication / memory namespace.Actual behavior
The shared
client.session_tokenis overwritten by whichever session most-recently completed_do_setup. All concurrent recall/remember calls on that client share that token until the next overwrite. Cross-tenant memory pollution.Real-world impact
Memanto's stated purpose is storing long-term private user data: calendar preferences, response preferences, contradiction history, conversation memory. The LangGraph integration is the canonical way users consume memanto from agent workflows (see #456 and the merged
Feat/langgraph integrationPRs). Any production deployment of this integration that:agent_idbasis (per the documented configurableagent_id_from_configpattern), AND_do_setupis invoked will hit this race. The window only needs two concurrent requests where the prior session was expired.