Skip to content

Commit 6c8567f

Browse files
jpheinclaude
andcommitted
feat(/mine): translate client-side paths via PALACE_DAEMON_PATH_MAP
The daemon's /mine endpoint requires a directory that exists on the daemon's filesystem. Hooks running on a client machine speak in their own namespace (e.g. /home/<user>/.claude/projects/...), but a remote daemon may see the same files at a different mount (e.g. via Syncthing replication into /mnt/raid/...). Without translation the hook's POST /mine fails with 400 "Directory does not exist" and transcript ingest silently goes nowhere. Add an opt-in PALACE_DAEMON_PATH_MAP env var and apply translation in /mine before path validation. Format is comma-separated client_prefix=daemon_prefix entries; first matching prefix wins, non-matching paths pass through unchanged so existing direct daemon-side absolute paths still work. Example deploy on disks:: PALACE_DAEMON_PATH_MAP="/home/jp/.claude/=/mnt/raid/claude-config/,/home/jp/Projects/=/mnt/raid/projects/" Companion to memorypalace fix/restore-transcript-ingest-via-daemon — that PR un-skips the three hook mining paths so they POST to /mine; this PR makes /mine accept the client-side paths those POSTs send. Tests: new tests/test_path_translation.py with 10 stdlib-unittest cases covering parse_path_map (empty, single, multi, malformed, whitespace, env-var fallback) and _translate_client_path (passthrough, prefix match, non-match passthrough, multi-rule first-match-wins). Run via: python -m unittest tests.test_path_translation -v Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 70cbf3f commit 6c8567f

3 files changed

Lines changed: 163 additions & 0 deletions

File tree

main.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,54 @@ def _check_auth(x_api_key: str | None):
176176
raise HTTPException(status_code=401, detail="Invalid API key")
177177

178178

179+
def _parse_path_map(raw: str | None = None) -> list[tuple[str, str]]:
180+
"""Parse PALACE_DAEMON_PATH_MAP into ordered (client_prefix, daemon_prefix) pairs.
181+
182+
Format: comma-separated ``client_prefix=daemon_prefix`` entries. Whitespace
183+
around each token is stripped. Empty entries and entries missing ``=`` are
184+
skipped silently. Order is preserved so the operator can put more-specific
185+
prefixes first.
186+
187+
Example::
188+
189+
PALACE_DAEMON_PATH_MAP="/home/jp/.claude/=/mnt/raid/claude-config/,/home/jp/Projects/=/mnt/raid/projects/"
190+
"""
191+
if raw is None:
192+
raw = os.environ.get("PALACE_DAEMON_PATH_MAP", "")
193+
raw = (raw or "").strip()
194+
if not raw:
195+
return []
196+
pairs: list[tuple[str, str]] = []
197+
for entry in raw.split(","):
198+
entry = entry.strip()
199+
if not entry or "=" not in entry:
200+
continue
201+
client_prefix, daemon_prefix = entry.split("=", 1)
202+
client_prefix = client_prefix.strip()
203+
daemon_prefix = daemon_prefix.strip()
204+
if client_prefix and daemon_prefix:
205+
pairs.append((client_prefix, daemon_prefix))
206+
return pairs
207+
208+
209+
def _translate_client_path(path: str) -> str:
210+
"""Translate a client-side absolute path to a daemon-side path.
211+
212+
Hooks running on a client machine (e.g. katana) speak in their own
213+
filesystem namespace (``/home/jp/.claude/...``); the daemon may see the
214+
same files at a different mount (``/mnt/raid/claude-config/...`` via
215+
Syncthing). ``PALACE_DAEMON_PATH_MAP`` lets the operator declare those
216+
rewrites without coupling client code to deployment specifics.
217+
218+
The first matching prefix wins; non-matching paths pass through
219+
unchanged so daemon-side absolute paths still work.
220+
"""
221+
for client_prefix, daemon_prefix in _parse_path_map():
222+
if path.startswith(client_prefix):
223+
return daemon_prefix + path[len(client_prefix):]
224+
return path
225+
226+
179227
def _sem_for(request_dict: dict) -> asyncio.Semaphore:
180228
method = request_dict.get("method", "")
181229
if method == "ping":
@@ -1014,6 +1062,10 @@ async def mine(request: Request, x_api_key: str | None = Header(default=None)):
10141062
if not directory:
10151063
raise HTTPException(status_code=400, detail="'dir' is required")
10161064

1065+
# Hook clients send paths in their own filesystem namespace. Translate
1066+
# to the daemon's view via PALACE_DAEMON_PATH_MAP before validation.
1067+
directory = _translate_client_path(directory)
1068+
10171069
dir_path = Path(directory)
10181070
if not dir_path.is_absolute() or ".." in dir_path.parts:
10191071
raise HTTPException(status_code=400, detail="'dir' must be an absolute path with no traversal")

tests/__init__.py

Whitespace-only changes.

