Skip to content

Bug: LangGraph integration mutates shared SdkClient.session_token per-agent_id — concurrent users with different agent_ids leak each other's memories (cross-tenant) #884

Description

@Fr3ya

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:

  1. Builds one compiled LangGraph graph at startup (the standard FastAPI pattern), AND
  2. Serves multiple users / tenants on a per-agent_id basis (per the documented configurable agent_id_from_config pattern), AND
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions