Repair MCP diagnostics and gateway lifecycle

This commit is contained in:
HumbleTurtle
2026-08-01 03:50:28 -03:00
parent ffd31cf568
commit bfda22f59b
16 changed files with 663 additions and 110 deletions
+4 -2
View File
@@ -80,7 +80,7 @@ def test_init_and_auth_repair_normalize_to_existing_handlers(monkeypatch):
assert calls == [
("project", ["init", "--slug", "demo"]),
("auth", ["refresh"]),
("auth", ["repair"]),
]
@@ -121,6 +121,7 @@ def test_doctor_reports_presence_without_exposing_values(tmp_path, monkeypatch):
(install_dir / "napseer_mcp_supervisor.py").write_text("# supervisor\n", encoding="utf-8")
monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
monkeypatch.setattr(module, "runtime_assets_missing", lambda: [])
monkeypatch.setattr(module, "mcp_runtime_probe", lambda: {"status": "ok", "transport": True, "read": True, "tool_count": 56})
monkeypatch.setattr(
module,
"state_dir_status",
@@ -150,6 +151,7 @@ def test_doctor_does_not_create_state_directory(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
monkeypatch.setattr(module, "runtime_assets_missing", lambda: [])
monkeypatch.setattr(module, "mcp_runtime_probe", lambda: {"status": "failed", "transport": False, "read": False})
result = module.doctor_status()
@@ -229,7 +231,7 @@ def test_bundle_install_stages_validates_and_activates_atomically(tmp_path, monk
result = module.install_assets()
assert result["cli_distribution"]["release_version"] == "0.2.0"
assert result["cli_distribution"]["release_version"] == "0.2.1"
assert (install_dir / "current").is_symlink()
assert (install_dir / "current" / "bundle-manifest.json").is_file()
assert (install_dir / "current" / "napseer_mcp_server.py").is_file()
+14
View File
@@ -126,6 +126,19 @@ def test_gateway_schedule_aliases_rejected():
expect_unknown(mod, args, "unknown gateway")
def test_gateway_schedule_list_uses_canonical_route():
mod = load_module()
calls = []
mod.cli_gateway_require_unlocked = lambda args, operation: None
mod.cli_gateway_service_call = lambda route, args: calls.append((route, dict(args))) or {
"status": "listed",
"schedules": [],
}
with contextlib.redirect_stdout(io.StringIO()):
mod.cli_main(["napseer_mcp_server.py", "gateway", "schedule", "list"])
assert calls == [("/local-api/gateway/schedule/list", {})]
def test_gateway_service_run_rejected():
"""Lifecycle verbs (run, start, etc.) moved to `nap service`."""
mod = load_module()
@@ -209,6 +222,7 @@ if __name__ == "__main__":
test_gateway_status_aliases_rejected()
test_gateway_terminal_aliases_rejected()
test_gateway_schedule_aliases_rejected()
test_gateway_schedule_list_uses_canonical_route()
test_gateway_service_run_rejected()
test_gateway_vault_aliases_rejected()
test_gateway_rotate_passphrase_aliases_rejected()
+1
View File
@@ -82,6 +82,7 @@ def run():
})
mod.REFRESH_TOKEN = "old-oauth-refresh"
mod.REFRESH_EXPIRES_AT = "old-refresh-expiry"
mod.refresh_public_auth_state = lambda: mod.AUTH
mod.request_form_json = lambda method, path, payload, token_required=False: {
"access_token": "fresh-oauth-access",
"refresh_token": "fresh-oauth-refresh",
+53
View File
@@ -0,0 +1,53 @@
import importlib.util
import pathlib
import threading
import time
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
spec = importlib.util.spec_from_file_location("napseer_mcp_server_gateway_auth_test", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def test_renew_auth_serializes_rotating_refresh_tokens():
mod = load_module()
active = 0
maximum = 0
guard = threading.Lock()
def fake_refresh():
nonlocal active, maximum
with guard:
active += 1
maximum = max(maximum, active)
time.sleep(0.03)
with guard:
active -= 1
return {"status": "renewed"}
mod.refresh_public_auth_state = lambda: None
mod._renew_auth_locked = fake_refresh
threads = [threading.Thread(target=mod.renew_auth) for _ in range(3)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert maximum == 1
def test_gateway_capability_update_does_not_refresh_by_default():
mod = load_module()
mod.AUTH = {"worker_capabilities": {}}
calls = []
mod.save_auth = lambda updates: mod.AUTH.update(updates)
mod.renew_auth = lambda: calls.append("renewed")
result = mod.ensure_gateway_worker_capability(refresh=False)
assert result["status"] == "updated"
assert calls == []
@@ -0,0 +1,38 @@
import importlib.util
import pathlib
import types
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
spec = importlib.util.spec_from_file_location("napseer_mcp_server_pty_capture_test", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def test_capture_reads_pty_ring_instead_of_tmux():
mod = load_module()
attach = types.SimpleNamespace(
chunks=(
types.SimpleNamespace(data=b"first\n"),
types.SimpleNamespace(data=b"napseer-lifecycle-ok\n"),
),
newest_seq=2,
replay_gap=False,
)
manager = types.SimpleNamespace(attach=lambda terminal_id: attach)
mod.gateway_terminal_backend_from_id = lambda terminal_id, remote_authenticated=False: (
"pty",
{"id": terminal_id},
)
mod.gateway_pty_manager = lambda: manager
result = mod.gateway_terminal_capture({"terminal_id": "term_test"})
assert result["status"] == "captured"
assert result["terminal_backend"] == "pty"
assert result["terminal_id"] == "term_test"
assert "napseer-lifecycle-ok" in result["output"]
assert result["output_seq"] == 2
@@ -5,6 +5,7 @@ import base64
import importlib.util
import pathlib
import sys
import tempfile
PROJECT_ID = "11111111-1111-1111-1111-111111111111"
@@ -46,6 +47,8 @@ def assert_wrapped_record(mod, record, secret_kind, data_key_epoch=1):
def run():
mod = load_module()
test_state = tempfile.TemporaryDirectory()
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
writes = []
mod.AUTH = {"agent_id": "gateway-agent-1", "account_id": ACCOUNT_ID}
+61
View File
@@ -0,0 +1,61 @@
import importlib.util
import pathlib
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
spec = importlib.util.spec_from_file_location("napseer_mcp_server_worker_repair_test", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def test_gateway_worker_repair_separates_credentials_and_returns_no_tokens():
mod = load_module()
mod.AUTH = {
"account_mode": "operator_project",
"project_slug": "demo",
"project_name": "Demo",
}
mod.DEFAULT_PROJECT_ID = "project-1"
mod.BASE_URL = "https://api.example.test"
mod.GATEWAY_AUTH_PATH = pathlib.Path("/definitely/missing/gateway-auth.json")
mod.ensure_container_uuid = lambda: "container-1"
mod.normalize_gateway_command = lambda value=None: value or "bash -l"
written = []
mod.write_gateway_worker_auth = lambda payload: written.append(dict(payload))
def request(method, path, payload=None, token_required=True, **kwargs):
if path == "/v1/service-bootstrap-tokens":
return {"bootstrap_token": "bootstrap-secret"}
if path == "/v1/service-registrations":
assert token_required is False
return {
"registration": {"id": "registration-1"},
"activation_token": "activation-secret",
}
if path.endswith("/accept"):
return {"worker": {"id": "worker-1", "agent_id": "agent-1", "project_id": "project-1"}}
if path.endswith("/activate"):
assert token_required is False
return {
"worker": {"id": "worker-1", "agent_id": "agent-1", "project_id": "project-1"},
"token": {
"access_token": "worker-access-secret",
"refresh_token": "worker-refresh-secret",
"expires_at": "later",
"refresh_expires_at": "much-later",
},
}
raise AssertionError(path)
mod.request_json = request
result = mod.gateway_worker_repair({"display_name": "demo-local-gateway"})
assert written[0]["account_mode"] == "gateway_worker"
assert written[0]["token"] == "worker-access-secret"
assert written[0]["refresh_token"] == "worker-refresh-secret"
assert "token" not in result
assert "refresh_token" not in result
assert result["status"] == "repaired"
@@ -16,6 +16,11 @@ def load_module():
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
# Keep project resolution independent from any real .napseer/auth.json in
# the checkout. Store the handle on the module so the directory remains
# alive for the complete test invocation.
module._test_state = tempfile.TemporaryDirectory()
module.AUTH_PATH = pathlib.Path(module._test_state.name) / "auth.json"
module.AUTH = {"account_id": "acct-1", "token": "token", "token_expires_at": "later"}
module.DEFAULT_PROJECT_ID = "project-1"
module.project_memory_encryption_active = lambda project_id: False
+8 -4
View File
@@ -114,6 +114,8 @@ def encrypted_node(mod, full_path="/notes/alpha", content="secret needle", metad
def run():
mod = load_module()
test_state = tempfile.TemporaryDirectory()
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
mod.AUTH = {"account_id": ACCOUNT_ID, "token": "token", "token_expires_at": "later"}
mod.PROJECT_VAULT_PASSPHRASE = PASSPHRASE
mod.DEFAULT_PROJECT_ID = "project-1"
@@ -121,6 +123,7 @@ def run():
mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear()
cache_secret(mod)
real_active_memory_secret = mod.active_memory_secret
real_get_node_by_id = mod.get_node_by_id
envelope = mod.encrypt_memory_bytes(
"project-1",
@@ -247,8 +250,9 @@ def run():
mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear()
cache_secret(mod)
node = encrypted_node(mod, metadata={"private": "secret metadata"})
mod.get_node_by_id = real_get_node_by_id
mod.request_json = lambda method, path, payload=None, **kwargs: node
read = mod.get_node_by_path({"path": "/notes/alpha"})
read = mod.get_node_by_id({"node_id": "node-1"})
assert read["content_text"] == "secret needle"
assert read["metadata"] == {"private": "secret metadata"}
@@ -257,8 +261,8 @@ def run():
moved["folder_path"] = "/renamed"
mod.request_json = lambda method, path, payload=None, **kwargs: {"items": [moved]}
listed = mod.list_project_nodes({"limit": 10, "include_content": True})
assert listed["items"][0]["content_text"] == "secret needle"
assert listed["items"][0]["metadata"] == {"private": "secret metadata"}
assert "content_text" not in listed["items"][0]
assert listed["items"][0]["full_path"] == "/renamed/alpha"
mod.request_json = lambda method, path, payload=None, **kwargs: {"items": [moved]}
compact = mod.list_project_nodes({"limit": 10})
@@ -311,7 +315,7 @@ def run():
legacy_moved["full_path"] = "/renamed/legacy"
mod.request_json = lambda method, path, payload=None, **kwargs: legacy_moved
try:
mod.get_node_by_path({"path": "/renamed/legacy"})
mod.get_node_by_id({"node_id": "node-1"})
except RuntimeError:
pass
else:
+22 -14
View File
@@ -7,7 +7,7 @@ import sys
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "nap_install.py"
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "nap_install.py"
spec = importlib.util.spec_from_file_location("nap_install", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
@@ -25,10 +25,14 @@ def run():
assert calls == []
assert mod.handle_gateway(["vault", "--help"]) == 0
assert calls[-1] == ("gateway", ["vault", "--help"])
assert calls == []
assert mod.handle_gateway(["vault", "list"]) == 0
assert calls[-1] == ("gateway", ["vault", "list"])
try:
mod.handle_gateway(["vault", "list"])
except RuntimeError as exc:
assert "gateway vault status" in str(exc)
else:
raise AssertionError("removed gateway vault list alias must not route")
assert mod.handle_gateway(["vault", "rotate-secret", "--kind", "memory"]) == 0
assert calls[-1] == ("gateway", ["vault", "rotate-secret", "--kind", "memory"])
@@ -39,18 +43,22 @@ def run():
assert "nap gateway vault process" in str(exc), str(exc)
else:
raise AssertionError("deprecated gateway process shortcut must not route")
assert mod.main(["nap", "chat", "secret", "setup"]) == 0
assert calls[-1] == ("chat", ["secret", "setup"])
try:
mod.main(["nap", "chat", "secret", "setup"])
except RuntimeError as exc:
assert "MCP chat-secret tools" in str(exc)
else:
raise AssertionError("removed chat compatibility command must not route")
try:
mod.handle_gateway(["config"])
except RuntimeError as exc:
assert "unknown gateway subcommand" in str(exc)
assert "unknown gateway command" in str(exc)
else:
raise AssertionError("gateway config alias must not route")
try:
mod.handle_gateway(["vault-setup", "list"])
except RuntimeError as exc:
assert "unknown gateway subcommand" in str(exc)
assert "unknown gateway command" in str(exc)
else:
raise AssertionError("deprecated gateway vault-setup alias must not route")
@@ -75,12 +83,12 @@ def run():
assert "unknown" in str(exc), (argv, str(exc))
else:
raise AssertionError(f"deprecated route must not be accepted at install level: {argv}")
# nap project bootstrap is now a deprecated alias handled by the MCP server
# (the install side forwards it; the MCP side raises with a clear error message).
forwarded = []
mod.run_wrapper = lambda command, args: forwarded.append((command, args)) or 0
mod.main(["nap", "project", "bootstrap"])
assert forwarded == [("project", ["bootstrap"])], forwarded
try:
mod.main(["nap", "project", "bootstrap"])
except RuntimeError as exc:
assert "unknown project command" in str(exc)
else:
raise AssertionError("deprecated project bootstrap alias must not route")
print("ok: nap installer gateway route smoke passed")
+4
View File
@@ -4,6 +4,7 @@
import importlib.util
import pathlib
import sys
import tempfile
PROJECT_ID = "11111111-1111-1111-1111-111111111111"
@@ -21,6 +22,8 @@ def load_module():
def run():
mod = load_module()
test_state = tempfile.TemporaryDirectory()
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
saves = []
def save_auth(updates):
@@ -37,6 +40,7 @@ def run():
}
mod.save_auth = save_auth
mod.save_auth_file_credentials = save_auth
mod.replace_public_auth_state = lambda updates, clear_keys=(): save_auth(updates)
mod.request_json = lambda method, path: {
"id": PROJECT_ID,
"slug": "existing-project",
@@ -4,6 +4,7 @@
import importlib.util
import pathlib
import sys
import tempfile
def load_module():
@@ -30,6 +31,8 @@ def assert_raises_hashicorp_closed(fn):
def run():
mod = load_module()
test_state = tempfile.TemporaryDirectory()
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
calls = []
mod.DEFAULT_PROJECT_ID = "project-1"
+44
View File
@@ -0,0 +1,44 @@
"""Make supported standalone smoke programs part of the pytest gate."""
import pathlib
import subprocess
import sys
import pytest
SMOKES = [
"test_anonymous_auth_recovery.py",
"test_chat_secret_cli_flow.py",
"test_cli_dispatch_consolidation.py",
"test_gateway_cloud_routes_removed.py",
"test_gateway_configure_consistency.py",
"test_gateway_local_auth_refresh.py",
"test_gateway_local_vault_key.py",
"test_gateway_vault_setup_payload.py",
"test_live_auth_state_reload.py",
"test_mcp_memory_ergonomics_helpers.py",
"test_mcp_memory_node_encryption.py",
"test_nap_install_gateway_routes.py",
"test_project_attach_oauth.py",
"test_project_encryption_enable_payload.py",
"test_project_init_wizard.py",
"test_two_passphrase_boundary.py",
]
@pytest.mark.parametrize("filename", SMOKES)
def test_standalone_smoke(filename):
test_dir = pathlib.Path(__file__).resolve().parent
result = subprocess.run(
[sys.executable, str(test_dir / filename)],
cwd=test_dir.parent,
capture_output=True,
text=True,
timeout=90,
)
assert result.returncode == 0, (
f"{filename} failed with exit {result.returncode}\n"
f"stdout:\n{result.stdout[-4000:]}\n"
f"stderr:\n{result.stderr[-4000:]}"
)