Release resilient atomic Napseer CLI runtime
This commit is contained in:
@@ -35,33 +35,31 @@ def test_initial_enrollment_persists_refresh_credential():
|
||||
mod.AUTH = anonymous_auth(mod)
|
||||
mod.BASE_URL = mod.AUTH["base_url"]
|
||||
mod.DEFAULT_PROJECT_ID = None
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
key_path = pathlib.Path(directory) / "test-key"
|
||||
pathlib.Path(str(key_path) + ".pub").write_text(
|
||||
"ssh-ed25519 test-public-key", encoding="utf-8"
|
||||
)
|
||||
mod.ensure_local_files = lambda: key_path
|
||||
mod.sign_text = lambda *args: "test-signature"
|
||||
saved = []
|
||||
mod.save_auth = lambda payload: saved.append(dict(payload))
|
||||
responses = iter([
|
||||
{"challenge_id": "challenge", "challenge_text": "sign-me"},
|
||||
{
|
||||
"token": {
|
||||
"access_token": "access",
|
||||
"refresh_token": "refresh",
|
||||
"expires_at": "2099-01-01T00:00:00Z",
|
||||
"refresh_expires_at": "",
|
||||
},
|
||||
"worker": {"id": "worker", "agent_id": "agent-id"},
|
||||
},
|
||||
])
|
||||
mod.request_json = lambda *args, **kwargs: next(responses)
|
||||
saved = []
|
||||
requests = []
|
||||
mod.save_auth = lambda payload: saved.append(dict(payload))
|
||||
|
||||
mod.ensure_enrolled({"slug": "example"})
|
||||
def request(method, path, payload=None, token_required=False):
|
||||
requests.append((method, path, payload, token_required))
|
||||
return {
|
||||
"token": {
|
||||
"access_token": "access",
|
||||
"refresh_token": "refresh",
|
||||
"expires_at": "2099-01-01T00:00:00Z",
|
||||
"refresh_expires_at": "",
|
||||
},
|
||||
"worker": {"id": "worker", "agent_id": "agent-id"},
|
||||
}
|
||||
|
||||
mod.request_json = request
|
||||
mod.ensure_enrolled({"slug": "example"})
|
||||
|
||||
assert saved[0]["refresh_token"] == "refresh"
|
||||
assert "refresh_expires_at" in saved[0]
|
||||
assert [(method, path) for method, path, _, _ in requests] == [
|
||||
("POST", "/v1/enrollment/token")
|
||||
]
|
||||
assert requests[0][3] is False
|
||||
|
||||
|
||||
def test_failed_anonymous_refresh_uses_ssh_recovery():
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import importlib.util
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
|
||||
SCRIPT_PATH = (
|
||||
pathlib.Path(__file__).resolve().parents[1]
|
||||
/ "resources"
|
||||
/ "scripts"
|
||||
/ "nap_install.py"
|
||||
)
|
||||
|
||||
|
||||
def load_installer():
|
||||
spec = importlib.util.spec_from_file_location("nap_install_command_system_test", SCRIPT_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def fail_if_called(*_args, **_kwargs):
|
||||
raise AssertionError("help dispatched a side-effecting handler")
|
||||
|
||||
|
||||
def test_root_help_exposes_only_canonical_commands(capsys):
|
||||
module = load_installer()
|
||||
|
||||
assert module.main(["nap"]) == 0
|
||||
|
||||
output = capsys.readouterr().out
|
||||
visible = [name for name, metadata in module.COMMAND_METADATA.items() if metadata["visible"]]
|
||||
assert visible == [name for name, _summary in module.CANONICAL_COMMANDS]
|
||||
assert len(visible) == 10
|
||||
for name in visible:
|
||||
assert f" {name}" in output
|
||||
for hidden in ("chat", "plan", "lineage", "agent", "feedback", "where", "install"):
|
||||
assert f" {hidden}" not in output
|
||||
|
||||
|
||||
def test_help_is_resolved_before_mutating_dispatch(monkeypatch, capsys):
|
||||
module = load_installer()
|
||||
monkeypatch.setattr(module, "install_assets", fail_if_called)
|
||||
monkeypatch.setattr(module, "run_wrapper", fail_if_called)
|
||||
monkeypatch.setattr(module, "handle_gateway", fail_if_called)
|
||||
|
||||
assert module.main(["nap", "update", "help"]) == 0
|
||||
assert module.main(["nap", "install", "--help"]) == 0
|
||||
assert module.main(["nap", "auth", "--help"]) == 0
|
||||
assert module.main(["nap", "project", "encryption", "--help"]) == 0
|
||||
assert module.main(["nap", "gateway", "vault", "help"]) == 0
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Help never performs an update" in output
|
||||
assert "Use `nap mcp install`" in output
|
||||
assert "Usage: nap auth" in output
|
||||
assert "Usage: nap project" in output
|
||||
assert "Usage: nap gateway" in output
|
||||
|
||||
|
||||
def test_help_topic_uses_same_canonical_text(capsys):
|
||||
module = load_installer()
|
||||
|
||||
assert module.main(["nap", "help", "mcp"]) == 0
|
||||
|
||||
assert "Usage: nap mcp [status|install|update|serve]" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_init_and_auth_repair_normalize_to_existing_handlers(monkeypatch):
|
||||
module = load_installer()
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"run_wrapper",
|
||||
lambda command, args: calls.append((command, args)) or 0,
|
||||
)
|
||||
|
||||
assert module.main(["nap", "init", "--slug", "demo"]) == 0
|
||||
assert module.main(["nap", "auth", "repair"]) == 0
|
||||
|
||||
assert calls == [
|
||||
("project", ["init", "--slug", "demo"]),
|
||||
("auth", ["refresh"]),
|
||||
]
|
||||
|
||||
|
||||
def test_update_does_not_overwrite_repository_source(monkeypatch, capsys):
|
||||
module = load_installer()
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"install_assets",
|
||||
lambda: calls.append("update") or {"status": "installed"},
|
||||
)
|
||||
|
||||
assert module.main(["nap", "update"]) == 0
|
||||
|
||||
assert calls == ["update"]
|
||||
assert json.loads(capsys.readouterr().out)["status"] == "updated"
|
||||
|
||||
|
||||
def test_doctor_reports_presence_without_exposing_values(tmp_path, monkeypatch):
|
||||
module = load_installer()
|
||||
state_dir = tmp_path / ".napseer"
|
||||
state_dir.mkdir()
|
||||
auth_path = state_dir / "auth.json"
|
||||
auth_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"account_mode": "anonymous",
|
||||
"project_id": "test-project",
|
||||
"token": "test-access-value",
|
||||
"refresh_token": "test-refresh-value",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
install_dir = tmp_path / "install"
|
||||
install_dir.mkdir()
|
||||
(install_dir / "napseer_mcp_server.py").write_text("# worker\n", encoding="utf-8")
|
||||
(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,
|
||||
"state_dir_status",
|
||||
lambda: {
|
||||
"active_auth_path": str(auth_path),
|
||||
"state_dir_exists": True,
|
||||
},
|
||||
)
|
||||
|
||||
result = module.doctor_status()
|
||||
serialized = json.dumps(result)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["checks"]["token_present"] is True
|
||||
assert result["checks"]["refresh_present"] is True
|
||||
assert "test-access-value" not in serialized
|
||||
assert "test-refresh-value" not in serialized
|
||||
|
||||
|
||||
def test_doctor_does_not_create_state_directory(tmp_path, monkeypatch):
|
||||
module = load_installer()
|
||||
state_dir = tmp_path / ".napseer"
|
||||
install_dir = tmp_path / "install"
|
||||
install_dir.mkdir()
|
||||
(install_dir / "napseer_mcp_server.py").write_text("# worker\n", encoding="utf-8")
|
||||
(install_dir / "napseer_mcp_supervisor.py").write_text("# supervisor\n", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
|
||||
monkeypatch.setattr(module, "runtime_assets_missing", lambda: [])
|
||||
|
||||
result = module.doctor_status()
|
||||
|
||||
assert result["status"] == "repair_required"
|
||||
assert not state_dir.exists()
|
||||
|
||||
|
||||
def test_wrapper_dispatch_never_installs_missing_runtime(monkeypatch):
|
||||
module = load_installer()
|
||||
monkeypatch.setattr(module, "runtime_assets_missing", lambda: ["napseer_mcp_server.py"])
|
||||
monkeypatch.setattr(module, "install_assets", fail_if_called)
|
||||
|
||||
try:
|
||||
module.run_wrapper("configure", [])
|
||||
except RuntimeError as exc:
|
||||
assert "nap mcp install" in str(exc)
|
||||
else:
|
||||
raise AssertionError("missing runtime should require an explicit repair command")
|
||||
|
||||
|
||||
def fake_bundle(module, revision="a" * 40, broken_name=None):
|
||||
contents = {
|
||||
name: f"#!/usr/bin/env python3\nVALUE = {index!r}\n"
|
||||
for index, name in enumerate(module.SCRIPT_NAMES)
|
||||
}
|
||||
items = []
|
||||
payloads = {}
|
||||
for name in module.SCRIPT_NAMES:
|
||||
content = contents[name]
|
||||
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
item = {
|
||||
"name": name,
|
||||
"version": module.CLI_RELEASE_VERSION,
|
||||
"bytes": len(content.encode("utf-8")),
|
||||
"sha256": digest,
|
||||
"source_repo": "https://github.com/napseer/nap-cli",
|
||||
"source_revision": revision,
|
||||
"source_revision_status": "resolved",
|
||||
"contract_version": module.CLI_DISTRIBUTION_CONTRACT_VERSION,
|
||||
"minimum_contract_version": module.CLI_MINIMUM_CONTRACT_VERSION,
|
||||
"install_path": module.INSTALL_PATHS[name],
|
||||
"mode": "0755",
|
||||
}
|
||||
items.append(item)
|
||||
payloads[name] = ({**item, "content": content}, content)
|
||||
if broken_name:
|
||||
payload, content = payloads[broken_name]
|
||||
payloads[broken_name] = (payload, content + "not valid python !!!")
|
||||
manifest = {
|
||||
"schema_version": module.CLI_BUNDLE_SCHEMA_VERSION,
|
||||
"bundle_id": f"nap-cli:{module.CLI_RELEASE_VERSION}:{revision}",
|
||||
"release_version": module.CLI_RELEASE_VERSION,
|
||||
"published_at": "2026-07-29T00:00:00Z",
|
||||
"contract": {
|
||||
"current": module.CLI_DISTRIBUTION_CONTRACT_VERSION,
|
||||
"minimum_supported": module.CLI_MINIMUM_CONTRACT_VERSION,
|
||||
},
|
||||
"source": {
|
||||
"repo": "https://github.com/napseer/nap-cli",
|
||||
"revision": revision,
|
||||
"revision_status": "resolved",
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
return manifest, payloads
|
||||
|
||||
|
||||
def test_bundle_install_stages_validates_and_activates_atomically(tmp_path, monkeypatch):
|
||||
module = load_installer()
|
||||
install_dir = tmp_path / "install"
|
||||
bin_dir = tmp_path / "bin"
|
||||
monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
|
||||
monkeypatch.setattr(module, "BIN_DIR", bin_dir)
|
||||
manifest, payloads = fake_bundle(module)
|
||||
monkeypatch.setattr(module, "bundle_manifest", lambda: manifest)
|
||||
monkeypatch.setattr(module, "fetch_script", lambda name: payloads[name])
|
||||
|
||||
result = module.install_assets()
|
||||
|
||||
assert result["cli_distribution"]["release_version"] == "0.2.0"
|
||||
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()
|
||||
assert (install_dir / "current" / "terminal" / "protocol.py").is_file()
|
||||
assert (install_dir / "napseer_mcp_supervisor.py").is_symlink()
|
||||
assert (bin_dir / "nap").is_symlink()
|
||||
assert module.version_status()["source_revision"] == "a" * 40
|
||||
|
||||
|
||||
def test_failed_bundle_validation_keeps_previous_release_active(tmp_path, monkeypatch):
|
||||
module = load_installer()
|
||||
monkeypatch.setattr(module, "INSTALL_DIR", tmp_path / "install")
|
||||
monkeypatch.setattr(module, "BIN_DIR", tmp_path / "bin")
|
||||
first_manifest, first_payloads = fake_bundle(module, revision="a" * 40)
|
||||
monkeypatch.setattr(module, "bundle_manifest", lambda: first_manifest)
|
||||
monkeypatch.setattr(module, "fetch_script", lambda name: first_payloads[name])
|
||||
module.install_assets()
|
||||
active_before = (module.INSTALL_DIR / "current").resolve()
|
||||
|
||||
broken_manifest, broken_payloads = fake_bundle(
|
||||
module,
|
||||
revision="b" * 40,
|
||||
broken_name="napseer_mcp_server.py",
|
||||
)
|
||||
monkeypatch.setattr(module, "bundle_manifest", lambda: broken_manifest)
|
||||
monkeypatch.setattr(module, "fetch_script", lambda name: broken_payloads[name])
|
||||
|
||||
try:
|
||||
module.install_assets()
|
||||
except RuntimeError as exc:
|
||||
assert "does not match bundle manifest" in str(exc)
|
||||
else:
|
||||
raise AssertionError("invalid bundle must not activate")
|
||||
|
||||
assert (module.INSTALL_DIR / "current").resolve() == active_before
|
||||
assert not list((module.INSTALL_DIR / "releases").glob(".staging-*"))
|
||||
|
||||
|
||||
def test_manifest_rejects_path_traversal():
|
||||
module = load_installer()
|
||||
manifest, _payloads = fake_bundle(module)
|
||||
target = next(item for item in manifest["items"] if item["name"] == "nap_install.py")
|
||||
target["install_path"] = "../nap_install.py"
|
||||
|
||||
try:
|
||||
module.validated_manifest_items(manifest)
|
||||
except RuntimeError as exc:
|
||||
assert "unsafe install_path" in str(exc)
|
||||
else:
|
||||
raise AssertionError("unsafe manifest path must be rejected")
|
||||
@@ -6,11 +6,12 @@ Verifies that:
|
||||
are accepted and route to the right function.
|
||||
- dropped aliases (ls, new, send, rm, set-status, admin, global-list, etc.)
|
||||
now raise RuntimeError with a clear pointer to the canonical verb.
|
||||
- `nap project encryption set` requires --state; positional-state shortcut is gone.
|
||||
- `nap project encryption plaintext` (action-as-state) now errors.
|
||||
- project encryption compatibility forms normalize to one transition handler.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import contextlib
|
||||
import io
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
@@ -155,35 +156,48 @@ def test_gateway_rotate_passphrase_aliases_rejected():
|
||||
expect_unknown(mod, args, "rotate-passphrase")
|
||||
|
||||
|
||||
def test_project_encryption_state_flag_required():
|
||||
"""nap project encryption set requires --state; positional state is rejected."""
|
||||
def dispatch_encryption(mod, argv):
|
||||
calls = []
|
||||
mod.project_encryption_transition = lambda args: (
|
||||
calls.append(dict(args)) or {"status": "updated", "state": args["state"]}
|
||||
)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
mod.cli_main(argv)
|
||||
return calls
|
||||
|
||||
|
||||
def test_project_encryption_set_compatibility_form():
|
||||
"""The explicit set form routes to the canonical transition handler."""
|
||||
mod = load_module()
|
||||
# Without --state, the dispatch should raise
|
||||
expect_unknown(
|
||||
calls = dispatch_encryption(
|
||||
mod,
|
||||
["napseer_mcp_server.py", "project", "encryption", "set", "plaintext"],
|
||||
"--state plaintext|encrypted",
|
||||
)
|
||||
assert calls == [{"state": "plaintext", "passphrase": None}]
|
||||
|
||||
|
||||
def test_project_encryption_action_as_state_rejected():
|
||||
"""`nap project encryption plaintext` (the old shortcut) must error."""
|
||||
def test_project_encryption_action_as_state_is_hidden_compatibility():
|
||||
mod = load_module()
|
||||
expect_unknown(
|
||||
calls = dispatch_encryption(
|
||||
mod,
|
||||
["napseer_mcp_server.py", "project", "encryption", "plaintext"],
|
||||
"project encryption",
|
||||
)
|
||||
assert calls == [{"state": "plaintext", "passphrase": None}]
|
||||
|
||||
|
||||
def test_project_encryption_show_alias_rejected():
|
||||
def test_project_encryption_status_aliases_are_read_only_compatibility():
|
||||
mod = load_module()
|
||||
for args in [
|
||||
calls = []
|
||||
mod.project_encryption_status = lambda args: (
|
||||
calls.append(dict(args)) or {"status": "ok", "state": "plaintext"}
|
||||
)
|
||||
for argv in [
|
||||
["napseer_mcp_server.py", "project", "encryption", "state"],
|
||||
["napseer_mcp_server.py", "project", "encryption", "show"],
|
||||
["napseer_mcp_server.py", "project", "encryption", "encrypted"],
|
||||
]:
|
||||
expect_unknown(mod, args, "project encryption")
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
mod.cli_main(argv)
|
||||
assert calls == [{}, {}]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -198,7 +212,7 @@ if __name__ == "__main__":
|
||||
test_gateway_service_run_rejected()
|
||||
test_gateway_vault_aliases_rejected()
|
||||
test_gateway_rotate_passphrase_aliases_rejected()
|
||||
test_project_encryption_state_flag_required()
|
||||
test_project_encryption_action_as_state_rejected()
|
||||
test_project_encryption_show_alias_rejected()
|
||||
test_project_encryption_set_compatibility_form()
|
||||
test_project_encryption_action_as_state_is_hidden_compatibility()
|
||||
test_project_encryption_status_aliases_are_read_only_compatibility()
|
||||
print("ok: cli dispatch consolidation tests passed")
|
||||
|
||||
@@ -246,141 +246,62 @@ def test_node_get_rejects_path_reads(mod):
|
||||
|
||||
|
||||
def test_tools_include_node_by_path_descriptor(mod):
|
||||
tool_by_name = {item["name"]: item for item in mod.tools()}
|
||||
assert len(tool_by_name) == 56
|
||||
assert len(tool_by_name) == len(mod.tools())
|
||||
|
||||
get_tool = next(item for item in mod.tools() if item["name"] == "nap_node_get")
|
||||
assert get_tool["inputSchema"].get("required") is None
|
||||
assert "node_id" in get_tool["inputSchema"]["properties"]
|
||||
assert "uri" in get_tool["inputSchema"]["properties"]
|
||||
assert "path" not in get_tool["inputSchema"]["properties"]
|
||||
|
||||
by_id = next(item for item in mod.tools() if item["name"] == "nap_node_by_id")
|
||||
assert by_id["inputSchema"]["required"] == ["node_id"]
|
||||
assert "nap:// URI" in by_id["description"]
|
||||
|
||||
by_uri = next(item for item in mod.tools() if item["name"] == "nap_node_by_uri")
|
||||
assert by_uri["inputSchema"]["required"] == ["uri"]
|
||||
assert "nap://" in by_uri["description"]
|
||||
|
||||
tool = next(item for item in mod.tools() if item["name"] == "nap_node_by_path")
|
||||
assert tool["inputSchema"]["required"] == ["path"]
|
||||
assert "preview_chars" not in tool["inputSchema"]["properties"]
|
||||
assert "does not read full node content" in tool["description"]
|
||||
|
||||
assert all(item["name"] != "nap_cat" for item in mod.tools())
|
||||
for hidden_name in [
|
||||
"nap_cat",
|
||||
"nap_node_by_id",
|
||||
"nap_node_by_uri",
|
||||
"nap_tee",
|
||||
"nap_patch",
|
||||
"nap_find",
|
||||
"nap_index_sync",
|
||||
]:
|
||||
assert hidden_name not in tool_by_name
|
||||
|
||||
for name in ["nap_discover", "nap_find", "nap_context", "nap_find_related", "nap_recent"]:
|
||||
for name in ["nap_discover", "nap_context", "nap_find_related", "nap_recent"]:
|
||||
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"]
|
||||
view = schema["properties"].get("view")
|
||||
if view:
|
||||
assert "full" not in view["enum"]
|
||||
|
||||
for name in ["nap_patch", "nap_node_patch", "nap_rm"]:
|
||||
for name in ["nap_node_patch", "nap_rm"]:
|
||||
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"]
|
||||
assert "node_id" in schema["properties"]
|
||||
assert "uri" in schema["properties"]
|
||||
assert "path" not in schema["properties"]
|
||||
|
||||
tee_schema = next(item for item in mod.tools() if item["name"] == "nap_tee")["inputSchema"]
|
||||
assert tee_schema.get("required") is None
|
||||
assert "path" in tee_schema["properties"]
|
||||
assert "node_id" in tee_schema["properties"]
|
||||
assert "uri" in tee_schema["properties"]
|
||||
|
||||
bulk_node_schema = next(item for item in mod.tools() if item["name"] == "nap_bulk")["inputSchema"]["properties"]["nodes"]["items"]
|
||||
assert bulk_node_schema.get("required") is None
|
||||
assert "path" in bulk_node_schema["properties"]
|
||||
assert "node_id" in bulk_node_schema["properties"]
|
||||
assert "uri" in bulk_node_schema["properties"]
|
||||
|
||||
for name in [
|
||||
"nap_kanban_start",
|
||||
"nap_kanban_send_review",
|
||||
"nap_kanban_complete",
|
||||
"nap_kanban_block",
|
||||
"nap_kanban_unblock",
|
||||
"nap_kanban_update",
|
||||
"nap_kanban_archive",
|
||||
for required_name in [
|
||||
"nap_library_ls",
|
||||
"nap_library_attach",
|
||||
"nap_library_detach",
|
||||
"nap_library_nodes",
|
||||
"nap_library_node_get",
|
||||
"nap_auth_refresh",
|
||||
"nap_node_restore",
|
||||
"nap_plan_transition",
|
||||
"nap_index_status",
|
||||
]:
|
||||
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"]
|
||||
assert "node_id" in schema["properties"]
|
||||
assert "uri" in schema["properties"]
|
||||
assert "path" not in schema["properties"]
|
||||
|
||||
mv_schema = next(item for item in mod.tools() if item["name"] == "nap_mv")["inputSchema"]
|
||||
assert mv_schema["required"] == ["new_path"]
|
||||
assert "node_id" in mv_schema["properties"]
|
||||
assert "uri" in mv_schema["properties"]
|
||||
assert "path" not in mv_schema["properties"]
|
||||
|
||||
ln_schema = next(item for item in mod.tools() if item["name"] == "nap_ln")["inputSchema"]
|
||||
assert "source_node_id" in ln_schema["properties"]
|
||||
assert "source_uri" in ln_schema["properties"]
|
||||
assert "target_node_id" in ln_schema["properties"]
|
||||
assert "target_uri" in ln_schema["properties"]
|
||||
assert "source_path" not in ln_schema["properties"]
|
||||
assert "target_path" not in ln_schema["properties"]
|
||||
|
||||
backlinks_schema = next(item for item in mod.tools() if item["name"] == "nap_backlinks")["inputSchema"]
|
||||
assert "node_id" in backlinks_schema["properties"]
|
||||
assert "uri" in backlinks_schema["properties"]
|
||||
assert "path" not in backlinks_schema["properties"]
|
||||
|
||||
context_schema = next(item for item in mod.tools() if item["name"] == "nap_context")["inputSchema"]
|
||||
assert "node_id" in context_schema["properties"]
|
||||
assert "node_ids" in context_schema["properties"]
|
||||
assert "uri" in context_schema["properties"]
|
||||
assert "uris" in context_schema["properties"]
|
||||
assert "path" in context_schema["properties"]
|
||||
assert "paths" in context_schema["properties"]
|
||||
|
||||
related_schema = next(item for item in mod.tools() if item["name"] == "nap_find_related")["inputSchema"]
|
||||
assert "node_id" in related_schema["properties"]
|
||||
assert "uri" in related_schema["properties"]
|
||||
assert "path" not in related_schema["properties"]
|
||||
|
||||
complete_schema = next(item for item in mod.tools() if item["name"] == "nap_plan_complete")["inputSchema"]
|
||||
assert "plan_node_id" in complete_schema["properties"]
|
||||
assert "plan_uri" in complete_schema["properties"]
|
||||
assert "outcome_node_id" in complete_schema["properties"]
|
||||
assert "outcome_uri" in complete_schema["properties"]
|
||||
assert "path" not in complete_schema["properties"]
|
||||
assert "outcome_path" not in complete_schema["properties"]
|
||||
|
||||
supersede_schema = next(item for item in mod.tools() if item["name"] == "nap_plan_supersede")["inputSchema"]
|
||||
assert "required" not in supersede_schema
|
||||
assert "plan_node_id" in supersede_schema["properties"]
|
||||
assert "replacement_node_id" in supersede_schema["properties"]
|
||||
assert "path" not in supersede_schema["properties"]
|
||||
assert "replacement_path" not in supersede_schema["properties"]
|
||||
|
||||
cancel_schema = next(item for item in mod.tools() if item["name"] == "nap_plan_cancel")["inputSchema"]
|
||||
assert "required" not in cancel_schema
|
||||
assert "plan_node_id" in cancel_schema["properties"]
|
||||
assert "path" not in cancel_schema["properties"]
|
||||
|
||||
plan_to_kanban_schema = next(item for item in mod.tools() if item["name"] == "nap_plan_to_kanban")["inputSchema"]
|
||||
assert "plan_node_id" in plan_to_kanban_schema["properties"]
|
||||
assert "plan_uri" in plan_to_kanban_schema["properties"]
|
||||
assert "plan_path" not in plan_to_kanban_schema["properties"]
|
||||
|
||||
for name in ["nap_agent_cat", "nap_agent_patch", "nap_agent_rm"]:
|
||||
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"]
|
||||
assert schema["required"] == ["agent_slug"]
|
||||
assert "node_id" in schema["properties"]
|
||||
assert "uri" in schema["properties"]
|
||||
assert "path" not in schema["properties"]
|
||||
|
||||
agent_tee_schema = next(item for item in mod.tools() if item["name"] == "nap_agent_tee")["inputSchema"]
|
||||
assert agent_tee_schema["required"] == ["agent_slug"]
|
||||
assert "path" in agent_tee_schema["properties"]
|
||||
assert "node_id" in agent_tee_schema["properties"]
|
||||
assert "uri" in agent_tee_schema["properties"]
|
||||
|
||||
agent_ln_schema = next(item for item in mod.tools() if item["name"] == "nap_agent_ln")["inputSchema"]
|
||||
assert agent_ln_schema["required"] == ["agent_slug"]
|
||||
assert "source_node_id" in agent_ln_schema["properties"]
|
||||
assert "target_node_id" in agent_ln_schema["properties"]
|
||||
assert "source_path" not in agent_ln_schema["properties"]
|
||||
assert "target_path" not in agent_ln_schema["properties"]
|
||||
assert required_name in tool_by_name
|
||||
|
||||
|
||||
def test_mutation_tools_reject_path_and_patch_by_node_id(mod):
|
||||
@@ -508,15 +429,21 @@ def test_tee_and_bulk_create_by_path_update_by_identity(mod):
|
||||
assert writes[-1][0] == "PATCH"
|
||||
assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123"
|
||||
|
||||
with pytest.raises(RuntimeError, match="path already resolves"):
|
||||
mod.upsert_node({"path": "/notes/current", "content_text": "bad"})
|
||||
compatibility_update = mod.upsert_node(
|
||||
{"path": "/notes/current", "content_text": "compatibility update"}
|
||||
)
|
||||
assert compatibility_update["updated"] is True
|
||||
assert writes[-1][0] == "PATCH"
|
||||
|
||||
bulk = mod.bulk_upsert_nodes({"nodes": [{"node_id": "node-123", "content_text": "bulk update"}]})
|
||||
assert bulk["items"][0]["created"] is False
|
||||
assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123"
|
||||
|
||||
with pytest.raises(RuntimeError, match="path already resolves"):
|
||||
mod.bulk_upsert_nodes({"nodes": [{"path": "/notes/current", "content_text": "bad"}]})
|
||||
compatibility_bulk_update = mod.bulk_upsert_nodes(
|
||||
{"nodes": [{"path": "/notes/current", "content_text": "compatibility update"}]}
|
||||
)
|
||||
assert compatibility_bulk_update["items"][0]["created"] is False
|
||||
assert writes[-1][0] == "PATCH"
|
||||
|
||||
|
||||
def test_agent_tools_create_by_route_and_update_by_identity(mod):
|
||||
@@ -867,12 +794,12 @@ def test_index_sync_fetches_incremental_changes_and_removes_archived_nodes(mod):
|
||||
assert mod.search_local_index({"q": "existing"})["items"] == []
|
||||
|
||||
|
||||
def test_tools_include_index_sync_descriptor(mod):
|
||||
tool = next(item for item in mod.tools() if item["name"] == "nap_index_sync")
|
||||
assert "Incrementally synchronize" in tool["description"]
|
||||
assert tool["inputSchema"]["properties"]["lookback_seconds"]["default"] == 5
|
||||
contract = mod.tool_contract_metadata("nap_index_sync")
|
||||
assert contract["side_effect"] == "local-file"
|
||||
def test_tools_include_read_only_index_status_descriptor(mod):
|
||||
tool = next(item for item in mod.tools() if item["name"] == "nap_index_status")
|
||||
assert "local search index" in tool["description"]
|
||||
assert tool["inputSchema"]["properties"] == {}
|
||||
contract = mod.tool_contract_metadata("nap_index_status")
|
||||
assert contract["side_effect"] == "none"
|
||||
assert contract["auth_mode"] == "local-only"
|
||||
|
||||
|
||||
|
||||
@@ -266,7 +266,11 @@ def run():
|
||||
assert compact["view"] == "paths"
|
||||
assert "content_text" not in compact["items"][0]
|
||||
assert compact["items"][0]["full_path"] == "/renamed/alpha"
|
||||
full = mod.list_project_nodes({"limit": 10, "view": "full"})
|
||||
full = mod.list_project_nodes({
|
||||
"limit": 10,
|
||||
"view": "full",
|
||||
"_allow_full": True,
|
||||
})
|
||||
assert full["items"][0]["content_text"] == "secret needle"
|
||||
|
||||
plan_base = {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import select
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
SUPERVISOR = ROOT / "resources" / "scripts" / "napseer_mcp_supervisor.py"
|
||||
|
||||
|
||||
def write_worker(path: pathlib.Path, version: int) -> None:
|
||||
path.write_text(
|
||||
f"""\
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
VERSION = {version}
|
||||
|
||||
for line in sys.stdin:
|
||||
message = json.loads(line)
|
||||
if message.get("method") == "notifications/initialized":
|
||||
continue
|
||||
if message.get("method") == "tools/call":
|
||||
name = (message.get("params") or {{}}).get("name")
|
||||
if name == "crash":
|
||||
os._exit(17)
|
||||
result = {{"pid": os.getpid(), "version": VERSION, "name": name}}
|
||||
else:
|
||||
result = {{"version": VERSION}}
|
||||
print(json.dumps({{"jsonrpc": "2.0", "id": message.get("id"), "result": result}}), flush=True)
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
class SupervisorClient:
|
||||
def __init__(self, worker_path: pathlib.Path):
|
||||
environment = os.environ.copy()
|
||||
environment["NAPSEER_MCP_WORKER_PATH"] = str(worker_path)
|
||||
environment["NAPSEER_MCP_RESPONSE_TIMEOUT_SECONDS"] = "5"
|
||||
self.process = subprocess.Popen(
|
||||
[sys.executable, str(SUPERVISOR)],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=environment,
|
||||
)
|
||||
|
||||
def request(self, request_id: int, method: str, params=None):
|
||||
assert self.process.stdin is not None
|
||||
assert self.process.stdout is not None
|
||||
message = {"jsonrpc": "2.0", "id": request_id, "method": method}
|
||||
if params is not None:
|
||||
message["params"] = params
|
||||
self.process.stdin.write(json.dumps(message).encode("utf-8") + b"\n")
|
||||
self.process.stdin.flush()
|
||||
readable, _, _ = select.select([self.process.stdout], [], [], 8)
|
||||
assert readable, "supervisor did not return a response"
|
||||
return json.loads(self.process.stdout.readline())
|
||||
|
||||
def close(self):
|
||||
if self.process.poll() is None:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=5)
|
||||
|
||||
|
||||
def test_restarts_worker_killed_between_requests(tmp_path):
|
||||
worker_path = tmp_path / "worker.py"
|
||||
write_worker(worker_path, 1)
|
||||
client = SupervisorClient(worker_path)
|
||||
try:
|
||||
first = client.request(1, "tools/call", {"name": "pid", "arguments": {}})
|
||||
os.kill(first["result"]["pid"], signal.SIGTERM)
|
||||
time.sleep(0.1)
|
||||
|
||||
second = client.request(2, "tools/list")
|
||||
|
||||
assert second["result"]["version"] == 1
|
||||
assert client.process.poll() is None
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_reloads_replaced_worker_between_requests(tmp_path):
|
||||
worker_path = tmp_path / "worker.py"
|
||||
write_worker(worker_path, 1)
|
||||
client = SupervisorClient(worker_path)
|
||||
try:
|
||||
assert client.request(1, "initialize")["result"]["version"] == 1
|
||||
time.sleep(0.01)
|
||||
write_worker(worker_path, 2)
|
||||
os.utime(worker_path, None)
|
||||
|
||||
assert client.request(2, "tools/list")["result"]["version"] == 2
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_worker_crash_returns_error_without_closing_transport(tmp_path):
|
||||
worker_path = tmp_path / "worker.py"
|
||||
write_worker(worker_path, 1)
|
||||
client = SupervisorClient(worker_path)
|
||||
try:
|
||||
failed = client.request(1, "tools/call", {"name": "crash", "arguments": {}})
|
||||
recovered = client.request(2, "tools/list")
|
||||
|
||||
assert failed["error"]["code"] == -32098
|
||||
assert recovered["result"]["version"] == 1
|
||||
assert client.process.poll() is None
|
||||
finally:
|
||||
client.close()
|
||||
@@ -163,13 +163,36 @@ def test_cli_main_dispatches_init():
|
||||
print("ok: cli_main routes `project init` to cli_project_init passed")
|
||||
|
||||
|
||||
def test_cli_main_rejects_old_aliases():
|
||||
"""Verify `auth login` / `project bootstrap-existing` are no longer accepted."""
|
||||
mod = load_module()
|
||||
def test_cli_main_accepts_canonical_login_and_hidden_compatibility_alias():
|
||||
"""Login aliases must route to one handler without performing browser or network I/O."""
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
mod = load_module()
|
||||
calls = []
|
||||
mod.operator_account_login = lambda args: (
|
||||
calls.append(dict(args)) or {"status": "authenticated"}
|
||||
)
|
||||
|
||||
for subcommand in ["login", "login-local", "operator-login"]:
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
mod.cli_main([
|
||||
"napseer_mcp_server.py",
|
||||
"auth",
|
||||
subcommand,
|
||||
"--no-browser",
|
||||
"--timeout",
|
||||
"1",
|
||||
])
|
||||
|
||||
assert len(calls) == 3
|
||||
assert all(call["open_browser"] is False for call in calls)
|
||||
assert all(call["timeout_seconds"] == 1 for call in calls)
|
||||
|
||||
|
||||
def test_cli_main_rejects_project_login_aliases():
|
||||
mod = load_module()
|
||||
cases = [
|
||||
(["napseer_mcp_server.py", "auth", "login"], "unknown auth subcommand: login"),
|
||||
(["napseer_mcp_server.py", "auth", "operator-login"], "unknown auth subcommand: operator-login"),
|
||||
(["napseer_mcp_server.py", "project", "login"], "unknown project subcommand: login"),
|
||||
(["napseer_mcp_server.py", "project", "bootstrap-existing"], "unknown project subcommand: bootstrap-existing"),
|
||||
]
|
||||
@@ -180,7 +203,7 @@ def test_cli_main_rejects_old_aliases():
|
||||
assert expected_substring in str(exc), (args, str(exc))
|
||||
else:
|
||||
raise AssertionError(f"expected RuntimeError for {args}")
|
||||
print("ok: cli_main rejects old auth/project aliases with clear errors passed")
|
||||
print("ok: cli_main rejects old project aliases with clear errors passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -188,5 +211,6 @@ if __name__ == "__main__":
|
||||
test_init_already_initialized()
|
||||
test_init_token_only_creates_project()
|
||||
test_cli_main_dispatches_init()
|
||||
test_cli_main_rejects_old_aliases()
|
||||
test_cli_main_accepts_canonical_login_and_hidden_compatibility_alias()
|
||||
test_cli_main_rejects_project_login_aliases()
|
||||
print("ok: project init wizard smoke tests passed")
|
||||
|
||||
Reference in New Issue
Block a user