Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Bug Fixes

- **Explicit tunnel reads and deletes now stay scoped to the selected palace.** `create_tunnel(..., config=...)` already wrote to that palace's `tunnels.json`, but `follow_tunnels` silently read the ambient default while `list_tunnels` and `delete_tunnel` could not accept the config at all. In multi-palace callers this made a successful write appear missing and could delete a same-ID tunnel from another palace. All tunnel helpers and their MCP handlers now propagate one canonical config through load, lock, and save. (#2263)
- **`sweep` books a failed `stat` as a failure again.** The non-regular-file gate added in 3.7.1 reads `stat.S_ISREG(f.stat().st_mode)` inside a `try`, and its `except OSError` printed `SKIP` and moved on. A dangling symlink, a symlink loop and a file unlinked between `rglob` and the gate all raise there, and before the gate existed every one of them reached `sweep()` and was booked in `failures` — so `sweep` went from reporting a transcript it could not read to reporting success. A failed probe is now an error, not a benign file type: it is logged, printed as `WARNING`, and appended to `failures`, while a probe that succeeds and says "not regular" still skips silently. (#2221)
- **`mempalace init` no longer tracebacks on a directory it cannot enter.** `_parse_gradle`'s `is_file()` gate sat in front of the `try` that the parser's own `except OSError` provides, so a manifest under a directory with `r` but no `x` raised `PermissionError` out of a call that used to answer "no manifest name". The gate moved inside that `try`, and `_collect_manifest_names` stats through `os.path.isfile`, which reports rather than raises. (#2221)
- **`split` no longer blocks on a FIFO at its own output name, nor writes through a broken link.** The type gate in `main` covers the files the glob listed; `split_file` builds its output names itself, so a pre-existing named pipe at one of them wedged `write_text` in the kernel waiting for a reader. The check asks about the link itself rather than its target, because a dangling symlink at an output name reads as "nothing there" and `write_text` would create the target — landing a chunk outside the output directory. Output names that are anything but a regular file are now skipped with a `SKIP` line. (#2221)
Expand Down
7 changes: 4 additions & 3 deletions mempalace/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2583,6 +2583,7 @@ def tool_create_tunnel(
label=label,
source_drawer_id=source_drawer_id,
target_drawer_id=target_drawer_id,
config=_config,
)
except ValueError as e:
return {"error": str(e)}
Expand All @@ -2594,14 +2595,14 @@ def tool_list_tunnels(wing: str = None):
wing = _sanitize_optional_name(wing, "wing")
except ValueError as e:
return {"error": str(e)}
return list_tunnels(wing)
return list_tunnels(wing, config=_config)


def tool_delete_tunnel(tunnel_id: str):
"""Delete an explicit tunnel by its ID."""
if not tunnel_id or not isinstance(tunnel_id, str):
return {"error": "tunnel_id is required"}
return delete_tunnel(tunnel_id)
return delete_tunnel(tunnel_id, config=_config)


def tool_list_hallways(wing: str = None):
Expand Down Expand Up @@ -2630,7 +2631,7 @@ def tool_follow_tunnels(wing: str, room: str):
col = _get_collection()
if not col:
return _collection_error_or_no_palace()
return follow_tunnels(wing, room, col=col)
return follow_tunnels(wing, room, col=col, config=_config)


# ==================== WRITE TOOLS ====================
Expand Down
29 changes: 20 additions & 9 deletions mempalace/palace_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,14 +608,19 @@ def create_tunnel(
return tunnel


def list_tunnels(wing: str = None):
def list_tunnels(wing: str = None, config=None):
"""List all explicit tunnels, optionally filtered by wing.

Returns tunnels where ``wing`` appears as either source or target
(tunnels are symmetric, so either endpoint is a valid filter match).

Args:
wing: Optional source or target wing filter.
config: Optional ``MempalaceConfig`` selecting the palace tunnel
sidecar. Explicit-path callers must pass the matching config.
"""
norm_wing = _normalize_wing(wing)
tunnels = _load_tunnels()
tunnels = _load_tunnels(config)
if norm_wing:
# Normalize stored wings too: older tunnels.json records hold the
# underscore form (from the prior write-path normalization), while
Expand All @@ -632,28 +637,34 @@ def list_tunnels(wing: str = None):
return tunnels


def delete_tunnel(tunnel_id: str):
"""Delete an explicit tunnel by ID. Returns ``{"deleted": <id>}``."""
with mine_lock(_get_tunnel_file()):
tunnels = _load_tunnels()
def delete_tunnel(tunnel_id: str, config=None):
"""Delete an explicit tunnel by ID from the selected palace sidecar.

Returns ``{"deleted": <id>}``.
"""
config = config or MempalaceConfig()
with mine_lock(_get_tunnel_file(config)):
tunnels = _load_tunnels(config)
tunnels = [t for t in tunnels if t.get("id") != tunnel_id]
_save_tunnels(tunnels)
_save_tunnels(tunnels, config)
return {"deleted": tunnel_id}


def follow_tunnels(wing: str, room: str, col=None, config=None):
"""Follow explicit tunnels from a room — returns connected drawers.

Given a location (wing/room), finds all tunnels leading from or to it,
and optionally fetches the connected drawer content.
and optionally fetches the connected drawer content. ``config`` selects
the palace tunnel sidecar; explicit-path callers must pass the matching
config.
"""
# Fall back to raw ``wing`` so an empty/whitespace query string still
# produces a value to compare with; ``_normalize_wing`` returns ``None``
# for empty input. Stored wings are normalized on the read path so the
# mempalace.yaml slug (underscore) and an explicit ``--wing`` slug
# (verbatim) both resolve through the same comparison.
norm_wing = _normalize_wing(wing) or wing
tunnels = _load_tunnels()
tunnels = _load_tunnels(config)
connections = []

for t in tunnels:
Expand Down
44 changes: 44 additions & 0 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2797,6 +2797,50 @@ def _raise(*args, **kwargs):

assert result == {"error": msg}

def test_tunnel_tools_forward_server_config(self, monkeypatch):
"""Every tunnel handler must stay scoped to the MCP server's palace."""
from mempalace import mcp_server

config = object()
collection = object()
seen = {}

monkeypatch.setattr(mcp_server, "_config", config)
monkeypatch.setattr(mcp_server, "_get_collection", lambda: collection)

def fake_create(*args, config=None, **kwargs):
seen["create"] = config
return {"id": "tunnel_1"}

def fake_list(*args, config=None, **kwargs):
seen["list"] = config
return []

def fake_delete(*args, config=None, **kwargs):
seen["delete"] = config
return {"deleted": "tunnel_1"}

def fake_follow(*args, config=None, **kwargs):
seen["follow"] = config
return []

monkeypatch.setattr(mcp_server, "create_tunnel", fake_create)
monkeypatch.setattr(mcp_server, "list_tunnels", fake_list)
monkeypatch.setattr(mcp_server, "delete_tunnel", fake_delete)
monkeypatch.setattr(mcp_server, "follow_tunnels", fake_follow)

mcp_server.tool_create_tunnel("wing_a", "room_a", "wing_b", "room_b")
mcp_server.tool_list_tunnels("wing_a")
mcp_server.tool_delete_tunnel("tunnel_1")
mcp_server.tool_follow_tunnels("wing_a", "room_a")

assert seen == {
"create": config,
"list": config,
"delete": config,
"follow": config,
}

# ── hallway MCP tools (mirror the tunnel pattern) ──

def _seed_hallways(self, monkeypatch, tmp_path):
Expand Down
56 changes: 56 additions & 0 deletions tests/test_palace_graph_tunnels.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,62 @@ def test_no_legacy_warning_when_paths_match(self, tmp_path, monkeypatch, caplog)

assert "Legacy tunnels file" not in caplog.text

@staticmethod
def _seed_two_palaces(tmp_path, monkeypatch):
"""Create the same tunnel ID in two isolated sidecars.

Palace B is the ambient fallback. Explicit ``config=A`` calls must
never read or mutate B, even though both files contain the same ID.
"""
from mempalace.config import MempalaceConfig

config_a = MempalaceConfig(palace_path=tmp_path / "palace-a" / "palace")
config_b = MempalaceConfig(palace_path=tmp_path / "palace-b" / "palace")
tunnel_a = palace_graph.create_tunnel(
"wing_alpha",
"topic:shared",
"wing_beta",
"topic:shared",
label="A-only",
kind="topic",
config=config_a,
)
tunnel_b = palace_graph.create_tunnel(
"wing_alpha",
"topic:shared",
"wing_beta",
"topic:shared",
label="B-only",
kind="topic",
config=config_b,
)
assert tunnel_a["id"] == tunnel_b["id"]

monkeypatch.setattr(palace_graph, "MempalaceConfig", lambda: config_b)
return config_a, config_b, tunnel_a["id"]

def test_list_tunnels_reads_only_selected_palace(self, tmp_path, monkeypatch):
config_a, config_b, _ = self._seed_two_palaces(tmp_path, monkeypatch)

assert [t["label"] for t in palace_graph.list_tunnels(config=config_a)] == ["A-only"]
assert [t["label"] for t in palace_graph.list_tunnels(config=config_b)] == ["B-only"]

def test_follow_tunnels_reads_only_selected_palace(self, tmp_path, monkeypatch):
config_a, config_b, _ = self._seed_two_palaces(tmp_path, monkeypatch)

followed_a = palace_graph.follow_tunnels("wing_alpha", "topic:shared", config=config_a)
followed_b = palace_graph.follow_tunnels("wing_alpha", "topic:shared", config=config_b)

assert [t["label"] for t in followed_a] == ["A-only"]
assert [t["label"] for t in followed_b] == ["B-only"]

def test_delete_tunnel_mutates_only_selected_palace(self, tmp_path, monkeypatch):
config_a, config_b, tunnel_id = self._seed_two_palaces(tmp_path, monkeypatch)

assert palace_graph.delete_tunnel(tunnel_id, config=config_a) == {"deleted": tunnel_id}
assert palace_graph._load_tunnels(config_a) == []
assert [t["label"] for t in palace_graph._load_tunnels(config_b)] == ["B-only"]


# =============================================================================
# Regression: create_tunnel validates explicit-tunnel endpoints (#1468)
Expand Down