Files
wehub-resource-sync c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

909 lines
34 KiB
Python

import os
import subprocess
import threading
import time
import pytest
from _chroma_palace_helper import make_minimal_chroma_sqlite
from mempalace import daemon
from mempalace import service
# POSIX file-mode bits (0600/0700) are not representable on Windows: os.chmod
# can only toggle the read-only attribute, so a "private" file still reports
# 0o666. The daemon relies on the user-profile directory ACLs for privacy
# there, so the owner-only assertions only make sense on POSIX.
_posix_only_perms = pytest.mark.skipif(
os.name == "nt",
reason="POSIX 0600/0700 file-mode bits are not representable on Windows (ACL-based privacy)",
)
# Env keys run_server mutates from its background thread, plus umask. If a
# lifecycle test times out before the server comes up, run_server's finally
# never runs and those mutations leak into the rest of the suite — every later
# test that reads MempalaceConfig().palace_path sees a stale deleted tmp path and
# fails (the 60+ test cascade seen on slow CI runners). The fixtures below force a
# clean baseline around every daemon test so a leaked thread can't poison the
# process for tests/test_mcp_server.py and friends (which have no such guard).
_LEAK_ENV_KEYS = ("MEMPALACE_PALACE_PATH", "MEMPALACE_BACKEND", "MEMPALACE_BACKEND_EXPLICIT")
@pytest.fixture(scope="module")
def _clean_env_snapshot():
"""Capture the true pre-suite values once, before any daemon test runs."""
return {key: os.environ.get(key) for key in _LEAK_ENV_KEYS}
@pytest.fixture(autouse=True)
def _isolate_process_global_state(_clean_env_snapshot):
"""Restore the process-global env + umask to the pre-suite baseline after every
daemon test, even if a leaked run_server thread is still holding them mutated.
"""
prev_umask = os.umask(0o022)
os.umask(prev_umask) # read current umask without changing it
yield
for key, value in _clean_env_snapshot.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
os.umask(prev_umask)
def _raise_not_ready(*a, **kw):
"""Stand-in for DaemonClient when the spawned daemon must never come up."""
raise daemon.DaemonError("not ready")
def test_prune_terminal_drops_old_terminal_jobs_keeps_active(tmp_path, monkeypatch):
"""Terminal jobs older than the retention window are pruned; queued/running
and fresh terminal jobs are untouched. Bounded queue growth for the DB that
holds verbatim payloads."""
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
store = daemon.QueueStore(daemon.queue_path(str(palace)))
old_term = store.enqueue("mine", {"source": "old"})
store.finish(old_term.id, state="succeeded", result={"success": True})
fresh_term = store.enqueue("mine", {"source": "fresh"})
store.finish(fresh_term.id, state="succeeded", result={"success": True})
queued = store.enqueue("mine", {"source": "queued"})
from datetime import datetime, timedelta, timezone
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
with store._lock, store._connect() as conn:
conn.execute(
"UPDATE jobs SET finished_at = ? WHERE id = ?",
(cutoff, old_term.id),
)
pruned = store.prune_terminal(older_than_days=7)
assert pruned == 1
# The old terminal job is gone; the fresh terminal and queued jobs survive.
with pytest.raises(daemon.DaemonError):
store.get(old_term.id)
assert store.get(fresh_term.id).state == "succeeded"
assert store.get(queued.id).state == "queued"
def test_queue_dedupes_and_recovers_running_jobs(tmp_path, monkeypatch):
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
store = daemon.QueueStore(daemon.queue_path(str(palace)))
first = store.enqueue("mine", {"source": "a"}, dedupe_key="same")
second = store.enqueue("mine", {"source": "a"}, dedupe_key="same")
assert second.id == first.id
claimed = store.claim_next()
assert claimed.id == first.id
assert claimed.state == "running"
recovered = store.recover_running()
assert recovered == 1
assert store.get(first.id).state == "queued"
def test_daemon_http_lifecycle_executes_job(tmp_path, monkeypatch):
calls = []
def fake_execute(kind, payload):
calls.append((kind, payload))
return {"success": True, "exit_code": 0, "stdout": "done\n"}
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
health = client.health()
assert health["ok"] is True
assert health["palace_path"] == daemon.canonical_palace_path(str(palace))
job = client.submit("mine", {"source": "src"}, dedupe_key="job")
finished = client.wait(job["id"], timeout=5)
assert finished["state"] == "succeeded"
assert finished["result"]["stdout"] == "done\n"
assert calls == [("mine", {"source": "src", "palace_path": str(palace.resolve())})]
_stop_server(client, thread, holders)
def test_submit_job_uses_client_and_waits(monkeypatch, tmp_path):
palace = tmp_path / "palace"
palace.mkdir()
class DummyClient:
def __init__(self):
self.submitted = None
def submit(self, kind, payload, dedupe_key=None, priority=0):
self.submitted = (kind, payload, dedupe_key, priority)
return {"id": "job-1", "state": "queued"}
def wait(self, job_id, timeout=daemon.DEFAULT_WAIT_TIMEOUT):
assert job_id == "job-1"
return {
"id": "job-1",
"state": "succeeded",
"result": {"success": True, "exit_code": 0},
}
dummy = DummyClient()
monkeypatch.setattr(daemon, "ensure_client", lambda *a, **kw: dummy)
job = daemon.submit_job(
"mine",
{"source": "src"},
palace_path=str(palace),
dedupe_key="dedupe",
wait=True,
)
assert job["state"] == "succeeded"
assert dummy.submitted[0] == "mine"
# palace_path is overridden (not trusted from the payload), never appended.
assert dummy.submitted[1]["palace_path"] == daemon.canonical_palace_path(str(palace))
assert dummy.submitted[2] == "dedupe"
def test_service_tool_classification():
assert service.classify_tool("mempalace_search") == "read"
assert service.classify_tool("mempalace_add_drawer") == "write"
assert service.classify_tool("mempalace_checkpoint") == "write"
assert service.classify_tool("mempalace_delete_by_source") == "write"
assert service.classify_tool("mempalace_mine") == "maintenance"
assert service.classify_tool("unknown") == "unknown"
# --- helpers for HTTP-lifecycle tests ---
def _capture_httpd(monkeypatch):
"""Capture the httpd instance run_server creates.
run_server defines a local ``class _Server(ThreadingHTTPServer)``; by
monkeypatching ``daemon.ThreadingHTTPServer`` before run_server runs, that
subclass inherits from a capturing base that records each instance. The
httpd can then be force-stopped from the test thread (see _stop_server) so a
slow/failed ``client.shutdown()`` POST can never leave the server thread
alive — which on Windows hangs the interpreter at process exit on an open
listening socket.
"""
holders: list = []
base = daemon.ThreadingHTTPServer
class _CapturingServer(base):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
holders.append(self)
monkeypatch.setattr(daemon, "ThreadingHTTPServer", _CapturingServer)
return holders
def _stop_server(client, thread, holders, *, join_timeout=5.0):
"""Shut the daemon down deterministically and assert the thread died.
First try the normal path (POST /shutdown). If the server thread is still
alive afterwards — the POST was slow, lost, or the drain overran the join —
call httpd.shutdown() directly from this thread (stdlib-safe: it is a
different thread than serve_forever) to force serve_forever to return, then
re-join. The assert turns a leak into a visible failure instead of a silent
interpreter-exit hang.
"""
try:
client.shutdown()
except Exception: # noqa: BLE001 - best-effort; we force-shutdown below
pass
thread.join(timeout=join_timeout)
if thread.is_alive() and holders:
httpd = holders[-1]
try:
httpd.shutdown()
except Exception: # noqa: BLE001
pass
try:
httpd.server_close()
except Exception: # noqa: BLE001
pass
thread.join(timeout=join_timeout)
assert not thread.is_alive(), "daemon server thread did not shut down"
def _start_server(tmp_path, monkeypatch, execute_fn):
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
monkeypatch.setattr(service, "execute_job", execute_fn)
holders = _capture_httpd(monkeypatch)
# Capture any exception run_server raises in its thread. Without this a
# startup crash is invisible: the poll below would just spin for 30s and
# fail with a bare ``assert client is not None`` giving no cause.
server_error: list = []
def _serve():
try:
daemon.run_server(palace_path=str(palace), port=0)
except BaseException as exc: # noqa: BLE001 - re-surfaced to the test thread
server_error.append(exc)
thread = threading.Thread(target=_serve, name="test-daemon-server", daemon=True)
thread.start()
client = None
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
if server_error:
raise AssertionError(f"run_server crashed during startup: {server_error[0]!r}")
client = daemon.get_client_if_running(str(palace))
if client is not None:
break
time.sleep(0.05)
if client is None:
raise AssertionError(
"daemon did not become ready within 30s "
f"(thread_alive={thread.is_alive()}, httpd_bound={bool(holders)}, "
f"endpoint_exists={daemon.endpoint_path(str(palace)).exists()})"
)
return client, thread, palace, holders
# --- ship-blocker regressions ---
def test_systemexit_in_job_does_not_kill_worker(tmp_path, monkeypatch):
"""A SystemExit (BaseException, not Exception) must be caught, the job
marked failed, and the worker kept alive for the next job. Regression for
the critical worker-death bug."""
state = {"first": True}
def fake_execute(kind, payload):
if state["first"]:
state["first"] = False
raise SystemExit("boom")
return {"success": True, "exit_code": 0}
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
try:
first = client.submit("mine", {"source": "src"})
finished_first = client.wait(first["id"], timeout=5)
assert finished_first["state"] == "failed"
assert finished_first["error"]["error_class"] == "SystemExit"
# Worker must still be alive — health reports it and a second job runs.
assert client.health()["worker_alive"] is True
second = client.submit("mine", {"source": "src2"})
finished_second = client.wait(second["id"], timeout=5)
assert finished_second["state"] == "succeeded"
finally:
_stop_server(client, thread, holders)
def test_shutdown_cancels_active_job(tmp_path, monkeypatch):
"""POST /shutdown must not leave an in-flight job 'running' for blind
re-queue on next start. The worker is drained (bounded), then the active
job is marked 'cancelled' so recover_running won't re-run it.
In production the serve process exits immediately after run_server returns,
killing the daemon worker thread before it can overwrite the cancelled
state. The test mirrors that by asserting the cancelled state *before*
releasing the blocked worker.
"""
block = threading.Event()
def fake_execute(kind, payload):
# Simulate a long-running job that never finishes on its own.
block.wait(30)
return {"success": True, "exit_code": 0}
monkeypatch.setattr(daemon, "SHUTDOWN_DRAIN_SECONDS", 0.2)
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
job = client.submit("mine", {"source": "src"}, dedupe_key="x")
# Wait until the worker has claimed it (state flips to running).
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if client.get_job(job["id"])["state"] == "running":
break
time.sleep(0.02)
assert client.get_job(job["id"])["state"] == "running"
_stop_server(client, thread, holders)
# The interrupted job must be cancelled (terminal), not left running.
store = daemon.QueueStore(daemon.queue_path(str(palace)))
final = store.get(job["id"])
assert final.state == "cancelled"
# And recover_running must not re-queue a cancelled job.
assert store.recover_running() == 0
# Release the blocked worker so it (and the daemon thread) can exit.
block.set()
def test_recover_running_dead_letters_exhausted_jobs(tmp_path, monkeypatch):
"""A job that has crashed MAX_ATTEMPTS times must be dead-lettered to
'failed', not re-queued — non-idempotent kinds (diary_write) would
otherwise duplicate verbatim content on every restart."""
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
store = daemon.QueueStore(daemon.queue_path(str(palace)))
job = store.enqueue("diary_write", {"entry": "x"})
# Simulate MAX_ATTEMPTS claims that each crashed (running, attempts=MAX).
with store._lock, store._connect() as conn:
conn.execute(
"UPDATE jobs SET state='running', attempts=? WHERE id=?",
(daemon.MAX_ATTEMPTS, job.id),
)
recovered = store.recover_running()
assert recovered == 0 # not re-queued
final = store.get(job.id)
assert final.state == "failed"
assert final.attempts == daemon.MAX_ATTEMPTS
def test_claim_next_does_not_reclaim_running_job(tmp_path, monkeypatch):
"""The conditional UPDATE (WHERE state='queued') means a job already
flipped to 'running' cannot be claimed again — the cross-process
double-execution guard."""
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
store = daemon.QueueStore(daemon.queue_path(str(palace)))
job = store.enqueue("mine", {"source": "src"})
first = store.claim_next()
assert first.id == job.id
# Manually re-mark it queued but leave a second claim attempt: claim_next
# should still only ever return one running job per claim. After finishing
# the first, the next claim returns None (queue empty).
store.finish(first.id, state="succeeded", result={"success": True})
assert store.claim_next() is None
@_posix_only_perms
def test_queue_db_file_is_owner_only(tmp_path, monkeypatch):
"""The queue DB holds verbatim payloads — it must be 0600, not the sqlite
default 0644. Regression for the privacy-principle violation."""
import os as _os
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
store = daemon.QueueStore(daemon.queue_path(str(palace)))
store.enqueue("diary_write", {"entry": "secret verbatim content"})
mode = _os.stat(str(store.path)).st_mode & 0o777
assert mode == 0o600, f"queue.sqlite3 is {oct(mode)}, expected 0600"
@_posix_only_perms
def test_token_file_is_owner_only(tmp_path, monkeypatch):
import os as _os
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
daemon.ensure_token(str(palace))
token_path = daemon.state_dir(str(palace)) / "token"
assert (_os.stat(str(token_path)).st_mode & 0o777) == 0o600
def test_health_rejects_missing_and_wrong_token(tmp_path, monkeypatch):
from urllib import error as urlerror
from urllib import request as urlrequest
client, thread, palace, holders = _start_server(
tmp_path, monkeypatch, lambda k, p: {"success": True}
)
try:
base = f"http://127.0.0.1:{client.port}"
# No Authorization header → 401.
with pytest.raises(urlerror.HTTPError):
urlrequest.urlopen(urlrequest.Request(base + "/health"), timeout=3)
# Wrong token → 401.
with pytest.raises(urlerror.HTTPError):
urlrequest.urlopen(
urlrequest.Request(base + "/health", headers={"Authorization": "Bearer wrong"}),
timeout=3,
)
finally:
_stop_server(client, thread, holders)
def test_worker_overrides_client_palace_path(tmp_path, monkeypatch):
"""An authenticated client must not be able to retarget the daemon at a
different palace by stuffing palace_path into the payload."""
seen = {}
def fake_execute(kind, payload):
seen["palace_path"] = payload.get("palace_path")
return {"success": True, "exit_code": 0}
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
try:
job = client.submit(
"mine", {"source": "src", "palace_path": "/tmp/other-palace"}, dedupe_key="p"
)
client.wait(job["id"], timeout=5)
finally:
_stop_server(client, thread, holders)
assert seen["palace_path"] == daemon.canonical_palace_path(str(palace))
assert seen["palace_path"] != "/tmp/other-palace"
def test_mcp_tool_allowlist_rejects_non_write_tools(tmp_path, monkeypatch):
"""The daemon queue is a durable write surface; read/maintenance/unknown
tools must be rejected so verbatim content can't be exfiltrated into the
queue or retried destructively."""
# read tool → rejected
out = service.run_mcp_tool({"name": "mempalace_search", "arguments": {}})
assert out["success"] is False
assert "only accepts write tools" in out["error"]
# maintenance tool → rejected (has its own kinds: mine/sync)
out = service.run_mcp_tool({"name": "mempalace_mine", "arguments": {}})
assert out["success"] is False
# unknown tool → rejected
out = service.run_mcp_tool({"name": "mempalace_bogus", "arguments": {}})
assert out["success"] is False
# write tool → passes the allowlist (handler not called here since TOOLS
# won't have it under the test name; but classification must let it through)
assert service.classify_tool("mempalace_add_drawer") == "write"
def test_execute_job_isolates_env_per_job(monkeypatch):
"""A job that mutates MEMPALACE_BACKEND must not leak into the next job's
env. Regression for the per-job isolation bug (_apply_backend poisoning)."""
import os as _os
monkeypatch.delenv("MEMPALACE_BACKEND", raising=False)
monkeypatch.delenv("MEMPALACE_PALACE_PATH", raising=False)
def fake_mine(payload):
_os.environ["MEMPALACE_BACKEND"] = "leaked-backend"
return {"success": True, "exit_code": 0}
monkeypatch.setattr(service, "run_mine", fake_mine)
service.execute_job("mine", {"palace_path": "/tmp/p", "source": "s"})
assert _os.environ.get("MEMPALACE_BACKEND") is None
def test_daemon_client_raises_on_endpoint_missing_port(tmp_path, monkeypatch):
"""A malformed endpoint.json must raise DaemonError, not a bare KeyError."""
import json as _json
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
daemon.ensure_token(str(palace))
# endpoint with no port
daemon.state_dir(str(palace)).mkdir(parents=True, exist_ok=True)
(daemon.state_dir(str(palace)) / "endpoint.json").write_text(
_json.dumps({"host": "127.0.0.1", "pid": 1}) + "\n", encoding="utf-8"
)
with pytest.raises(daemon.DaemonError):
daemon.DaemonClient(str(palace))
def test_pid_alive_probe_is_signal_free_and_correct():
"""``_pid_alive`` must be a pure liveness probe.
On Windows ``os.kill(pid, 0)`` is NOT harmless — signal 0 is
``CTRL_C_EVENT``, so it emits a console Ctrl-C to the target's process
group. The daemon client polls a same-process endpoint, so that Ctrl-C was
delivered back to the interpreter and surfaced as a spurious
``KeyboardInterrupt`` that hung the whole test session on CI runners (which,
unlike a detached dev shell, have an attached console). Assert the probe is
both correct and emits no SIGINT even when hammered like the poll loop.
"""
import signal
assert daemon._pid_alive(os.getpid()) is True
assert daemon._pid_alive(0) is False
assert daemon._pid_alive(-1) is False
# A pid that is almost certainly not running.
assert daemon._pid_alive(2_000_000_000) is False
# pytest runs tests on the main thread, so installing a SIGINT handler is
# allowed. If the probe regresses to os.kill(pid, 0) on Windows, the repeated
# calls below deliver CTRL_C_EVENT and this handler fires.
fired = []
previous = signal.getsignal(signal.SIGINT)
signal.signal(signal.SIGINT, lambda *_: fired.append(1))
try:
for _ in range(25):
daemon._pid_alive(os.getpid())
time.sleep(0.25)
finally:
signal.signal(signal.SIGINT, previous)
assert fired == [], "_pid_alive delivered a console control event (CTRL_C_EVENT)"
def test_start_daemon_kills_orphan_on_readiness_timeout(tmp_path, monkeypatch):
"""If the spawned daemon never becomes ready, start_daemon must kill and
reap the orphaned subprocess rather than leaking it with the port/token."""
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
daemon.ensure_token(str(palace))
monkeypatch.setattr(daemon, "get_client_if_running", lambda *a, **kw: None)
class FakeProc:
def __init__(self):
self.killed = False
self.returncode = None
def poll(self):
return self.returncode # None == still alive
def kill(self):
self.killed = True
def wait(self):
self.returncode = -9
return self.returncode
fake = FakeProc()
def fake_popen(*a, **kw):
return fake
monkeypatch.setattr(daemon.subprocess, "Popen", fake_popen)
monkeypatch.setattr(daemon, "DaemonClient", _raise_not_ready)
monkeypatch.setattr(daemon.time, "sleep", lambda *a, **kw: None)
with pytest.raises(daemon.DaemonError):
daemon.start_daemon(str(palace), timeout=0.05)
assert fake.killed is True
# --- service.run_* happy-path coverage ---
# These close the draft PR's follow-up ("Add focused happy-path tests for
# service.run_mine / run_diary_write / run_mcp_tool") and, now that the daemon
# tests complete reliably, keep service.py's coverage above the CI gate. The
# capsys-using tests come first; the two that import mempalace.mcp_server (which
# rebinds sys.stdout) come last and do not use capsys, so the rebind can't break
# capture in this file or later files (capsys activates after the rebind).
def test_print_job_result_replays_stdout_stderr_and_returns_exit_code(capsys):
from mempalace import service
code = service.print_job_result(
{"success": False, "error": "boom", "stdout": "out\n", "stderr": "err\n", "exit_code": 3}
)
assert code == 3
captured = capsys.readouterr()
assert "out" in captured.out
assert "err" in captured.err
def test_print_job_result_prints_error_to_stderr_when_no_stderr(capsys):
from mempalace import service
code = service.print_job_result({"success": False, "error": "boom", "exit_code": 1})
assert code == 1
captured = capsys.readouterr()
assert "mempalace: boom" in captured.err
def test_run_sync_returns_success_when_palace_dir_missing(tmp_path):
from mempalace import service
result = service.run_sync({"palace_path": str(tmp_path / "nope"), "dry_run": True})
assert result["success"] is True
assert result["exit_code"] == 0
def test_run_sync_returns_success_when_palace_has_no_backend_artifact(tmp_path):
from mempalace import service
palace = tmp_path / "palace"
palace.mkdir()
result = service.run_sync({"palace_path": str(palace), "dry_run": True})
assert result["success"] is True
assert result["exit_code"] == 0
def test_run_mine_invalid_mode_returns_structured_error(tmp_path):
from mempalace import service
palace = tmp_path / "palace"
palace.mkdir()
out = service.run_mine({"palace_path": str(palace), "mode": "bogus"})
assert out["success"] is False
assert "invalid mine mode" in out["error"]
assert out["exit_code"] == 2
def test_run_mcp_tool_rejects_non_dict_arguments():
from mempalace import service
out = service.run_mcp_tool({"name": "mempalace_add_drawer", "arguments": "nope"})
assert out["success"] is False
assert "must be an object" in out["error"]
assert out["exit_code"] == 2
def test_run_mcp_tool_dispatches_write_tool(monkeypatch):
import mempalace.mcp_server as mcp
from mempalace import service
captured = {}
def fake_handler(**arguments):
captured["arguments"] = arguments
return {"success": True, "written": True}
monkeypatch.setattr(mcp, "TOOLS", {"mempalace_add_drawer": {"handler": fake_handler}})
out = service.run_mcp_tool({"name": "mempalace_add_drawer", "arguments": {"x": 1}})
assert out["success"] is True
assert out["written"] is True
assert out["exit_code"] == 0
assert captured["arguments"] == {"x": 1}
def test_run_diary_write_forwards_args_and_sets_exit_code(monkeypatch):
import mempalace.mcp_server as mcp
from mempalace import service
captured = {}
def fake_diary(agent_name, entry, topic, wing):
captured.update(agent_name=agent_name, entry=entry, topic=topic, wing=wing)
return {"success": True}
monkeypatch.setattr(mcp, "tool_diary_write", fake_diary)
out = service.run_diary_write(
{"agent_name": "alice", "entry": "hello", "topic": "t", "wing": "w"}
)
assert out["success"] is True
assert out["exit_code"] == 0
assert captured == {"agent_name": "alice", "entry": "hello", "topic": "t", "wing": "w"}
def test_run_mine_applies_backend_before_mode_validation(tmp_path):
"""Covers _apply_backend (env set + get_backend_class validation) on the daemon
path; the invalid mode short-circuits before any mining runs."""
from mempalace import service
palace = tmp_path / "palace"
palace.mkdir()
out = service.run_mine({"palace_path": str(palace), "mode": "bogus", "backend": "chroma"})
assert out["success"] is False
assert out["exit_code"] == 2
def test_execute_job_dispatches_diary_write_mcp_tool_and_unknown(monkeypatch):
"""Covers execute_job's kind dispatch for diary_write, mcp_tool, and the
unknown-kind fallback."""
import mempalace.mcp_server as mcp
from mempalace import service
monkeypatch.setattr(mcp, "tool_diary_write", lambda **kw: {"success": True})
monkeypatch.setattr(
mcp, "TOOLS", {"mempalace_add_drawer": {"handler": lambda **kw: {"success": True}}}
)
assert service.execute_job("diary_write", {"entry": "x"})["success"] is True
assert (
service.execute_job("mcp_tool", {"name": "mempalace_add_drawer", "arguments": {}})[
"success"
]
is True
)
unknown = service.execute_job("bogus_kind", {})
assert unknown["success"] is False
assert unknown["exit_code"] == 2
def test_run_sync_structured_errors_on_sync_failures(tmp_path, monkeypatch):
"""Covers run_sync's three exception handlers (MineAlreadyRunning, ValueError,
generic Exception) so a failing sync_palace returns a structured error instead
of propagating."""
import mempalace.sync as sync_module
from mempalace import service
from mempalace.palace import MineAlreadyRunning
palace = tmp_path / "palace"
palace.mkdir()
make_minimal_chroma_sqlite(palace)
def _raise(exc):
def fn(**kw):
raise exc
return fn
monkeypatch.setattr(sync_module, "sync_palace", _raise(MineAlreadyRunning("locked")))
r = service.run_sync({"palace_path": str(palace), "dry_run": True})
assert r["success"] is False
assert r["error_class"] == "LockHeldByOtherProcess"
monkeypatch.setattr(sync_module, "sync_palace", _raise(ValueError("bad scope")))
r = service.run_sync({"palace_path": str(palace), "dry_run": True})
assert r["success"] is False
assert r["exit_code"] == 2
monkeypatch.setattr(sync_module, "sync_palace", _raise(RuntimeError("boom")))
r = service.run_sync({"palace_path": str(palace), "dry_run": True})
assert r["success"] is False
assert "sync failed" in r["error"]
# --- post-merge review follow-ups (Copilot review on #1826) ---
@_posix_only_perms
def test_run_server_tightens_umask_before_building_queue(tmp_path, monkeypatch):
"""The owner-only umask must be active BEFORE the queue DB is built.
SQLite's WAL/SHM sidecars hold un-checkpointed verbatim payloads and are
created with the process umask, so a loose umask at DaemonRuntime/QueueStore
construction time would leave them world-readable. Capture the umask at the
moment DaemonRuntime is constructed and assert it is already 0o077.
"""
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
captured = {}
def _spy_runtime(*args, **kwargs):
current = os.umask(0o022)
os.umask(current) # restore without changing
captured["umask"] = current
raise RuntimeError("stop before binding a real socket")
monkeypatch.setattr(daemon, "DaemonRuntime", _spy_runtime)
with pytest.raises(RuntimeError):
daemon.run_server(str(palace), port=0)
assert captured["umask"] == 0o077
def test_negative_content_length_is_rejected_without_blocking(tmp_path, monkeypatch):
"""A POST with Content-Length: -1 must get a prompt 400, not hang the worker.
rfile.read(-1) would read until the client closes the socket (an auth-gated
DoS) and bypass the MAX_BODY_BYTES cap. The recv timeout below turns a
regression into a failure instead of a hang.
"""
import socket
client, thread, palace, holders = _start_server(
tmp_path, monkeypatch, lambda k, p: {"success": True}
)
try:
sock = socket.create_connection((client.host, client.port), timeout=5)
sock.settimeout(5)
request = (
"POST /jobs HTTP/1.1\r\n"
"Host: daemon\r\n"
f"Authorization: Bearer {client.token}\r\n"
"Content-Length: -1\r\n"
"Connection: close\r\n\r\n"
)
sock.sendall(request.encode("ascii"))
status_line = sock.recv(4096).decode("latin-1").split("\r\n", 1)[0]
sock.close()
assert "400" in status_line, f"expected 400, got {status_line!r}"
finally:
_stop_server(client, thread, holders)
def test_run_mcp_tool_marks_bare_error_dict_as_failure(monkeypatch):
"""A write tool that returns {"error": ...} with no success flag must be
recorded as a failed job, not succeeded (Copilot review)."""
import mempalace.mcp_server as mcp
from mempalace import service
monkeypatch.setattr(
mcp,
"TOOLS",
{"mempalace_create_tunnel": {"handler": lambda **kw: {"error": "bad endpoint"}}},
)
out = service.run_mcp_tool({"name": "mempalace_create_tunnel", "arguments": {}})
assert out["success"] is False
assert out["exit_code"] == 1
assert out["error"] == "bad endpoint"
# A result with neither an explicit success flag nor an error is a success.
monkeypatch.setattr(
mcp, "TOOLS", {"mempalace_create_tunnel": {"handler": lambda **kw: {"tunnel_id": "t1"}}}
)
out = service.run_mcp_tool({"name": "mempalace_create_tunnel", "arguments": {}})
assert out["success"] is True
assert out["exit_code"] == 0
def test_get_client_if_running_uses_short_probe_timeout(monkeypatch):
"""The hook liveness precheck must pass a short health timeout so a wedged
daemon can't stall the hook past its budget (Copilot review)."""
captured = {}
class _FakeClient:
def __init__(self, palace_path):
pass
def health(self, *, timeout):
captured["timeout"] = timeout
return {"ok": True}
monkeypatch.setattr(daemon, "DaemonClient", _FakeClient)
assert daemon.HOOK_PROBE_TIMEOUT <= 0.5
client = daemon.get_client_if_running("/p", health_timeout=daemon.HOOK_PROBE_TIMEOUT)
assert client is not None
assert captured["timeout"] == daemon.HOOK_PROBE_TIMEOUT
# --- _detached_kwargs ---
def test_detached_kwargs_posix(tmp_path, monkeypatch):
monkeypatch.setattr("mempalace.daemon.os.name", "posix")
kwargs = daemon._detached_kwargs(tmp_path / "daemon.log")
fh = kwargs["stdout"]
try:
assert kwargs.get("start_new_session") is True
assert kwargs.get("stdin") is subprocess.DEVNULL
assert kwargs.get("close_fds") is True
assert "creationflags" not in kwargs
finally:
fh.close()
def test_detached_kwargs_windows(tmp_path, monkeypatch):
monkeypatch.setattr("mempalace.daemon.os.name", "nt")
monkeypatch.setattr("mempalace.daemon.subprocess.CREATE_NO_WINDOW", 0x08000000, raising=False)
monkeypatch.setattr("mempalace.daemon.subprocess.DETACHED_PROCESS", 0x00000008, raising=False)
monkeypatch.setattr(
"mempalace.daemon.subprocess.CREATE_NEW_PROCESS_GROUP", 0x00000200, raising=False
)
monkeypatch.setattr(
"mempalace.daemon.subprocess.CREATE_BREAKAWAY_FROM_JOB", 0x01000000, raising=False
)
kwargs = daemon._detached_kwargs(tmp_path / "daemon.log")
fh = kwargs["stdout"]
try:
flags = kwargs.get("creationflags", 0)
assert flags & 0x08000000, "CREATE_NO_WINDOW must be set"
assert not (flags & 0x00000008), (
"DETACHED_PROCESS must NOT be set (it suppresses CREATE_NO_WINDOW)"
)
assert flags & 0x00000200, "CREATE_NEW_PROCESS_GROUP preserved (Ctrl-Break group isolation)"
assert flags & 0x01000000, (
"CREATE_BREAKAWAY_FROM_JOB preserved (survive parent Job-Object close)"
)
assert kwargs.get("stdin") is subprocess.DEVNULL
assert kwargs.get("close_fds") is True
# creationflags + start_new_session are mutually exclusive on Windows
# (Popen raises ValueError); the nt branch must not set the latter.
assert "start_new_session" not in kwargs
finally:
fh.close()