diff --git a/.changeset/hermes-projectless-routing.md b/.changeset/hermes-projectless-routing.md new file mode 100644 index 000000000..0aaee19db --- /dev/null +++ b/.changeset/hermes-projectless-routing.md @@ -0,0 +1,5 @@ +--- +"tracedecay": patch +--- + +Harden Hermes projectless routing, profile-owned context and memory scope, user-level transcript search, and generated-plugin isolation so host profile directories cannot become TraceDecay code projects. diff --git a/scripts/hermes_plugin_unit_check.py b/scripts/hermes_plugin_unit_check.py index 3938752f4..b5ef3ff64 100644 --- a/scripts/hermes_plugin_unit_check.py +++ b/scripts/hermes_plugin_unit_check.py @@ -210,16 +210,119 @@ def run_checks(work: Path): unrelated_root = work / "unrelated-project" registered_child.mkdir(parents=True) unrelated_root.mkdir() + hermes_descendant = host_home / "repos" / "registered-project" + hermes_descendant.mkdir(parents=True) + (hermes_descendant / "src").mkdir() + assert plugin.tools.code_project_root(cwd=str(host_home)) is None + assert plugin._code_project_root(cwd=str(host_home), hermes_home=str(host_home)) is None + assert plugin.tools.code_project_root(cwd=str(hermes_descendant)) == str(hermes_descendant) + assert plugin._code_project_root( + cwd=str(hermes_descendant), hermes_home=str(host_home) + ) == str(hermes_descendant) + missing_home_child = host_home / "missing-project" + assert plugin._project_scope_resolution( + str(missing_home_child), str(host_home) + ) == ("rejected", None) + tool_argv = [] + tool_run_kwargs = [] + real_tools_run = plugin.tools.subprocess.run + class ToolResult: + returncode = 0 + stdout = "{}" + stderr = "" + try: + def capture_tool_run(argv, **run_kwargs): + tool_argv.append(argv) + tool_run_kwargs.append(run_kwargs) + return ToolResult() + plugin.tools.subprocess.run = capture_tool_run + plugin.tools.call_tracedecay_tool( + "tracedecay_project_search", {"query": "scope"}, cwd=str(host_home) + ) + assert "--project" not in tool_argv[-1], tool_argv[-1] + assert tool_argv[-1][1:4] == ["projects", "search", "scope"], tool_argv[-1] + assert tool_run_kwargs[-1]["cwd"] == os.path.abspath(os.sep) + plugin.tools.call_tracedecay_tool( + "tracedecay_project_search", {"query": "scope"}, cwd=str(hermes_descendant) + ) + assert tool_run_kwargs[-1]["cwd"] == str(hermes_descendant) + registry_raw = plugin.tools.call_tracedecay_tool( + "tracedecay_project_list", {"limit": 3}, cwd=str(host_home) + ) + assert tool_argv[-1][1:3] == ["projects", "list"], tool_argv[-1] + assert json.loads(json.loads(registry_raw)["content"][0]["text"]) == {} + plugin.tools.call_tracedecay_tool( + "tracedecay_project_context", {"project_id": "proj_test"}, cwd=str(host_home) + ) + assert tool_argv[-1][1:4] == ["projects", "context", "proj_test"] + active_context_raw = plugin.tools.call_tracedecay_tool( + "tracedecay_project_context", {}, cwd=str(hermes_descendant) + ) + assert "--project" in tool_argv[-1], (tool_argv[-1], active_context_raw) + assert tool_argv[-1][tool_argv[-1].index("--project") + 1] == str( + hermes_descendant + ) + assert "tracedecay_project_context" in tool_argv[-1], tool_argv[-1] + plugin.tools.call_tracedecay_tool( + "tracedecay_status", {"small": True}, cwd=str(hermes_descendant) + ) + assert tool_argv[-1][tool_argv[-1].index("--project") + 1] == str( + hermes_descendant + ) + for name, args in ( + ("tracedecay_fact_store", {"action": "list", "memory_scope": "user"}), + ("tracedecay_lcm_status", {"storage_scope": "user"}), + ( + "tracedecay_message_search", + {"query": "general chat", "storage_scope": "user"}, + ), + ): + plugin.tools.call_tracedecay_tool(name, args, cwd=str(hermes_descendant)) + assert "--project" not in tool_argv[-1], tool_argv[-1] + assert tool_run_kwargs[-1]["cwd"] == os.path.abspath(os.sep) + finally: + plugin.tools.subprocess.run = real_tools_run real_json = plugin.call_tracedecay_json try: + plugin.call_tracedecay_json = lambda *_args, **_kwargs: {"error": "offline"} + assert plugin._project_scope_resolution( + str(hermes_descendant), str(host_home) + ) == ("rejected", None) + def raise_resolution(*_args, **_kwargs): + raise RuntimeError("offline") + plugin.call_tracedecay_json = raise_resolution + assert plugin._project_scope_resolution( + str(hermes_descendant), str(host_home) + ) == ("rejected", None) plugin.call_tracedecay_json = lambda *_args, **_kwargs: { "project_root": str(registered_root) } assert plugin._resolved_project_scope(str(registered_child)) == str(registered_root) assert plugin._resolved_project_scope(str(unrelated_root)) is None + assert plugin._resolved_project_scope(str(host_home), str(host_home)) is None + plugin.call_tracedecay_json = lambda *_args, **_kwargs: { + "project_root": str(host_home) + } + assert plugin._resolved_project_scope( + str(hermes_descendant / "src"), str(host_home) + ) is None + plugin.call_tracedecay_json = lambda *_args, **_kwargs: { + "project_root": str(hermes_descendant) + } + assert plugin._resolved_project_scope( + str(hermes_descendant / "src"), str(host_home) + ) == str(hermes_descendant) finally: plugin.call_tracedecay_json = real_json - ok("registered project resolution accepts descendants but rejects siblings") + home_engine = plugin.TraceDecayContextEngine(hermes_home=str(host_home)) + home_engine.on_session_start(session_id="home-scope", cwd=str(host_home)) + assert home_engine.project_root is None + home_provider = plugin.TracedecayMemoryProvider() + home_provider.initialize( + session_id="home-scope", hermes_home=str(host_home), cwd=str(host_home) + ) + assert home_provider.project_root is None + ok("Hermes home is user scope while registered descendant repos remain projects") # ── 3. Registration split + provider dedup ────────────────────────── # The installer wrote memory.provider: tracedecay into the temp profile @@ -227,6 +330,10 @@ def run_checks(work: Path): # duplicates; transcript search has no provider twin and stays. ctx = StubCtx() plugin.register(ctx) + assert ctx.engine.hermes_home == str(host_home), ( + ctx.engine.hermes_home, + str(host_home), + ) assert ctx.skills == { "managed-test": managed_skill, "tracedecay": plugin_dir / "skills" / "tracedecay" / "SKILL.md", @@ -238,6 +345,70 @@ def run_checks(work: Path): assert "tracedecay_fact_store" not in ctx.tools, sorted(ctx.tools) assert "tracedecay_fact_feedback" not in ctx.tools, sorted(ctx.tools) assert "tracedecay_memory_status" not in ctx.tools, sorted(ctx.tools) + + custom_home = work / "custom-hermes-home" + custom_home.mkdir() + custom_descendant = custom_home / "repos" / "unregistered" + custom_descendant.mkdir(parents=True) + custom_registered = custom_home / "repos" / "registered" + custom_registered.mkdir() + custom_ctx = StubCtx() + custom_ctx.hermes_home = str(custom_home) + plugin.register(custom_ctx) + assert custom_ctx.engine.hermes_home == str(custom_home) + custom_ctx.engine.on_session_start(session_id="custom-home", cwd=str(custom_home)) + assert custom_ctx.engine.project_root is None + custom_ctx.provider.initialize(session_id="custom-home", cwd=str(custom_home)) + assert custom_ctx.provider.hermes_home == str(custom_home) + assert custom_ctx.provider.project_root is None + registered_argv = [] + real_tools_run = plugin.tools.subprocess.run + real_json = plugin.call_tracedecay_json + try: + plugin.tools.subprocess.run = lambda argv, **_kwargs: ( + registered_argv.append(argv) or ToolResult() + ) + for tool_name, tool_args in ( + ("tracedecay_search", {"query": "scope"}), + ("tracedecay_status", {}), + ("tracedecay_runtime", {}), + ): + result = custom_ctx.tools[tool_name]["handler"]( + tool_args, cwd=str(custom_home) + ) + assert "requires a registered project" in result, (tool_name, result) + assert registered_argv == [], registered_argv + custom_ctx.tools["tracedecay_project_search"]["handler"]( + {"query": "scope"}, cwd=str(custom_home) + ) + assert "--project" not in registered_argv[-1], registered_argv[-1] + assert registered_argv[-1][1:4] == ["projects", "search", "scope"] + before = len(registered_argv) + plugin.call_tracedecay_json = lambda *_args, **_kwargs: { + "project_root": str(custom_home) + } + result = custom_ctx.tools["tracedecay_search"]["handler"]( + {"query": "scope"}, cwd=str(custom_descendant) + ) + assert "requires a registered project" in result, result + assert len(registered_argv) == before, registered_argv[before:] + plugin.call_tracedecay_json = lambda *_args, **_kwargs: { + "project": {"project_root": str(custom_registered)} + } + custom_ctx.tools["tracedecay_search"]["handler"]( + { + "query": "scope", + "project_selector": {"project_path": str(custom_registered)}, + }, + cwd=str(custom_home), + ) + assert registered_argv[-1][registered_argv[-1].index("--project") + 1] == str( + custom_registered + ) + finally: + plugin.tools.subprocess.run = real_tools_run + plugin.call_tracedecay_json = real_json + ok("registered tools and providers honor a custom Hermes home") assert "tracedecay_lcm_compress" not in ctx.tools, sorted(ctx.tools) assert "tracedecay_lcm_preflight" not in ctx.tools, sorted(ctx.tools) # Context-engine native mirrors stay gated without the capability flag. @@ -289,16 +460,37 @@ class ForwardingCtx(StubCtx): notifications = [] real_run = plugin.subprocess.run real_thread = plugin.threading.Thread - class ImmediateThread: + real_resolution = plugin._project_scope_resolution + pending_threads = [] + resolver_calls = [] + class DeferredThread: def __init__(self, target, **_kwargs): self.target = target def start(self): - self.target() + pending_threads.append(self.target) try: - plugin.threading.Thread = ImmediateThread + plugin.threading.Thread = DeferredThread plugin.subprocess.run = lambda argv, **kwargs: notifications.append((argv, kwargs)) + def resolve_receipt(path, *_args): + resolver_calls.append(path) + if path and os.path.realpath(str(path)) == os.path.realpath(str(runtime_project)): + return "registered", str(runtime_project) + if path and os.path.realpath(str(path)) == os.path.realpath(str(hermes_descendant)): + return "rejected", None + return "unregistered", str(path) if path else None + plugin._project_scope_resolution = resolve_receipt assert receipt_hook(tool_name="web_search", cwd=str(runtime_project)) is None assert notifications == [] + assert receipt_hook(tool_name="terminal", cwd=str(host_home)) is None + assert notifications == [] + assert resolver_calls == [] + assert pending_threads == [] + assert receipt_hook(tool_name="terminal", cwd=str(hermes_descendant)) is None + assert resolver_calls == [] + assert len(pending_threads) == 1 + pending_threads.pop(0)() + assert resolver_calls == [str(hermes_descendant)] + assert notifications == [] assert receipt_hook( tool_name="terminal", args={"command": "secret output is deliberately absent"}, @@ -309,6 +501,10 @@ def start(self): status="success", duration_ms=9, ) is None + assert resolver_calls == [str(hermes_descendant)] + assert len(pending_threads) == 1 + pending_threads.pop(0)() + assert resolver_calls == [str(hermes_descendant), str(runtime_project)] assert len(notifications) == 1 argv, call = notifications[0] assert argv[-1] == "hook-hermes-terminal-receipt" @@ -319,6 +515,7 @@ def start(self): finally: plugin.subprocess.run = real_run plugin.threading.Thread = real_thread + plugin._project_scope_resolution = real_resolution ok("post_tool_call emits bounded asynchronous terminal receipts") real_block = plugin.tools.plugin_config_block @@ -391,14 +588,24 @@ def _handled_response(name, args, **kwargs): assert engine.context_length == 200_000 ok("context engine safely deep-copies per-agent budget state") - engine.initialize(session_id="project-session", project_root=str(runtime_project)) - assert engine.project_root == str(runtime_project) - engine.initialize(session_id="untethered-session", cwd=str(host_home)) - assert engine.project_root is None - engine.initialize(session_id="project-session") - assert engine.project_root == str(runtime_project) - engine.initialize(session_id="untethered-session") - assert engine.project_root is None + real_resolver = plugin._resolved_project_scope + try: + plugin._resolved_project_scope = lambda path, *_args: ( + str(runtime_project) + if path + and os.path.realpath(str(path)) == os.path.realpath(str(runtime_project)) + else None + ) + engine.initialize(session_id="project-session", project_root=str(runtime_project)) + assert engine.project_root == str(runtime_project) + engine.initialize(session_id="untethered-session", cwd=str(host_home)) + assert engine.project_root is None + engine.initialize(session_id="project-session") + assert engine.project_root == str(runtime_project) + engine.initialize(session_id="untethered-session") + assert engine.project_root is None + finally: + plugin._resolved_project_scope = real_resolver ok("context engine isolates project routing per Hermes session") messages = [ @@ -615,7 +822,7 @@ def call_llm(self, **kwargs): ok("untethered memory and LCM use profile-level user scope") real_resolver = plugin._resolved_project_scope - plugin._resolved_project_scope = lambda path: expected_project_root + plugin._resolved_project_scope = lambda path, *_args: expected_project_root provider.sync_turn( "project task", "done", diff --git a/src/agents/hermes/templates.rs b/src/agents/hermes/templates.rs index 07262e7f7..1bcef6bfe 100644 --- a/src/agents/hermes/templates.rs +++ b/src/agents/hermes/templates.rs @@ -107,7 +107,7 @@ _PLUGIN_CONFIG_CACHE = {{}} def hermes_home_dir(hermes_home=None): return str( hermes_home - or os.path.join(os.path.expanduser("~"), ".hermes") + or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) ) def plugin_config_block(hermes_home=None): @@ -141,10 +141,16 @@ def plugin_config_block(hermes_home=None): _PLUGIN_CONFIG_CACHE[path] = (cache_key, block) return block -def code_project_root(explicit=None, cwd=None): +def code_project_root(explicit=None, cwd=None, hermes_home=None): candidate = explicit or cwd or os.getcwd() if isinstance(candidate, str) and candidate.strip() and os.path.isabs(candidate): - return candidate.strip() + candidate = candidate.strip() + try: + if os.path.realpath(candidate) == os.path.realpath(hermes_home_dir(hermes_home)): + return None + except (OSError, TypeError, ValueError): + return None + return candidate return None def normalize_output(value) -> str: @@ -181,37 +187,86 @@ def call_tracedecay_tool(name: str, args: dict, **kwargs) -> str: if "messages" in kwargs and "messages" not in tool_args: tool_args = dict(tool_args) tool_args["messages"] = kwargs["messages"] + registry_argv = None + if name == "tracedecay_project_list": + registry_argv = [TRACEDECAY_BIN, "projects", "list"] + if tool_args.get("limit") is not None: + registry_argv.extend(["--limit", str(tool_args["limit"])]) + elif name == "tracedecay_project_search": + query = str(tool_args.get("query") or "").strip() + if not query: + return error_payload("tracedecay_project_search requires query") + registry_argv = [TRACEDECAY_BIN, "projects", "search", query] + if tool_args.get("limit") is not None: + registry_argv.extend(["--limit", str(tool_args["limit"])]) + elif name == "tracedecay_project_context": + project_selector = tool_args.get("project_selector") + nested_selector = project_selector if isinstance(project_selector, dict) else {{}} + selector = str( + tool_args.get("project_id") + or tool_args.get("project_path") + or tool_args.get("path") + or nested_selector.get("project_id") + or nested_selector.get("project_path") + or nested_selector.get("path") + or "" + ).strip() + if selector: + registry_argv = [TRACEDECAY_BIN, "projects", "context", selector] + if registry_argv is not None: + registry_argv.append("--json") # `project_root` is transport routing, never part of the MCP argument # object. Resolve it only from an explicit call or the real process / # session cwd; Hermes homes and profiles never select TraceDecay data. project_root = kwargs.get("project_root") - if not project_root: - project_root = code_project_root(cwd=kwargs.get("cwd") or tool_args.get("cwd")) + user_scoped = ( + tool_args.get("memory_scope") == "user" + or tool_args.get("storage_scope") == "user" + ) + if user_scoped: + project_root = None + elif not project_root: + project_root = code_project_root( + cwd=kwargs.get("cwd") or tool_args.get("cwd"), + hermes_home=kwargs.get("hermes_home"), + ) + else: + project_root = code_project_root( + explicit=project_root, + hermes_home=kwargs.get("hermes_home"), + ) payload = json.dumps(tool_args) - argv = [TRACEDECAY_BIN, "tool"] - if project_root: - argv.extend(["--project", str(project_root)]) - argv.extend([name, "--json", "--args"]) - if len(payload.encode("utf-8")) >= ARGS_FILE_THRESHOLD_BYTES: - fd, args_file = tempfile.mkstemp(prefix="tracedecay-args-", suffix=".json") - with os.fdopen(fd, "w", encoding="utf-8") as args_handle: - args_handle.write(payload) - argv.append("@" + args_file) + if registry_argv is not None: + argv = registry_argv else: - argv.append(payload) + argv = [TRACEDECAY_BIN, "tool"] + if project_root: + argv.extend(["--project", str(project_root)]) + argv.extend([name, "--json", "--args"]) + if len(payload.encode("utf-8")) >= ARGS_FILE_THRESHOLD_BYTES: + fd, args_file = tempfile.mkstemp(prefix="tracedecay-args-", suffix=".json") + with os.fdopen(fd, "w", encoding="utf-8") as args_handle: + args_handle.write(payload) + argv.append("@" + args_file) + else: + argv.append(payload) timeout_seconds = ( TRACEDECAY_LONG_TIMEOUT_SECONDS if name in LONG_RUNNING_TOOLS else TRACEDECAY_TIMEOUT_SECONDS ) - result = subprocess.run( - argv, - check=False, - capture_output=True, - text=True, - timeout=timeout_seconds, - shell=False, - ) + run_kwargs = {{ + "check": False, + "capture_output": True, + "text": True, + "timeout": timeout_seconds, + "shell": False, + }} + if registry_argv is not None: + run_kwargs["cwd"] = str(project_root) if project_root else os.path.abspath(os.sep) + elif not project_root: + run_kwargs["cwd"] = os.path.abspath(os.sep) + result = subprocess.run(argv, **run_kwargs) if result.returncode != 0: return error_payload(f"tracedecay tool exited with status {{result.returncode}}", result) output = result.stdout.strip() @@ -219,6 +274,8 @@ def call_tracedecay_tool(name: str, args: dict, **kwargs) -> str: return "{{}}" try: json.loads(output) + if registry_argv is not None: + return json.dumps({{"content": [{{"type": "text", "text": output}}]}}) return output except json.JSONDecodeError: return error_payload("tracedecay tool returned invalid JSON", result) @@ -233,8 +290,10 @@ def call_tracedecay_tool(name: str, args: dict, **kwargs) -> str: except OSError: pass -def make_handler(name: str): +def make_handler(name: str, hermes_home=None): def handler(args: dict, **kwargs) -> str: + if hermes_home and not kwargs.get("hermes_home"): + kwargs["hermes_home"] = hermes_home return call_tracedecay_tool(name, args, **kwargs) return handler "# diff --git a/src/agents/hermes/templates/plugin_init.py b/src/agents/hermes/templates/plugin_init.py index 25d2b45ec..11d2cc9a4 100644 --- a/src/agents/hermes/templates/plugin_init.py +++ b/src/agents/hermes/templates/plugin_init.py @@ -462,6 +462,22 @@ def _drain(): return queued = _HOST_RECEIPT_QUEUE.popleft() try: + candidate = queued.pop("_project_candidate", None) + hermes_home = queued.pop("_hermes_home", None) + trusted_project = queued.pop("_trusted_project", False) + if candidate: + route_state, resolved = _project_scope_resolution( + candidate, hermes_home + ) + if route_state == "unregistered" and trusted_project: + resolved = _code_project_root( + explicit=candidate, + hermes_home=hermes_home, + ) + if not resolved: + continue + queued["cwd"] = str(resolved) + queued["route"]["cwd"] = str(resolved) subprocess.run( [tools.TRACEDECAY_BIN, "hook-hermes-terminal-receipt"], input=json.dumps(queued), @@ -490,11 +506,12 @@ def _post_tool_call(*args, **kwargs): if tool_name not in _TERMINAL_TOOL_NAMES: return None tool_args = payload.get("args") if isinstance(payload.get("args"), dict) else {} - cwd = _code_project_root( + candidate = _code_project_root( explicit=payload.get("project_root") or payload.get("project_path"), cwd=payload.get("cwd") or tool_args.get("cwd") or tool_args.get("workdir"), + hermes_home=payload.get("hermes_home"), ) - if not cwd: + if not candidate: return None session_id = payload.get("session_id") or payload.get("thread_id") turn_id = payload.get("turn_id") @@ -508,11 +525,14 @@ def _post_tool_call(*args, **kwargs): event = { "agent": "hermes", "event": "terminalReceipt", - "cwd": str(cwd), + "_project_candidate": str(candidate), + "_hermes_home": payload.get("hermes_home"), + "_trusted_project": bool( + payload.get("project_root") or payload.get("project_path") + ), "route": { "session_id": str(session_id)[:256] if session_id else None, "thread_id": str(payload.get("thread_id"))[:256] if payload.get("thread_id") else None, - "cwd": str(cwd), }, "receipt": { "tool_call_id": str(tool_call_id)[:256] if tool_call_id else None, @@ -560,8 +580,10 @@ def _notify_turn_ingested(session_id, project_root, transcript_watermark): "tracedecay-turn-ingested", ) -def _tracedecay_status(raw_args: str = ""): - raw = tools.call_tracedecay_tool("tracedecay_status", {}) +def _tracedecay_status(raw_args: str = "", hermes_home=None): + raw = tools.call_tracedecay_tool( + "tracedecay_status", {}, hermes_home=hermes_home + ) try: payload = json.loads(json.loads(raw)["content"][0]["text"]) except Exception: @@ -838,33 +860,59 @@ def _lcm_store_args(args, project_root): routed.setdefault("storage_scope", "user") return routed -def _resolved_project_scope(project_root): +def _project_scope_resolution(project_root, hermes_home=None): if not project_root: - return None + return "unregistered", None + try: + if os.path.realpath(str(project_root)) == os.path.realpath( + _resolve_hermes_home(hermes_home=hermes_home) + ): + return "rejected", None + except (OSError, TypeError, ValueError): + return "unregistered", None + candidate = _code_project_root(explicit=project_root, hermes_home=hermes_home) + if not candidate: + return "unregistered", None + try: + home_real = os.path.realpath(_resolve_hermes_home(hermes_home=hermes_home)) + candidate_real = os.path.realpath(candidate) + inside_hermes_home = os.path.commonpath((home_real, candidate_real)) == home_real + except (OSError, TypeError, ValueError): + inside_hermes_home = False + unresolved_state = "rejected" if inside_hermes_home else "unregistered" + unresolved_root = None if inside_hermes_home else candidate + if not os.path.exists(candidate): + return unresolved_state, unresolved_root try: status = call_tracedecay_json( "tracedecay_active_project", {}, - **_project_call_kwargs(project_root), + **_project_call_kwargs(candidate), ) except Exception: - return None + return unresolved_state, unresolved_root if not isinstance(status, dict) or status.get("error"): - return None + return unresolved_state, unresolved_root resolved = status.get("project_root") or status.get("root") if not resolved: - return None + return unresolved_state, unresolved_root + if not _code_project_root(explicit=resolved, hermes_home=hermes_home): + return "rejected", None try: resolved_real = os.path.realpath(str(resolved)) - candidate_real = os.path.realpath(str(project_root)) + candidate_real = os.path.realpath(str(candidate)) if os.path.commonpath((resolved_real, candidate_real)) != resolved_real: - return None + return unresolved_state, unresolved_root except (OSError, TypeError, ValueError): - return None - return str(resolved) + return unresolved_state, unresolved_root + return "registered", str(resolved) + +def _resolved_project_scope(project_root, hermes_home=None): + state, resolved = _project_scope_resolution(project_root, hermes_home) + return resolved if state == "registered" else None -def _project_scope_available(project_root) -> bool: - return _resolved_project_scope(project_root) is not None +def _project_scope_available(project_root, hermes_home=None) -> bool: + return _resolved_project_scope(project_root, hermes_home) is not None def _decoded_tool_arguments(call): if not isinstance(call, dict): @@ -910,7 +958,7 @@ def _tool_project_candidates(messages): for candidate in ( arguments.get("project_root"), arguments.get("project_path"), - selector.get("path") if isinstance(selector, dict) else None, + _project_selector_path(selector), arguments.get("cwd"), arguments.get("workdir"), ): @@ -920,16 +968,149 @@ def _tool_project_candidates(messages): candidates.extend(_terminal_cd_candidates(arguments.get("command") or arguments.get("cmd"))) return candidates -def _turn_project_root(messages): +def _turn_project_root(messages, hermes_home=None): for candidate in reversed(_tool_project_candidates(messages)): expanded = os.path.abspath(os.path.expanduser(candidate)) if os.path.isfile(expanded): expanded = os.path.dirname(expanded) - resolved = _resolved_project_scope(expanded) + resolved = _resolved_project_scope(expanded, hermes_home) if resolved: return resolved return None +_UNSCOPED_DIRECT_TOOLS = frozenset(( + "tracedecay_project_list", + "tracedecay_project_search", +)) + +_READ_ONLY_SELECTOR_TOOLS = frozenset(( + "tracedecay_search", + "tracedecay_grep", + "tracedecay_context", + "tracedecay_retrieve", + "tracedecay_callers", + "tracedecay_callees", + "tracedecay_impact", + "tracedecay_node", + "tracedecay_files", + "tracedecay_body", + "tracedecay_read", + "tracedecay_outline", + "tracedecay_signature_search", + "tracedecay_implementations", + "tracedecay_callers_for", + "tracedecay_call_chain", + "tracedecay_file_dependents", + "tracedecay_find_exact_symbol", + "tracedecay_by_qualified_name", + "tracedecay_signature", + "tracedecay_impls", + "tracedecay_derives", + "tracedecay_project_context", + "tracedecay_memory_status", + "tracedecay_message_search", + "tracedecay_analytics", +)) + +def _project_selector_path(selector): + if not isinstance(selector, dict): + return None + return selector.get("path") or selector.get("project_path") + +def _read_only_selector_call(name, args): + if name in _READ_ONLY_SELECTOR_TOOLS: + return True + return name == "tracedecay_fact_store" and str(args.get("action") or "") in ( + "search", "probe", "related", "reason", "contradict", "list" + ) + +def _make_project_safe_handler(name, handler, hermes_home): + def safe_handler(args, **kwargs): + tool_args = dict(args or {}) + selector = tool_args.get("project_selector") + selector_path = _project_selector_path(selector) + explicit_selector = bool( + tool_args.get("project_id") + or tool_args.get("project_path") + or selector_path + or (isinstance(selector, dict) and selector.get("project_id")) + ) + if explicit_selector and not _read_only_selector_call(name, tool_args): + return tools.error_payload( + f"{name} does not permit a cross-project mutating selector" + ) + if explicit_selector and name == "tracedecay_project_context": + return handler(tool_args, hermes_home=hermes_home) + candidate = ( + kwargs.get("project_root") + or kwargs.get("cwd") + or tool_args.get("project_root") + or tool_args.get("project_path") + or selector_path + or _runtime_working_directory() + ) + if explicit_selector: + context_args = {} + selector_id = tool_args.get("project_id") or ( + selector.get("project_id") if isinstance(selector, dict) else None + ) + if selector_id: + context_args["project_id"] = selector_id + else: + context_args["path"] = tool_args.get("project_path") or selector_path + context = call_tracedecay_json( + "tracedecay_project_context", + context_args, + hermes_home=hermes_home, + ) + project = context.get("project") if isinstance(context, dict) else None + resolved = ( + project.get("project_root") or project.get("canonical_root") + if isinstance(project, dict) + else None + ) + if resolved and not _code_project_root( + explicit=resolved, hermes_home=hermes_home + ): + resolved = None + else: + route_state, resolved = _project_scope_resolution(candidate, hermes_home) + if route_state == "unregistered" and kwargs.get("project_root"): + resolved = _code_project_root( + explicit=kwargs.get("project_root"), + hermes_home=hermes_home, + ) + if explicit_selector and not resolved: + return tools.error_payload( + f"{name} project selector did not resolve to a registered non-Hermes project" + ) + routed_kwargs = dict(kwargs) + routed_kwargs["hermes_home"] = hermes_home + if resolved: + routed_kwargs["project_root"] = resolved + return handler(tool_args, **routed_kwargs) + routed_kwargs.pop("project_root", None) + routed_kwargs.pop("cwd", None) + if name in _UNSCOPED_DIRECT_TOOLS: + return handler(tool_args, **routed_kwargs) + if name.startswith("tracedecay_lcm_"): + tool_args.setdefault("storage_scope", "user") + return handler(tool_args, **routed_kwargs) + if name in ( + "tracedecay_fact_store", + "tracedecay_fact_feedback", + "tracedecay_memory_status", + ): + tool_args.setdefault("memory_scope", "user") + return handler(tool_args, **routed_kwargs) + if name == "tracedecay_message_search": + tool_args.setdefault("storage_scope", "user") + return handler(tool_args, **routed_kwargs) + return tools.error_payload( + f"{name} requires a registered project; Hermes home and unregistered workspaces use user scope" + ) + return safe_handler + # Conventional config home: a `plugins.tracedecay` block in the profile # config.yaml (the same `plugins.` convention bundled Hermes plugins # use). Keys are flat and mirror the host-config attribute names the @@ -1082,18 +1263,28 @@ def _runtime_working_directory(): return candidate return os.getcwd() -def _code_project_root(explicit=None, cwd=None, configured=None): - if explicit: - return str(explicit) - candidate = cwd or configured or _runtime_working_directory() +def _code_project_root(explicit=None, cwd=None, configured=None, hermes_home=None): + candidate = explicit or cwd or configured or _runtime_working_directory() if isinstance(candidate, str) and candidate.strip() and os.path.isabs(candidate): - return candidate.strip() + candidate = candidate.strip() + try: + if os.path.realpath(candidate) == os.path.realpath( + _resolve_hermes_home(hermes_home=hermes_home) + ): + return None + except (OSError, TypeError, ValueError): + return None + return candidate return None def _resolve_hermes_home(config=None, hermes_home=None): for candidate in ( hermes_home, _configured_hermes_home(config), + # The generated package physically belongs to /plugins. + # Prefer that stable owner over process-global helpers and inherited + # environment values when no explicit/configured home was supplied. + tools.hermes_home_dir(), ): if candidate: return str(candidate) @@ -1104,8 +1295,7 @@ def _resolve_hermes_home(config=None, hermes_home=None): return str(resolved) except Exception: pass - fallback = os.path.expanduser("~/.hermes") - return fallback or None + return str(tools.hermes_home_dir()) def _configured_value(config, *names, default=None): if config is None: @@ -2342,7 +2532,10 @@ def __init__(self, config=None, hermes_home=None): self._host_config = config self.hermes_home = _resolve_hermes_home(config, hermes_home) self.config = _with_plugin_block(config, self.hermes_home) - self.project_root = _configured_project_root(self.config) + self.project_root = _resolved_project_scope( + _configured_project_root(self.config), + self.hermes_home, + ) # Auxiliary-route circuit breakers are process-global on purpose: # a broken summary model is broken for every session. self._route_failures = {} @@ -2459,15 +2652,39 @@ def _bind_session(self, session_id=None, hermes_home=None, project_root=None, ** configured_project_root = _configured_project_root(self.config) routing_supplied = bool(explicit_project_root or runtime_cwd) if explicit_project_root: - next_project_root = str(explicit_project_root) + route_state, next_project_root = _project_scope_resolution( + explicit_project_root, + self.hermes_home, + ) + if route_state == "unregistered": + next_project_root = _code_project_root( + explicit=explicit_project_root, + hermes_home=self.hermes_home, + ) elif runtime_cwd: - next_project_root = _resolved_project_scope(str(runtime_cwd)) + next_project_root = _resolved_project_scope(runtime_cwd, self.hermes_home) elif self.project_root: - next_project_root = self.project_root + route_state, next_project_root = _project_scope_resolution( + self.project_root, + self.hermes_home, + ) + if route_state == "unregistered": + next_project_root = _code_project_root( + explicit=self.project_root, + hermes_home=self.hermes_home, + ) + routing_supplied = routing_supplied or next_project_root is None elif configured_project_root: - next_project_root = str(configured_project_root) + next_project_root = _resolved_project_scope( + configured_project_root, + self.hermes_home, + ) + routing_supplied = True elif session_id is not None and not self.project_root: - next_project_root = _resolved_project_scope(_runtime_working_directory()) + next_project_root = _resolved_project_scope( + _runtime_working_directory(), + self.hermes_home, + ) else: next_project_root = None if next_project_root: @@ -3414,6 +3631,7 @@ class TracedecayMemoryProvider(MemoryProvider): def __init__(self): self.hermes_home = None + self._registered_hermes_home = None self.project_root = None self.session_id = None self.agent_context = "" @@ -3429,14 +3647,28 @@ def is_available(self) -> bool: return _tracedecay_binary_available() def initialize(self, session_id=None, **kwargs): - self.hermes_home = kwargs.get("hermes_home") or _resolve_hermes_home() + self.hermes_home = ( + kwargs.get("hermes_home") + or self._registered_hermes_home + or _resolve_hermes_home() + ) config = _with_plugin_block(kwargs.get("config"), self.hermes_home) + explicit_project_root = kwargs.get("project_root") candidate_root = _code_project_root( - explicit=kwargs.get("project_root"), + explicit=explicit_project_root, cwd=kwargs.get("cwd"), configured=_configured_project_root(config), + hermes_home=self.hermes_home, + ) + route_state, resolved_root = _project_scope_resolution( + candidate_root, self.hermes_home ) - self.project_root = candidate_root if _project_scope_available(candidate_root) else None + if route_state == "registered": + self.project_root = resolved_root + elif route_state == "unregistered" and explicit_project_root: + self.project_root = candidate_root + else: + self.project_root = None self.session_id = session_id # Execution context ("", "cron", "flush", ...): cron/flush runs are # not primary conversations and must not write turn state (cron @@ -3459,8 +3691,9 @@ def post_setup(self, hermes_home, config): return home = str(hermes_home or self.hermes_home or _resolve_hermes_home()) resolved_config = _with_plugin_block(config, home) - project_root = self.project_root or _code_project_root( - configured=_configured_project_root(resolved_config) + project_root = _resolved_project_scope( + self.project_root or _configured_project_root(resolved_config), + home, ) status = call_tracedecay_json( "tracedecay_memory_status", @@ -3583,7 +3816,7 @@ def sync_turn(self, user_content, assistant_content, *, session_id="", messages= same content-cursored path the context engine uses), so the raw store grows every turn instead of only when compression fires. """ - project_root = _turn_project_root(messages) or self.project_root + project_root = _turn_project_root(messages, self.hermes_home) or self.project_root if not _plugin_toggle("sync_turn", True): return if self.agent_context in ("cron", "flush"): @@ -3768,9 +4001,26 @@ def handle_tool_call(self, name, arguments=None, **kwargs) -> str: def register(ctx): global _HOST_FORWARDS_MESSAGES + context_config = getattr(ctx, "config", None) + explicit_context_home = ( + getattr(ctx, "hermes_home", None) or getattr(ctx, "_hermes_home", None) + ) + context_hermes_home = _resolve_hermes_home( + None, + explicit_context_home + or _configured_hermes_home(context_config) + or tools.hermes_home_dir(), + ) + + def bind_hermes_home(handler): + def bound(*args, **kwargs): + kwargs.setdefault("hermes_home", context_hermes_home) + return handler(*args, **kwargs) + return bound + ctx.register_hook("pre_llm_call", _pre_llm_call) try: - ctx.register_hook("post_tool_call", _post_tool_call) + ctx.register_hook("post_tool_call", bind_hermes_home(_post_tool_call)) except Exception as exc: logger.debug("tracedecay post_tool_call hook unavailable: %s", exc) # Declare the plugins.tracedecay config block so its keys exist in @@ -3801,18 +4051,16 @@ def register(ctx): if callable(register_command): register_command( "/tracedecay_status", - _tracedecay_status, + bind_hermes_home(_tracedecay_status), description="Show tracedecay project status.", ) if callable(getattr(ctx, "register_memory_provider", None)): - ctx.register_memory_provider(TracedecayMemoryProvider()) + memory_provider = TracedecayMemoryProvider() + memory_provider._registered_hermes_home = context_hermes_home + memory_provider.hermes_home = context_hermes_home + ctx.register_memory_provider(memory_provider) - context_config = getattr(ctx, "config", None) - context_hermes_home = ( - getattr(ctx, "hermes_home", None) - or getattr(ctx, "_hermes_home", None) - ) context_engine = TraceDecayContextEngine( config=context_config, hermes_home=context_hermes_home, @@ -3845,7 +4093,16 @@ def register(ctx): # fact_store/fact_feedback/memory_status — registering the # prefixed twins would double the schema footprint. continue - handler = _handle_lcm_expand_query if name == "tracedecay_lcm_expand_query" else tools.make_handler(name) + raw_handler = ( + _handle_lcm_expand_query + if name == "tracedecay_lcm_expand_query" + else tools.make_handler(name, hermes_home=context_hermes_home) + ) + handler = _make_project_safe_handler( + name, + raw_handler, + context_hermes_home, + ) visible_schema = _agent_visible_schema(schema) try: register_tool( diff --git a/src/mcp/tools/handlers/mod.rs b/src/mcp/tools/handlers/mod.rs index 1c4fc044f..bf04c5973 100644 --- a/src/mcp/tools/handlers/mod.rs +++ b/src/mcp/tools/handlers/mod.rs @@ -47,16 +47,22 @@ pub async fn handle_user_lcm_tool( "project_id", "project_path", "project_root", + "project_scope", "project_selector", ] .iter() .any(|key| args.get(*key).is_some()) { return Err(TraceDecayError::Config { - message: "storage_scope=user cannot be combined with a project selector".to_string(), + message: + "storage_scope=user cannot be combined with a project selector or project_scope" + .to_string(), }); } let sessions_db_path = crate::sessions::user_sessions_db_path(profile_root); + if tool_name == "tracedecay_message_search" { + return session::handle_user_message_search(&sessions_db_path, args).await; + } let context = session::LcmHandlerContext::user(&sessions_db_path); match tool_name { "tracedecay_lcm_status" => session::handle_lcm_status(context, args).await, diff --git a/src/mcp/tools/handlers/session.rs b/src/mcp/tools/handlers/session.rs index 286d32350..ae8de0d87 100644 --- a/src/mcp/tools/handlers/session.rs +++ b/src/mcp/tools/handlers/session.rs @@ -2403,6 +2403,35 @@ pub(super) async fn handle_message_search( )) } +pub(super) async fn handle_user_message_search( + sessions_db_path: &Path, + args: Value, +) -> Result { + let request = parse_message_search_request(&args)?; + let Some(db) = GlobalDb::open_read_only_at(sessions_db_path).await else { + return Ok(tool_json( + None, + &args, + &json!({ + "status": "unavailable", + "message": "could not open user tracedecay session database", + "results": [], + "count": 0 + }), + )); + }; + let results = if request.goals { + db.recent_session_goals(request.project_key, request.limit) + .await + } else { + search_session_messages_in_db(&db, &request).await + }; + let payload = message_search_payload(&request, &results, false); + Ok(tool_json_with_md(None, &args, &payload, || { + render_message_search_md(&payload) + })) +} + pub(super) async fn handle_lcm_status( context: LcmHandlerContext<'_>, args: Value, diff --git a/src/tool_command.rs b/src/tool_command.rs index 7b13faae0..bfb5fa275 100644 --- a/src/tool_command.rs +++ b/src/tool_command.rs @@ -160,7 +160,7 @@ fn user_memory_dispatch(tool_name: &str, args: &Value) -> bool { } fn user_lcm_dispatch(tool_name: &str, args: &Value) -> bool { - tool_name.starts_with("tracedecay_lcm_") + (tool_name.starts_with("tracedecay_lcm_") || tool_name == "tracedecay_message_search") && args.get("storage_scope").and_then(Value::as_str) == Some("user") } diff --git a/tests/agent_suite/agent_test.rs b/tests/agent_suite/agent_test.rs index 3d2284a27..aeac6cc60 100644 --- a/tests/agent_suite/agent_test.rs +++ b/tests/agent_suite/agent_test.rs @@ -1268,7 +1268,7 @@ fn test_hermes_plugin_init_snapshot_matches_embedded_asset() { hasher.update(body.as_bytes()); assert_eq!( hex::encode(hasher.finalize()), - "88db26663abdb759748ce77900bddf2746a97ba73635f2a69a909ed1a9d008be", + "89bb095bb94827724521b5fb57238411fbd875c9b6eab45ffe4835f5a42ba9ad", "templates/plugin_init.py payload hash changed — verify the edit is intentional and update this snapshot" ); } @@ -6337,8 +6337,13 @@ assert "--project" in argv, argv assert argv[argv.index("--project") + 1] == str(healthy_cwd), argv # The context engine filters the legacy pin but layers host-behavior settings. -plugin._resolved_project_scope = lambda path: ( - str(healthy_cwd) if os.path.realpath(str(path)) == os.path.realpath(str(healthy_cwd)) else None +plugin._resolved_project_scope = lambda path, *_args: ( + str(path) + if path and ( + os.path.realpath(str(path)) == os.path.realpath(str(healthy_cwd)) + or str(path) == "/host/wins" + ) + else None ) engine = plugin.TraceDecayContextEngine() assert engine.project_root is None, engine.project_root diff --git a/tests/hermes_suite/lcm_bridge.rs b/tests/hermes_suite/lcm_bridge.rs index 83614f8b5..22220905d 100644 --- a/tests/hermes_suite/lcm_bridge.rs +++ b/tests/hermes_suite/lcm_bridge.rs @@ -299,6 +299,7 @@ fn generated_context_engine_exposes_native_lcm_surface_and_dispatch() { r#" import json +plugin._resolved_project_scope = lambda path, *_args: path engine = plugin.TraceDecayContextEngine() engine.initialize(session_id="session-1", project_root="/tmp/project") @@ -603,11 +604,15 @@ def fake_call_tracedecay_tool(name, args, **kwargs): return json.dumps({"content": [{"type": "text", "text": json.dumps({"status": "ok"})}]}) plugin.tools.call_tracedecay_tool = fake_call_tracedecay_tool -plugin._resolved_project_scope = lambda path: None +plugin._resolved_project_scope = lambda path, *_args: ( + str(path) if path and str(path) == "/tmp/project" else None +) engine = plugin.TraceDecayContextEngine() engine.initialize(session_id="session-1") -assert engine.hermes_home == str(plugin_dir.parent.parent) +assert os.path.normcase(os.path.realpath(engine.hermes_home)) == os.path.normcase( + os.path.realpath(str(plugin_dir.parent.parent)) +) status = engine.get_status() assert "storage_scope" not in status assert "hermes_home" not in status @@ -872,7 +877,7 @@ assert "extraction backend unavailable" in payload["pre_compaction_extraction"][ } #[test] -fn generated_context_engine_home_default_is_host_config_only() { +fn generated_context_engine_home_default_uses_installed_profile() { run_generated_plugin_script( "check_context_engine_default_home.py", r#" @@ -887,25 +892,22 @@ with tempfile.TemporaryDirectory() as tmp: # expanduser reads HOME on POSIX and USERPROFILE on Windows. os.environ["HOME"] = str(home) os.environ["USERPROFILE"] = str(home) - expected = str(home / ".hermes") - assert not pathlib.Path(expected).exists() + expected = str(plugin_dir.parent.parent) engine = plugin.TraceDecayContextEngine() engine.initialize(session_id="session-1") def normalized(path): - # Windows expanduser("~/.hermes") emits mixed separators for the same - # location; normalize separators only there so Unix stays byte-exact. - return os.path.normpath(path) if os.name == "nt" else path + return os.path.normcase(os.path.realpath(path)) assert normalized(engine.hermes_home) == normalized(expected), engine.hermes_home status = engine.get_status() assert "storage_scope" not in status assert "hermes_home" not in status assert "lcm_project_root" not in status - assert normalized(status["project_root"]) != normalized(expected), status + assert status["project_root"] is None, status "#, - "Hermes home defaults may configure the host but never TraceDecay storage", + "Hermes home defaults to the installed profile but never TraceDecay storage", ); } @@ -1066,6 +1068,7 @@ engine = ctx.context_engines[0] assert isinstance(engine, plugin.TraceDecayContextEngine) assert isinstance(engine, ContextEngine) +plugin._resolved_project_scope = lambda path, *_args: path engine.initialize( session_id="session-123", hermes_home="/tmp/hermes-profile", @@ -1088,7 +1091,9 @@ def fake_call_tracedecay_tool(name, args, **kwargs): return "{}" plugin.tools.call_tracedecay_tool = fake_call_tracedecay_tool -plugin._resolved_project_scope = lambda path: None +plugin._resolved_project_scope = lambda path, *_args: ( + str(path) if path and str(path) == "/tmp/project" else None +) profile_engine = plugin.TraceDecayContextEngine() profile_engine.on_session_start(session_id="session-1", hermes_home="/tmp/hermes") @@ -1580,8 +1585,11 @@ class Result: def mcp_response(inner): return json.dumps({"content": [{"type": "text", "text": json.dumps(inner)}]}) -def fake_run(argv, check, capture_output, text, timeout, shell): +run_cwds = [] + +def fake_run(argv, check, capture_output, text, timeout, shell, cwd=None): calls.append(argv) + run_cwds.append(cwd) tool_name = argv[4] if "--project" in argv else argv[2] if tool_name == "tracedecay_lcm_expand_query": inner = { @@ -1614,12 +1622,14 @@ profile_result = profile_engine._preflight_probe(messages=[], current_tokens=100 assert profile_result["status"] == "ok" assert profile_engine.should_compress_preflight([], current_tokens=100) is False profile_argv = calls.pop() +profile_cwd = run_cwds.pop() assert profile_argv[0] == plugin.tools.TRACEDECAY_BIN -assert profile_argv[1:3] == ["tool", "--project"] -assert profile_argv[3] == os.getcwd() -assert profile_argv[3] != "/tmp/hermes-profile" +assert profile_argv[1:3] == ["tool", "tracedecay_lcm_preflight"] +assert "--project" not in profile_argv +assert profile_cwd == os.path.abspath(os.sep) profile_args = json.loads(profile_argv[profile_argv.index("--args") + 1]) assert "project_root" not in profile_args +assert profile_args["storage_scope"] == "user" explicit = plugin.tools.call_tracedecay_tool( "tracedecay_lcm_status", @@ -1633,7 +1643,7 @@ explicit_args = json.loads(explicit_argv[explicit_argv.index("--args") + 1]) assert explicit_args["storage_scope"] == "hermes_profile" assert explicit_args["hermes_home"] == "/tmp/hermes-profile" "#, - "generated bridge must route only by real projects and leave stale args for strict CLI rejection", + "generated bridge must route only by real projects and isolate explicit user scope", ); } @@ -2160,7 +2170,7 @@ def fake_call_tracedecay_tool(name, args, **kwargs): return '{"content":[{"type":"text","text":"{\\"status\\":\\"ok\\"}"}]}' plugin.tools.call_tracedecay_tool = fake_call_tracedecay_tool -plugin._project_scope_available = lambda root: True +plugin._project_scope_available = lambda root, *_args: True provider = plugin.TracedecayMemoryProvider() provider.initialize(session_id="session-1", hermes_home="/tmp/hermes", project_root="/tmp/project") @@ -2194,9 +2204,10 @@ assert empty_list_first_messages[1]["id"] != empty_list_second_messages[1]["id"] fallback = plugin.TracedecayMemoryProvider() fallback.initialize(session_id="session-2", hermes_home="/tmp/hermes") fallback.sync_turn("user", "assistant", session_id="session-2") -assert fallback.project_root == os.getcwd() +assert fallback.project_root is None assert "project_root" not in calls[-1][1] -assert calls[-1][2]["project_root"] == os.getcwd() +assert calls[-1][1]["storage_scope"] == "user" +assert "project_root" not in calls[-1][2] "#, "sync_turn fallback messages should not collapse repeated identical turns", ); @@ -4375,9 +4386,9 @@ class Ctx: self.context_engines.append(engine) ctx = Ctx() +plugin._resolved_project_scope = lambda path, *_args: path plugin.register(ctx) engine = ctx.context_engines[0] -plugin._resolved_project_scope = lambda path: path # Host runtime config applies at registration time. assert engine.project_root == "/tmp/pinned-project", engine.project_root @@ -4508,7 +4519,7 @@ assert payload["project_root"] == "/tmp/hermes-profile", payload # Engine resolution never consults the legacy profile pin. engine = plugin.TraceDecayContextEngine(config={}) assert engine.project_root is None, engine.project_root -plugin._resolved_project_scope = lambda path: path +plugin._resolved_project_scope = lambda path, *_args: path engine.on_session_start(session_id="s1", cwd="/somewhere/else") assert engine.project_root == "/somewhere/else", engine.project_root diff --git a/tests/mcp_suite/mcp_handler_test.rs b/tests/mcp_suite/mcp_handler_test.rs index 3d06d1e4f..8b1650794 100644 --- a/tests/mcp_suite/mcp_handler_test.rs +++ b/tests/mcp_suite/mcp_handler_test.rs @@ -10574,6 +10574,27 @@ async fn user_scoped_lcm_preflight_ingests_without_a_project() { .await .unwrap(); assert_eq!(session.project_key, "user"); + + let search = tracedecay::mcp::tools::handle_user_lcm_tool( + "tracedecay_message_search", + json!({ + "storage_scope": "user", + "provider": "hermes", + "query": "general preference", + "catch_up": false, + "format": "json" + }), + profile.path(), + ) + .await + .unwrap(); + let search_payload: Value = serde_json::from_str(extract_text(&search.value)).unwrap(); + assert_eq!(search_payload["status"], "ok"); + assert_eq!(search_payload["count"], 1); + assert_eq!( + search_payload["results"][0]["session"]["project_key"], + "user" + ); } #[tokio::test]