tests/test_path_translation.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Unit tests for PALACE_DAEMON_PATH_MAP parsing + translation.
2+
3+
Run with::
4+
5+
cd /path/to/palace-daemon
6+
python -m unittest tests.test_path_translation -v
7+
8+
These tests are pure-function and need no live daemon, palace, or
9+
network. They exist because the path-translation logic is the only
10+
glue between the hook's client-side path namespace and the daemon's
11+
filesystem view; getting it wrong silently swallows transcript ingest.
12+
"""
13+
import os
14+
import sys
15+
import unittest
16+
from unittest.mock import patch
17+
18+
# Ensure project root is on sys.path so ``import main`` resolves to the
19+
# daemon's main.py regardless of where the test runner is invoked from.
20+
_HERE = os.path.dirname(os.path.abspath(__file__))
21+
_ROOT = os.path.dirname(_HERE)
22+
if _ROOT not in sys.path:
23+
sys.path.insert(0, _ROOT)
24+
25+
import main # noqa: E402
26+
27+
28+
class TestParsePathMap(unittest.TestCase):
29+
def test_empty_returns_empty(self):
30+
self.assertEqual(main._parse_path_map(""), [])
31+
self.assertEqual(main._parse_path_map(None), [])
32+
self.assertEqual(main._parse_path_map(" "), [])
33+
34+
def test_single_pair(self):
35+
out = main._parse_path_map("/home/u/.claude/=/mnt/raid/claude-config/")
36+
self.assertEqual(out, [("/home/u/.claude/", "/mnt/raid/claude-config/")])
37+
38+
def test_multiple_pairs_preserve_order(self):
39+
raw = "/home/u/.claude/=/mnt/raid/cc/,/home/u/Projects/=/mnt/raid/projects/"
40+
out = main._parse_path_map(raw)
41+
self.assertEqual(
42+
out,
43+
[
44+
("/home/u/.claude/", "/mnt/raid/cc/"),
45+
("/home/u/Projects/", "/mnt/raid/projects/"),
46+
],
47+
)
48+
49+
def test_skips_malformed_entries(self):
50+
# Missing '=' and empty entries are skipped, valid ones survive.
51+
raw = "noequals,,/a/=/b/, =/x/, /c/= "
52+
out = main._parse_path_map(raw)
53+
self.assertEqual(out, [("/a/", "/b/")])
54+
55+
def test_strips_whitespace_around_tokens(self):
56+
out = main._parse_path_map(" /a/ = /b/ ")
57+
self.assertEqual(out, [("/a/", "/b/")])
58+
59+
def test_reads_env_var_when_no_arg(self):
60+
with patch.dict(
61+
os.environ, {"PALACE_DAEMON_PATH_MAP": "/x/=/y/"}, clear=False
62+
):
63+
out = main._parse_path_map()
64+
self.assertEqual(out, [("/x/", "/y/")])
65+
66+
67+
class TestTranslateClientPath(unittest.TestCase):
68+
def test_passthrough_when_no_map(self):
69+
with patch.dict(os.environ, {}, clear=True):
70+
self.assertEqual(
71+
main._translate_client_path("/home/u/.claude/projects/-x"),
72+
"/home/u/.claude/projects/-x",
73+
)
74+
75+
def test_first_matching_prefix_wins(self):
76+
env = {"PALACE_DAEMON_PATH_MAP": "/home/u/.claude/=/mnt/raid/cc/"}
77+
with patch.dict(os.environ, env, clear=True):
78+
self.assertEqual(
79+
main._translate_client_path("/home/u/.claude/projects/-x"),
80+
"/mnt/raid/cc/projects/-x",
81+
)
82+
83+
def test_non_matching_path_passes_through(self):
84+
env = {"PALACE_DAEMON_PATH_MAP": "/home/u/.claude/=/mnt/raid/cc/"}
85+
with patch.dict(os.environ, env, clear=True):
86+
self.assertEqual(
87+
main._translate_client_path("/var/log/syslog"),
88+
"/var/log/syslog",
89+
)
90+
91+
def test_multiple_rules_first_match_wins(self):
92+
# Order matters: list more specific rules first if needed.
93+
env = {
94+
"PALACE_DAEMON_PATH_MAP": (
95+
"/home/u/Projects/memorypalace/=/mnt/raid/mempalace/,"
96+
"/home/u/Projects/=/mnt/raid/projects/"
97+
)
98+
}
99+
with patch.dict(os.environ, env, clear=True):
100+
self.assertEqual(
101+
main._translate_client_path("/home/u/Projects/memorypalace/foo"),
102+
"/mnt/raid/mempalace/foo",
103+
)
104+
self.assertEqual(
105+
main._translate_client_path("/home/u/Projects/realmwatch/foo"),
106+
"/mnt/raid/projects/realmwatch/foo",
107+
)
108+
109+
110+
if __name__ == "__main__":
111+
unittest.main()

0 commit comments

Comments
 (0)