Author SHA1 Message Date
HumbleTurtle c179581d58 docs: align agent discovery and project help with configured access 2026-09-10 04:21:04 -03:00
HumbleTurtle cec2f88375 docs: correct the shared user credential storage path 2026-09-10 04:17:19 -03:00
HumbleTurtle 1cedc1b927 fix: make the published stdin bootstrap install the runtime bundle 2026-09-10 04:10:40 -03:00
HumbleTurtle 3dfedd1f35 test: isolate session fixtures from developer project credentials 2026-09-10 03:34:46 -03:00
HumbleTurtle 6c29a318e8 feat: configure agents with durable user sessions and scoped access 2026-09-10 03:29:48 -03:00
HumbleTurtle e8c6a5109e fix: bound MCP request cancellation and recovery 2026-09-05 02:20:58 -03:00
HumbleTurtle f8594bd3d8 feat: add portable project locator 2026-08-27 19:11:33 -03:00
HumbleTurtle 7eace14042 fix: expose local index rebuild command 2026-08-27 00:41:28 -03:00
HumbleTurtle 35acd72e4e feat: add anchor shortcut navigation 2026-08-26 19:20:06 -03:00
HumbleTurtle 6b308d5a49 fix: harden node patch contract diagnostics 2026-08-26 07:59:16 -03:00
HumbleTurtle 66ca3ab9be fix: make memory graph results actionable 2026-08-26 05:09:52 -03:00
HumbleTurtle 4fa36796b4 Optimize concurrent local discovery 2026-08-23 19:48:08 -03:00
HumbleTurtle e283b6b62f feat(mcp): report search projection coverage 2026-08-22 22:45:58 -03:00
HumbleTurtle f47459ba9b perf(mcp): plan and rank memory queries once 2026-08-22 20:15:33 -03:00
HumbleTurtle e410b5d4a5 fix(mcp): align discovery identity guidance 2026-08-22 19:39:39 -03:00
HumbleTurtle 4254cac826 perf(mcp): keep local read flows local 2026-08-22 19:28:41 -03:00
HumbleTurtle 2f61616a6f feat(mcp): add dependency-aware parallel batch 2026-08-22 18:42:31 -03:00
HumbleTurtle cc53da1dc7 fix: align local and server graph depth semantics 2026-08-21 22:42:31 -03:00
HumbleTurtle a9a682dee0 fix: gate hybrid graph reads on complete local indexes 2026-08-21 22:37:05 -03:00
HumbleTurtle 8921b131ef fix: reload MCP launchers when runtime changes 2026-08-21 22:33:56 -03:00
26 changed files with 6180 additions and 696 deletions
+58 -12
View File
@@ -25,9 +25,16 @@ Current state:
`tools/list`, and authenticated read probe. They deliberately report an `tools/list`, and authenticated read probe. They deliberately report an
existing client connection as `not_observable`; a successful fresh probe existing client connection as `not_observable`; a successful fresh probe
does not claim that a previously opened Codex transport is connected. does not claim that a previously opened Codex transport is connected.
- Operator OAuth credentials remain in `.napseer/auth.json`. A repaired local - `nap auth login` stores the shared account session in the user data folder
gateway uses a separate `.napseer/gateway-auth.json` worker identity, so (`~/.local/share/napseer/credentials/default.json` on Linux; platform user data directories
relay token renewal cannot rotate or overwrite the operator/MCP session. on macOS/Windows). `NAPSEER_USER_DATA_DIR` overrides that location.
`nap auth login --project` selects a local override. Legacy repository
credentials remain an explicit override until `nap auth migrate` moves them.
Gateway identities remain separate in `.napseer/gateway-auth.json`.
- `.napseer/project.json` is the commit-safe project locator. It contains only
the schema, API origin, project UUID, and slug; it never contains tokens,
account or worker identity, encryption state, keys, passphrases, or claim
links. A fresh clone uses it to attach the intended project.
- CLI terminal and schedule operations call the running gateway's - CLI terminal and schedule operations call the running gateway's
loopback-only, CSRF-protected control API. PTYs therefore live in the daemon loopback-only, CSRF-protected control API. PTYs therefore live in the daemon
instead of disappearing when a CLI subprocess exits. instead of disappearing when a CLI subprocess exits.
@@ -46,24 +53,63 @@ directory, add a trusted project `.codex/config.toml` override with both
`cwd` and `NAPSEER_PROJECT_ROOT`. MCP processes inherit their launch directory; `cwd` and `NAPSEER_PROJECT_ROOT`. MCP processes inherit their launch directory;
they cannot infer a later client workspace switch over stdio. they cannot infer a later client workspace switch over stdio.
If `NAPSEER_MCP_WORKER_PATH` points to a stable launcher that loads a separate,
replaceable runtime, set `NAPSEER_MCP_WORKER_WATCH_PATHS` to the
`os.pathsep`-separated runtime paths. The supervisor then reloads the launcher
after any watched runtime is atomically replaced, preserving the same bounded,
non-replaying request lifecycle used for direct workers.
MCP admits at most 32 unfinished requests by default, including cancelled work
that is still cleaning up. `-32097` means a request was rejected before
execution; retry after capacity is available. `-32098` means the result
is uncertain: inspect current state before retrying a mutation. Cancellation
prevents queued mutations from starting and attempts to release acquired locks.
It cannot undo a mutation already accepted by the API.
The supervisor waits up to 60 seconds for a response, then requests cancellation.
After 10 seconds without a cleanup acknowledgement, it terminates the worker and
fails affected requests without replay. Worker input writes have a five-second
deadline. Override these limits with `NAPSEER_MCP_RESPONSE_TIMEOUT_SECONDS`,
`NAPSEER_MCP_CANCEL_GRACE_SECONDS`, `NAPSEER_MCP_WRITE_TIMEOUT_SECONDS`, and
`NAPSEER_MCP_MAX_IN_FLIGHT_REQUESTS`. New source loads only after unfinished work
has drained. `nap_contract` exposes the runtime's request lifecycle contract.
These process recovery guarantees require the supervisor; the direct worker is
for isolated protocol debugging. An already-running older supervisor needs a
client reconnect after updating.
The public operator surface is intentionally small: The public operator surface is intentionally small:
```text ```text
nap init | status | doctor | auth | project | mcp | gateway | update | version | help nap init | status | doctor | auth | project | files | export | mcp | gateway | update | version | help
``` ```
Authentication has one recovery verb: `nap auth repair`. Normal refresh is Authentication has one recovery verb: `nap auth repair`. Normal refresh is
automatic. `nap gateway repair` creates the separate worker identity once; automatic. `nap gateway repair` creates the separate worker identity once;
`--replace` is required to replace an existing identity. `--replace` is required to replace an existing identity.
Project-scoped MCP tools are zero-login by default. In a new workspace, the Agents start only after the user configures an account login or a delegated API
first such tool enrolls an anonymous worker and creates its first project key. With an account login, `nap init` creates a project owned by that account.
automatically; `nap init` remains the explicit equivalent. Anonymous access A committed `.napseer/project.json` always selects the existing project; it
and refresh-token or SSH-key recovery continue without operator never causes a replacement to be created. Concurrent refreshes share a bounded
authentication. Authentication through `nap project claim` is required only credential lock, while project overrides remain independent. Network failures
after the anonymous account reaches its service limit (currently one project preserve credentials and offer recovery through `nap auth repair`.
or 100 stored nodes), or when its durable local enrollment identity is no
longer recoverable. `nap auth keys --help` describes scoped API key issuance;
`nap auth api-key --env VARIABLE_NAME` installs a delegated key privately.
Keys can expire or be revoked and cannot approve agent decisions. Use separate
keys when agents need separate identities or permissions.
Upgrading from 0.2.x to 0.3.0 requires the current bootstrap once because old
installers have a fixed list of runtime files. `nap update` on those versions
rejects the new bundle before changing the active installation. Run:
```sh
curl -fsSL https://api.napseer.com/install | python3 -
```
The bootstrap verifies every asset and switches the runtime atomically without
changing credentials or project locators. Restart existing MCP client sessions
after this upgrade; subsequent releases use `nap update` normally.
In Bash, run commands as `nap update`, `nap auth login`, and so on. A leading In Bash, run commands as `nap update`, `nap auth login`, and so on. A leading
`!` is shell history expansion: `!nap update` replays the most recent command `!` is shell history expansion: `!nap update` replays the most recent command
+55 -14
View File
@@ -6,6 +6,7 @@ Usage:
nap init nap init
nap status nap status
nap doctor nap doctor
nap reindex
nap auth login nap auth login
nap project create nap project create
nap project attach nap project attach
@@ -42,6 +43,7 @@ SCRIPT_NAMES = (
"napseer_mcp_server.py", "napseer_mcp_server.py",
"napseer_mcp_supervisor.py", "napseer_mcp_supervisor.py",
"napseer_spake2.py", "napseer_spake2.py",
"napseer_credentials.py",
"terminal_init.py", "terminal_init.py",
"terminal_protocol.py", "terminal_protocol.py",
"terminal_pty_manager.py", "terminal_pty_manager.py",
@@ -56,6 +58,7 @@ LOCAL_HOST = "127.0.0.1"
DEFAULT_GATEWAY_PORT = os.environ.get("NAPSEER_GATEWAY_PORT") DEFAULT_GATEWAY_PORT = os.environ.get("NAPSEER_GATEWAY_PORT")
RUNTIME_SCRIPT_NAMES = ( RUNTIME_SCRIPT_NAMES = (
"napseer_spake2.py", "napseer_spake2.py",
"napseer_credentials.py",
"terminal_init.py", "terminal_init.py",
"terminal_protocol.py", "terminal_protocol.py",
"terminal_pty_manager.py", "terminal_pty_manager.py",
@@ -63,15 +66,18 @@ RUNTIME_SCRIPT_NAMES = (
"napseer_mcp_supervisor.py", "napseer_mcp_supervisor.py",
) )
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024))) MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
CLI_RELEASE_VERSION = "0.2.2" CLI_RELEASE_VERSION = "0.3.0"
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-08-01" CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-10"
CLI_MINIMUM_CONTRACT_VERSION = "2026-05-19" # Older installers have a fixed asset list and would omit the credentials module.
# Reject them before activation; the published bootstrap performs the upgrade.
CLI_MINIMUM_CONTRACT_VERSION = "2026-09-10"
CLI_BUNDLE_SCHEMA_VERSION = "napseer.cli.bundle.v1" CLI_BUNDLE_SCHEMA_VERSION = "napseer.cli.bundle.v1"
INSTALL_PATHS = { INSTALL_PATHS = {
"nap_install.py": "nap_install.py", "nap_install.py": "nap_install.py",
"napseer_mcp_server.py": "napseer_mcp_server.py", "napseer_mcp_server.py": "napseer_mcp_server.py",
"napseer_mcp_supervisor.py": "napseer_mcp_supervisor.py", "napseer_mcp_supervisor.py": "napseer_mcp_supervisor.py",
"napseer_spake2.py": "napseer_spake2.py", "napseer_spake2.py": "napseer_spake2.py",
"napseer_credentials.py": "napseer_credentials.py",
"terminal_init.py": "terminal/__init__.py", "terminal_init.py": "terminal/__init__.py",
"terminal_protocol.py": "terminal/protocol.py", "terminal_protocol.py": "terminal/protocol.py",
"terminal_pty_manager.py": "terminal/pty_manager.py", "terminal_pty_manager.py": "terminal/pty_manager.py",
@@ -84,8 +90,11 @@ CANONICAL_COMMANDS = (
("init", "Initialize Napseer in the current folder."), ("init", "Initialize Napseer in the current folder."),
("status", "Show concise account, project, and runtime state."), ("status", "Show concise account, project, and runtime state."),
("doctor", "Diagnose local setup without changing it."), ("doctor", "Diagnose local setup without changing it."),
("reindex", "Rebuild the current project's local memory index."),
("auth", "Authenticate an account or repair credentials."), ("auth", "Authenticate an account or repair credentials."),
("project", "Create, attach, claim, or inspect a project."), ("project", "Create, attach, claim, or inspect a project."),
("files", "List, upload, download, or archive project files."),
("export", "Export project documentation and files to a new ZIP archive."),
("mcp", "Install, update, inspect, or serve the local MCP runtime."), ("mcp", "Install, update, inspect, or serve the local MCP runtime."),
("gateway", "Set up and operate the local gateway."), ("gateway", "Set up and operate the local gateway."),
("update", "Update the installed CLI and runtime bundle."), ("update", "Update the installed CLI and runtime bundle."),
@@ -96,8 +105,11 @@ COMMAND_METADATA = {
"init": {"mutates": True, "visible": True}, "init": {"mutates": True, "visible": True},
"status": {"mutates": False, "visible": True}, "status": {"mutates": False, "visible": True},
"doctor": {"mutates": False, "visible": True}, "doctor": {"mutates": False, "visible": True},
"reindex": {"mutates": True, "visible": True},
"auth": {"mutates": True, "visible": True}, "auth": {"mutates": True, "visible": True},
"project": {"mutates": True, "visible": True}, "project": {"mutates": True, "visible": True},
"files": {"mutates": True, "visible": True},
"export": {"mutates": True, "visible": True},
"mcp": {"mutates": True, "visible": True}, "mcp": {"mutates": True, "visible": True},
"gateway": {"mutates": True, "visible": True}, "gateway": {"mutates": True, "visible": True},
"update": {"mutates": True, "visible": True}, "update": {"mutates": True, "visible": True},
@@ -500,6 +512,7 @@ def activate_release(release_dir):
"napseer_mcp_server.py", "napseer_mcp_server.py",
"napseer_mcp_supervisor.py", "napseer_mcp_supervisor.py",
"napseer_spake2.py", "napseer_spake2.py",
"napseer_credentials.py",
): ):
path = INSTALL_DIR / filename path = INSTALL_DIR / filename
preserve_legacy_path(path) preserve_legacy_path(path)
@@ -629,11 +642,15 @@ def cwd_state_dir():
def state_dir_status(): def state_dir_status():
preferred = preferred_state_dir() preferred = preferred_state_dir()
from napseer_credentials import CredentialStore
try: auth_path, credential_source = CredentialStore(preferred).selected()
except RuntimeError: auth_path, credential_source = CredentialStore(preferred).user, "invalid_selection"
return { return {
"credential_source": credential_source,
"active_state_dir": str(preferred), "active_state_dir": str(preferred),
"active_auth_path": str(preferred / "auth.json"), "active_auth_path": str(auth_path),
"state_dir": str(preferred), "state_dir": str(preferred),
"auth_path": str(preferred / "auth.json"), "auth_path": str(auth_path),
"state_dir_exists": preferred.exists(), "state_dir_exists": preferred.exists(),
"state_dir_message": ( "state_dir_message": (
".napseer directory is active." ".napseer directory is active."
@@ -1066,12 +1083,25 @@ def print_command_help(command):
"Usage: nap doctor\n" "Usage: nap doctor\n"
"Run read-only checks and print exact repair commands." "Run read-only checks and print exact repair commands."
), ),
"reindex": (
"Usage: nap reindex [--limit N]\n"
"Rebuild the current project's local search and graph index."
),
"auth": ( "auth": (
"Usage: nap auth [status|login|repair]\n" "Usage: nap auth [status|login|repair]\n"
" login Authenticate an account; does not select a project.\n" " login Authenticate an account; does not select a project.\n"
" status Show credential state without credential values.\n" " status Show credential state without credential values.\n"
" repair Recover failed automatic credential renewal." " repair Recover failed automatic credential renewal.\n"
" logout [--project] Revoke the selected login.\n"
" login --project Store a separate project login in user data.\n"
" migrate Move legacy project credentials into user data.\n"
" use-user Explicitly select the general login for this folder.\n"
" api-key --env NAME Configure project access from a named environment variable.\n"
" keys list|create|revoke Manage named, expiring delegated access."
), ),
"files": "Usage: nap files list|upload|download|archive [--path FILE] [--id UUID] [--node-id UUID] [--cursor CURSOR] [--limit N]",
"export": "Usage: nap export --path NEW_ARCHIVE.zip\nExports active project nodes and files; refuses changes during export or an existing destination.",
"project": ( "project": (
"Usage: nap project [init|create|attach|claim|status|encryption]\n" "Usage: nap project [init|create|attach|claim|status|encryption]\n"
" init Initialize a fresh folder (same workflow as `nap init`).\n" " init Initialize a fresh folder (same workflow as `nap init`).\n"
@@ -1335,11 +1365,14 @@ def doctor_status():
auth_path = pathlib.Path(state["active_auth_path"]) auth_path = pathlib.Path(state["active_auth_path"])
auth = {} auth = {}
auth_error = None auth_error = None
if auth_path.exists():
try: try:
auth = json.loads(auth_path.read_text(encoding="utf-8")) from napseer_credentials import CredentialStore, read_json
except (OSError, json.JSONDecodeError): store=CredentialStore(preferred_state_dir())
auth_error = "auth file is unreadable or invalid JSON" auth=store.read()
locator=read_json(preferred_state_dir()/"project.json")
if not auth.get("project_id"): auth["project_id"]=locator.get("project_id")
except RuntimeError:
auth_error="credential state is unreadable; run nap auth login or inspect the selected override"
missing = runtime_assets_missing() missing = runtime_assets_missing()
issues = [] issues = []
setup = [] setup = []
@@ -1348,13 +1381,13 @@ def doctor_status():
elif not auth_path.exists(): elif not auth_path.exists():
setup.append({ setup.append({
"code": "project_not_initialized", "code": "project_not_initialized",
"message": "current folder is not initialized; anonymous setup is available without login", "message": "current folder is not initialized; configure a user login with nap auth login",
"next": "nap init or invoke a project-scoped MCP tool", "next": "nap auth login",
}) })
elif not auth.get("project_id"): elif not auth.get("project_id"):
setup.append({ setup.append({
"code": "project_not_configured", "code": "project_not_configured",
"message": "no project is configured for this folder; anonymous setup is available without login", "message": "no project is configured for this folder; nap init uses the configured user login",
"next": "nap init", "next": "nap init",
}) })
if missing: if missing:
@@ -1431,6 +1464,10 @@ def version_status():
def main(argv): def main(argv):
invoked_as_nap = pathlib.Path(argv[0]).name in {"nap", "nap.cmd"} invoked_as_nap = pathlib.Path(argv[0]).name in {"nap", "nap.cmd"}
# The published /install endpoint is executed as `python3 -`. Keep that
# bootstrap separate from the removed operator-facing `nap install` alias.
if len(argv) == 1 and not invoked_as_nap:
return handle_mcp(["install"])
command = argv[1] if len(argv) > 1 else ("help" if invoked_as_nap else "install") command = argv[1] if len(argv) > 1 else ("help" if invoked_as_nap else "install")
args = argv[2:] args = argv[2:]
if command in HELP_TOKENS: if command in HELP_TOKENS:
@@ -1445,6 +1482,8 @@ def main(argv):
if command == "doctor": if command == "doctor":
print_json(doctor_status()) print_json(doctor_status())
return 0 return 0
if command == "reindex":
return run_wrapper("reindex", args)
if command == "mcp": if command == "mcp":
return handle_mcp(args) return handle_mcp(args)
if command == "version": if command == "version":
@@ -1457,6 +1496,8 @@ def main(argv):
return 0 return 0
if command == "gateway": if command == "gateway":
return handle_gateway(args) return handle_gateway(args)
if command in {"files", "export"}:
return run_wrapper(command, args)
if command == "status": if command == "status":
return run_wrapper("configure", args) return run_wrapper("configure", args)
if command == "project": if command == "project":
@@ -1469,7 +1510,7 @@ def main(argv):
if args and args[0] in {"login", "login-local", "operator-login"}: if args and args[0] in {"login", "login-local", "operator-login"}:
validate_cli_options( validate_cli_options(
args[1:], args[1:],
flags={"--no-browser"}, flags={"--no-browser", "--project"},
value_options={ value_options={
"--frontend-url", "--frontend-url",
"--api-base-url", "--api-base-url",
+240
View File
@@ -0,0 +1,240 @@
"""User-owned credentials, separate from repository identity and gateway state.
No network, enrollment, or credential output belongs in this module.
"""
from contextlib import contextmanager
import hashlib
import json
import os
from pathlib import Path
import stat
import sys
import tempfile
import threading
import time
class CredentialError(RuntimeError):
pass
CREDENTIAL_KEYS = {
"base_url", "account_id", "token", "access_token", "refresh_token",
"token_expires_at", "refresh_expires_at", "account_mode", "oauth_client_id",
"oauth_scope", "logged_out", "credential_kind",
}
SELECTION_KEYS = {
"project_id", "project_slug", "project_name", "project_encryption_state",
"project_signing_key_fingerprint", "credential_profile",
}
_registry_lock = threading.Lock()
_thread_locks = {}
_held = threading.local()
def user_data_dir():
if os.environ.get("NAPSEER_USER_DATA_DIR"):
return Path(os.environ["NAPSEER_USER_DATA_DIR"]).expanduser().absolute()
if sys.platform == "win32":
return Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "napseer"
if sys.platform == "darwin":
return Path.home() / "Library" / "Application Support" / "napseer"
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "napseer"
def read_json(path):
try:
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
except FileNotFoundError:
return {}
except OSError:
raise CredentialError("Credential state cannot be opened safely.") from None
try:
if not stat.S_ISREG(os.fstat(fd).st_mode):
raise CredentialError("Credential state must be a regular file.")
with os.fdopen(fd, "r", encoding="utf-8") as stream:
fd = None
raw = stream.read(65537)
if len(raw) > 65536:
raise CredentialError("Credential state exceeds the size limit.")
result = json.loads(raw)
if not isinstance(result, dict):
raise ValueError()
return result
except (ValueError, UnicodeError, OSError):
raise CredentialError("Credential state is unreadable; run nap auth login to repair it.") from None
finally:
if fd is not None:
os.close(fd)
def atomic_json(path, data):
path = Path(path)
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
if path.is_symlink():
raise CredentialError("Credential state must not be a symbolic link.")
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
os.fchmod(stream.fileno(), 0o600)
json.dump(data, stream, indent=2)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
@contextmanager
def credential_lock(path, timeout=30):
"""Bounded process lock; nested calls in the same thread reuse the lock."""
path = Path(path).absolute()
with _registry_lock:
thread_lock = _thread_locks.setdefault(str(path), threading.RLock())
if not thread_lock.acquire(timeout=timeout):
raise CredentialError("Credential update is busy; retry after the other login or refresh finishes.")
try:
held = getattr(_held, "paths", {})
if str(path) in held:
yield
return
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
try:
fd = os.open(str(path) + ".lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
except OSError:
raise CredentialError("Credential lock cannot be opened safely.") from None
try:
if not stat.S_ISREG(os.fstat(fd).st_mode):
raise CredentialError("Credential lock must be a regular file.")
deadline = time.monotonic() + timeout
while True:
try:
if os.name == "nt":
import msvcrt
os.lseek(fd, 0, os.SEEK_SET)
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except (BlockingIOError, OSError):
if time.monotonic() >= deadline:
raise CredentialError("Credential update is busy; retry after the other login or refresh finishes.") from None
time.sleep(0.025)
held[str(path)] = fd
_held.paths = held
try:
yield
finally:
held.pop(str(path), None)
finally:
os.close(fd)
finally:
thread_lock.release()
class CredentialStore:
def __init__(self, state_dir, legacy_path=None):
self.state_dir = Path(state_dir)
self.legacy = Path(legacy_path or self.state_dir / "auth.json")
self.user = user_data_dir() / "credentials" / "default.json"
project_key = hashlib.sha256(str(self.state_dir.parent.resolve()).encode()).hexdigest()
self.project = user_data_dir() / "credentials" / "projects" / f"{project_key}.json"
self.selection = self.state_dir / "state.json"
def selected(self):
bound = getattr(_held,"bindings",{}).get(str(self.state_dir.absolute()))
if bound: return bound
explicit = os.environ.get("NAPSEER_AUTH_FILE")
if explicit:
return Path(explicit).expanduser().absolute(), "explicit"
if self.legacy != self.state_dir / "auth.json":
return self.legacy, "explicit"
if read_json(self.selection).get("credential_profile") == "user":
return self.user, "user"
if self.project.exists() or self.project.is_symlink():
return self.project, "project"
if self.legacy.exists() or self.legacy.is_symlink():
return self.legacy, "legacy_project"
return self.user, "user"
@contextmanager
def transaction(self):
"""Pin one credential source through read/refresh/write despite new overrides."""
identity = str(self.state_dir.absolute())
chosen = self.selected()
with credential_lock(chosen[0]):
bindings = getattr(_held,"bindings",{})
previous = bindings.get(identity)
bindings[identity] = chosen; _held.bindings = bindings
try: yield
finally:
if previous: bindings[identity] = previous
else: bindings.pop(identity,None)
def use_user(self):
current = read_json(self.user)
if not current.get("token") or current.get("logged_out"):
raise CredentialError("Run nap auth login before selecting the general user login.")
with credential_lock(self.selection):
selection = read_json(self.selection)
selection["credential_profile"] = "user"
atomic_json(self.selection, selection)
return self.user
def migrate(self):
with credential_lock(self.legacy), credential_lock(self.project):
legacy = read_json(self.legacy)
if not legacy: raise CredentialError("There is no legacy project credential to migrate.")
if self.project.exists(): raise CredentialError("A project credential already exists; migration will not overwrite it.")
atomic_json(self.project, legacy)
if read_json(self.project) != legacy: raise CredentialError("Migration verification failed; original credentials were preserved.")
self.legacy.unlink()
return self.project
def read(self):
path, source = self.selected()
credentials = read_json(path)
selection = {k: v for k, v in read_json(self.selection).items() if k in SELECTION_KEYS}
# Project-bound grants retain their issued project. General grants only
# consume repository selection; selection cannot confer authority.
return {**selection, **credentials}
def write(self, data):
path, source = self.selected()
with credential_lock(path):
if source in {"legacy_project", "explicit"}:
atomic_json(path, data)
return
credentials = {k: v for k, v in data.items() if k in CREDENTIAL_KEYS}
if source == "project":
credentials.update({k: v for k, v in data.items() if k in SELECTION_KEYS})
current = read_json(path)
# Reading project metadata must not churn or overwrite shared
# credentials, including after another process logged out.
if credentials != current:
atomic_json(path, credentials)
selection = {**read_json(self.selection), **{k: v for k, v in data.items() if k in SELECTION_KEYS}}
if selection != read_json(self.selection):
atomic_json(self.selection, selection)
def login(self, credentials, project=False):
target = self.project if project else self.user
with credential_lock(target):
atomic_json(target, credentials)
if project:
with credential_lock(self.selection):
selection = read_json(self.selection); selection["credential_profile"] = "project"
atomic_json(self.selection, selection)
return target
def logout(self, project=False):
target = self.selected()[0] if project else self.user
with credential_lock(target):
previous = read_json(target)
# Keep a tombstone: removing an override would silently restore the
# more privileged user session on the next request.
atomic_json(target, {"base_url": previous.get("base_url"), "logged_out": True})
return target
File diff suppressed because it is too large Load Diff
+134 -32
View File
@@ -8,11 +8,12 @@ session. This supervisor owns those client-facing pipes while treating
- valid requests are forwarded without head-of-line blocking and responses - valid requests are forwarded without head-of-line blocking and responses
are correlated by JSON-RPC request id; are correlated by JSON-RPC request id;
- notifications never create client-facing responses; - notifications never create client-facing responses;
- cancellation releases the matching pending request and forwards the - cancellation stops waiting for a result but retains unfinished work until
notification to the worker; the worker acknowledges cleanup, with bounded grace before termination;
- a worker exit fails every affected in-flight request once and never replays - a worker exit fails every affected in-flight request once and never replays
it automatically; it automatically;
- a replaced worker is reloaded at the next quiescent request boundary. - a replaced worker, or an explicitly configured runtime dependency behind a
stable launcher, is reloaded at the next quiescent request boundary.
The supervisor never logs MCP payloads, tool results, auth state, or child The supervisor never logs MCP payloads, tool results, auth state, or child
stderr. Protocol stdout contains JSON-RPC messages only. stderr. Protocol stdout contains JSON-RPC messages only.
@@ -25,10 +26,12 @@ import json
import os import os
import pathlib import pathlib
import queue import queue
import select
import signal import signal
import subprocess import subprocess
import sys import sys
import threading import threading
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import BinaryIO, Callable from typing import BinaryIO, Callable
@@ -39,6 +42,11 @@ WORKER_PATH = pathlib.Path(
pathlib.Path(__file__).resolve().with_name("napseer_mcp_server.py"), pathlib.Path(__file__).resolve().with_name("napseer_mcp_server.py"),
) )
).expanduser() ).expanduser()
WORKER_WATCH_PATHS = tuple(
pathlib.Path(value).expanduser()
for value in os.environ.get("NAPSEER_MCP_WORKER_WATCH_PATHS", "").split(os.pathsep)
if value.strip()
)
RESPONSE_TIMEOUT_SECONDS = max( RESPONSE_TIMEOUT_SECONDS = max(
1, 1,
int(os.environ.get("NAPSEER_MCP_RESPONSE_TIMEOUT_SECONDS", "60")), int(os.environ.get("NAPSEER_MCP_RESPONSE_TIMEOUT_SECONDS", "60")),
@@ -48,6 +56,10 @@ MAX_IN_FLIGHT_REQUESTS = max(
int(os.environ.get("NAPSEER_MCP_MAX_IN_FLIGHT_REQUESTS", "32")), int(os.environ.get("NAPSEER_MCP_MAX_IN_FLIGHT_REQUESTS", "32")),
) )
WORKER_RESTARTED_ERROR = -32098 WORKER_RESTARTED_ERROR = -32098
REQUEST_REJECTED_ERROR = -32097
CANCEL_GRACE_SECONDS = max(1, int(os.environ.get("NAPSEER_MCP_CANCEL_GRACE_SECONDS", "10")))
WRITE_TIMEOUT_SECONDS = max(1, int(os.environ.get("NAPSEER_MCP_WRITE_TIMEOUT_SECONDS", "5")))
REQUEST_FINISHED = "notifications/napseer/requestFinished"
_CANCELLED = object() _CANCELLED = object()
@@ -55,11 +67,24 @@ class WorkerUnavailable(RuntimeError):
"""The replaceable MCP worker could not complete a request.""" """The replaceable MCP worker could not complete a request."""
class RequestRejected(WorkerUnavailable):
"""The request was not sent to the worker and cannot have executed."""
def source_identity(path: pathlib.Path) -> tuple[int, int, int]: def source_identity(path: pathlib.Path) -> tuple[int, int, int]:
stat = path.stat() stat = path.stat()
return stat.st_ino, stat.st_size, stat.st_mtime_ns return stat.st_ino, stat.st_size, stat.st_mtime_ns
def runtime_identity(
worker_path: pathlib.Path,
watch_paths: tuple[pathlib.Path, ...] = (),
) -> tuple[tuple[int, int, int], ...]:
"""Identify every source whose replacement changes the worker runtime."""
return tuple(source_identity(path) for path in (worker_path, *watch_paths))
def parse_message(message: bytes): def parse_message(message: bytes):
try: try:
payload = json.loads(message) payload = json.loads(message)
@@ -97,16 +122,16 @@ def cancelled_request_id(message: bytes):
return True, params.get("requestId") return True, params.get("requestId")
def worker_error(message: bytes) -> bytes: def worker_error(message: bytes, *, rejected=False) -> bytes:
payload = { payload = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": request_id(message), "id": request_id(message),
"error": { "error": {
"code": WORKER_RESTARTED_ERROR, "code": REQUEST_REJECTED_ERROR if rejected else WORKER_RESTARTED_ERROR,
"message": ( "message": (
"Napseer MCP could not complete this request. The outcome may be " "Napseer MCP could not complete this request. The outcome may be "
"uncertain; inspect state before retrying a mutation." "uncertain; inspect state before retrying a mutation."
), ) if not rejected else "Napseer MCP is busy or this request id is still active. This request was not started; retry later.",
}, },
} }
return json.dumps(payload, separators=(",", ":")).encode("utf-8") + b"\n" return json.dumps(payload, separators=(",", ":")).encode("utf-8") + b"\n"
@@ -118,13 +143,21 @@ class PendingRequest:
key: str key: str
generation: int generation: int
response: queue.Queue = field(default_factory=lambda: queue.Queue(maxsize=1)) response: queue.Queue = field(default_factory=lambda: queue.Queue(maxsize=1))
cancelled: bool = False
cancel_timer: threading.Timer | None = None
class Worker: class Worker:
def __init__(self, path: pathlib.Path, unsolicited: Callable[[bytes], None] | None = None): def __init__(
self,
path: pathlib.Path,
unsolicited: Callable[[bytes], None] | None = None,
watch_paths: tuple[pathlib.Path, ...] = (),
):
self.path = path self.path = path
self.watch_paths = tuple(watch_paths)
self.process: subprocess.Popen[bytes] | None = None self.process: subprocess.Popen[bytes] | None = None
self.identity: tuple[int, int, int] | None = None self.identity: tuple[tuple[int, int, int], ...] | None = None
self.generation = 0 self.generation = 0
self.unsolicited = unsolicited self.unsolicited = unsolicited
self._lifecycle_lock = threading.RLock() self._lifecycle_lock = threading.RLock()
@@ -135,7 +168,7 @@ class Worker:
def _discard_stderr(self, stream: BinaryIO) -> None: def _discard_stderr(self, stream: BinaryIO) -> None:
try: try:
for _line in stream: while stream.read(4096):
pass pass
finally: finally:
stream.close() stream.close()
@@ -159,6 +192,8 @@ class Worker:
if pending.generation != generation: if pending.generation != generation:
continue continue
self._pending.pop(key, None) self._pending.pop(key, None)
if pending.cancel_timer is not None:
pending.cancel_timer.cancel()
failed.append(pending) failed.append(pending)
for pending in failed: for pending in failed:
self._signal(pending, error) self._signal(pending, error)
@@ -168,18 +203,32 @@ class Worker:
if payload is None: if payload is None:
return False return False
normalized = response if response.endswith(b"\n") else response + b"\n" normalized = response if response.endswith(b"\n") else response + b"\n"
if payload.get("method") == REQUEST_FINISHED:
params = payload.get("params") or {}
if isinstance(params, dict) and "requestId" in params:
key = request_key(params["requestId"])
with self._pending_lock:
pending = self._pending.get(key)
if pending is not None and pending.generation == generation and pending.cancelled:
self._pending.pop(key, None)
if pending.cancel_timer is not None:
pending.cancel_timer.cancel()
return True
if "id" in payload: if "id" in payload:
key = request_key(payload.get("id")) key = request_key(payload.get("id"))
with self._pending_lock: with self._pending_lock:
pending = self._pending.get(key) pending = self._pending.get(key)
if pending is not None and pending.generation == generation: if pending is not None and pending.generation == generation:
self._pending.pop(key, None) self._pending.pop(key, None)
if pending.cancel_timer is not None:
pending.cancel_timer.cancel()
else: else:
pending = None pending = None
if pending is not None: if pending is not None:
if not pending.cancelled:
self._signal(pending, normalized) self._signal(pending, normalized)
return True return True
if "method" in payload and self.unsolicited is not None: if generation == self.generation and "method" in payload and self.unsolicited is not None:
self.unsolicited(normalized) self.unsolicited(normalized)
return True return True
@@ -219,12 +268,15 @@ class Worker:
if process is None: if process is None:
return return
if process.poll() is None: if process.poll() is None:
try:
process.terminate() process.terminate()
except ProcessLookupError:
pass
try: try:
process.wait(timeout=2) process.wait(timeout=2)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
process.kill() process.kill()
process.wait() process.wait(timeout=2)
for stream in (process.stdin, process.stdout): for stream in (process.stdin, process.stdout):
if stream is not None: if stream is not None:
try: try:
@@ -237,13 +289,14 @@ class Worker:
with self._lifecycle_lock: with self._lifecycle_lock:
self._stop_locked() self._stop_locked()
try: try:
identity = source_identity(self.path) identity = runtime_identity(self.path, self.watch_paths)
process = subprocess.Popen( process = subprocess.Popen(
[sys.executable, "-u", str(self.path)], [sys.executable, "-u", str(self.path)],
stdin=subprocess.PIPE, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
bufsize=0, bufsize=0,
env={**os.environ, "NAPSEER_MCP_SUPERVISED": "1"},
) )
except (OSError, subprocess.SubprocessError) as exc: except (OSError, subprocess.SubprocessError) as exc:
raise WorkerUnavailable("unable to start Napseer MCP worker") from exc raise WorkerUnavailable("unable to start Napseer MCP worker") from exc
@@ -254,6 +307,7 @@ class Worker:
self.generation += 1 self.generation += 1
generation = self.generation generation = self.generation
self.process = process self.process = process
os.set_blocking(process.stdin.fileno(), False)
self.identity = identity self.identity = identity
threading.Thread( threading.Thread(
target=self._discard_stderr, target=self._discard_stderr,
@@ -275,7 +329,7 @@ class Worker:
def ensure_current(self) -> None: def ensure_current(self) -> None:
with self._lifecycle_lock: with self._lifecycle_lock:
try: try:
current_identity = source_identity(self.path) current_identity = runtime_identity(self.path, self.watch_paths)
except OSError as exc: except OSError as exc:
self._stop_locked() self._stop_locked()
raise WorkerUnavailable("Napseer MCP worker is missing") from exc raise WorkerUnavailable("Napseer MCP worker is missing") from exc
@@ -288,14 +342,30 @@ class Worker:
self.start() self.start()
def _write(self, process: subprocess.Popen[bytes], message: bytes) -> None: def _write(self, process: subprocess.Popen[bytes], message: bytes) -> None:
with self._stdin_lock: deadline = time.monotonic() + WRITE_TIMEOUT_SECONDS
if not self._stdin_lock.acquire(timeout=WRITE_TIMEOUT_SECONDS):
raise WorkerUnavailable("Napseer MCP worker input timed out")
try:
if self.process is not process or process.poll() is not None or process.stdin is None: if self.process is not process or process.poll() is not None or process.stdin is None:
raise WorkerUnavailable("Napseer MCP worker is not running") raise WorkerUnavailable("Napseer MCP worker is not running")
try: try:
process.stdin.write(message if message.endswith(b"\n") else message + b"\n") remaining = memoryview(message if message.endswith(b"\n") else message + b"\n")
process.stdin.flush() while remaining:
except (BrokenPipeError, OSError) as exc: timeout = deadline - time.monotonic()
if timeout <= 0 or self.process is not process:
raise WorkerUnavailable("Napseer MCP worker input timed out")
fd = process.stdin.fileno()
if not select.select([], [fd], [], timeout)[1]:
raise WorkerUnavailable("Napseer MCP worker input timed out")
try:
count = os.write(fd, remaining)
remaining = remaining[count:]
except BlockingIOError:
continue
except (BrokenPipeError, OSError, ValueError) as exc:
raise WorkerUnavailable("Napseer MCP worker input closed") from exc raise WorkerUnavailable("Napseer MCP worker input closed") from exc
finally:
self._stdin_lock.release()
def submit(self, message: bytes) -> PendingRequest | None: def submit(self, message: bytes) -> PendingRequest | None:
self.ensure_current() self.ensure_current()
@@ -305,16 +375,20 @@ class Worker:
if process is None: if process is None:
raise WorkerUnavailable("Napseer MCP worker is not running") raise WorkerUnavailable("Napseer MCP worker is not running")
if is_notification(message): if is_notification(message):
try:
self._write(process, message) self._write(process, message)
except WorkerUnavailable:
self.stop()
raise
return None return None
key = request_key(request_id(message)) key = request_key(request_id(message))
pending = PendingRequest(message=message, key=key, generation=generation) pending = PendingRequest(message=message, key=key, generation=generation)
with self._pending_lock: with self._pending_lock:
if key in self._pending: if key in self._pending:
raise WorkerUnavailable("duplicate in-flight JSON-RPC request id") raise RequestRejected("duplicate in-flight JSON-RPC request id")
if len(self._pending) >= MAX_IN_FLIGHT_REQUESTS: if len(self._pending) >= MAX_IN_FLIGHT_REQUESTS:
raise WorkerUnavailable("too many in-flight Napseer MCP requests") raise RequestRejected("too many unfinished Napseer MCP requests")
self._pending[key] = pending self._pending[key] = pending
try: try:
self._write(process, message) self._write(process, message)
@@ -322,26 +396,54 @@ class Worker:
with self._pending_lock: with self._pending_lock:
if self._pending.get(key) is pending: if self._pending.get(key) is pending:
self._pending.pop(key, None) self._pending.pop(key, None)
# A partial write has an uncertain outcome and corrupts framing.
# Stop this generation rather than appending another request to it.
self.stop()
raise raise
return pending return pending
def cancel(self, value) -> bool: def cancel(self, value, *, expected: PendingRequest | None = None) -> bool:
key = request_key(value) key = request_key(value)
with self._pending_lock: with self._pending_lock:
pending = self._pending.get(key) pending = self._pending.get(key)
if expected is not None and pending is not expected:
return False
if pending is None or request_method(pending.message) == "initialize": if pending is None or request_method(pending.message) == "initialize":
return False return False
self._pending.pop(key, None) if pending.cancelled:
self._signal(pending, _CANCELLED)
return True return True
pending.cancelled = True
pending.cancel_timer = threading.Timer(CANCEL_GRACE_SECONDS, self._expire_cancelled, args=(pending,))
pending.cancel_timer.daemon = True
timer = pending.cancel_timer
self._signal(pending, _CANCELLED)
timer.start()
process = self.process
if process is not None and pending.generation == self.generation:
try:
self._write(process, json.dumps({"jsonrpc": "2.0", "method": "notifications/cancelled",
"params": {"requestId": value}}).encode("utf-8"))
except WorkerUnavailable:
self._expire_cancelled(pending)
return True
def _expire_cancelled(self, pending: PendingRequest) -> None:
with self._lifecycle_lock:
with self._pending_lock:
unfinished = self._pending.get(pending.key) is pending
if unfinished and pending.generation == self.generation:
self._stop_locked()
def wait(self, pending: PendingRequest) -> bytes | None: def wait(self, pending: PendingRequest) -> bytes | None:
try: try:
result = pending.response.get(timeout=RESPONSE_TIMEOUT_SECONDS) result = pending.response.get(timeout=RESPONSE_TIMEOUT_SECONDS)
except queue.Empty as exc: except queue.Empty as exc:
with self._pending_lock: if request_method(pending.message) == "initialize":
if self._pending.get(pending.key) is pending: # Initialize is not cancellable, but a stuck initialization
self._pending.pop(pending.key, None) # must not permanently consume the worker generation.
self._expire_cancelled(pending)
else:
self.cancel(request_id(pending.message), expected=pending)
raise WorkerUnavailable("Napseer MCP worker response timed out") from exc raise WorkerUnavailable("Napseer MCP worker response timed out") from exc
if result is _CANCELLED: if result is _CANCELLED:
return None return None
@@ -361,7 +463,11 @@ def supervise() -> int:
sys.stdout.buffer.write(response if response.endswith(b"\n") else response + b"\n") sys.stdout.buffer.write(response if response.endswith(b"\n") else response + b"\n")
sys.stdout.buffer.flush() sys.stdout.buffer.flush()
worker = Worker(WORKER_PATH, unsolicited=emit) worker = Worker(
WORKER_PATH,
unsolicited=emit,
watch_paths=WORKER_WATCH_PATHS,
)
waiters = concurrent.futures.ThreadPoolExecutor( waiters = concurrent.futures.ThreadPoolExecutor(
max_workers=MAX_IN_FLIGHT_REQUESTS, max_workers=MAX_IN_FLIGHT_REQUESTS,
thread_name_prefix="napseer-mcp-request", thread_name_prefix="napseer-mcp-request",
@@ -391,16 +497,12 @@ def supervise() -> int:
cancelled, value = cancelled_request_id(message) cancelled, value = cancelled_request_id(message)
if cancelled: if cancelled:
worker.cancel(value) worker.cancel(value)
try:
worker.submit(message)
except WorkerUnavailable:
pass
continue continue
try: try:
pending = worker.submit(message) pending = worker.submit(message)
except WorkerUnavailable: except WorkerUnavailable as exc:
if not is_notification(message): if not is_notification(message):
emit(worker_error(message)) emit(worker_error(message, rejected=isinstance(exc, RequestRejected)))
continue continue
if pending is not None: if pending is not None:
waiters.submit(finish, message, pending) waiters.submit(finish, message, pending)
+10
View File
@@ -0,0 +1,10 @@
"""All tests use disposable user credential state, including legacy test scripts."""
import pytest
@pytest.fixture(autouse=True)
def isolated_user_credentials(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("NAPSEER_USER_DATA_DIR", str(tmp_path / "user-data"))
for name in ("NAPSEER_API_KEY", "NAPSEER_TOKEN", "NAPSEER_REFRESH_TOKEN", "NAPSEER_AUTH_FILE", "NAPSEER_PROJECT_ROOT", "NAPSEER_PROJECT_ID", "NAPSEER_BASE_URL"):
monkeypatch.delenv(name, raising=False)
+127
View File
@@ -0,0 +1,127 @@
"""Public worker/CLI entry points against synthetic binary HTTP storage."""
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
import threading
import uuid
import zipfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit, parse_qs
import pytest
from test_user_credentials import worker, outcome, SCRIPT
from napseer_credentials import CredentialStore, atomic_json
PROJECT="11111111-1111-4111-8111-111111111111"
NODE="22222222-2222-4222-8222-222222222222"
@pytest.fixture
def storage(tmp_path):
state={"files":{},"corrupt":False,"keys":[],"projects":[]}
node={"id":NODE,"name":"Guide","full_path":"/services/guide","type":"documentation","updated_at":"2026-09-09T00:00:00Z","content_text":"Recovery procedure","metadata":{},"links":[]}
class Handler(BaseHTTPRequestHandler):
def log_message(self,*_args): pass
def reply(self,status,value,binary=False):
body=value if binary else json.dumps(value).encode()
self.send_response(status);self.send_header("Content-Length",str(len(body)));self.end_headers();self.wfile.write(body)
def do_GET(self):
path=urlsplit(self.path).path
if path=='/v1/projects':return self.reply(200,{'items':state['projects']})
if path=='/v1/api-keys':return self.reply(200,{'items':state['keys']})
if path.endswith('/nodes'): return self.reply(200,{"items":[node]})
if path.endswith('/nodes/'+NODE):return self.reply(200,node)
if path.endswith('/files'): return self.reply(200,{"items":[entry[0] for entry in state["files"].values()]})
identity=path.split('/')[-2] if path.endswith('/metadata') else path.split('/')[-1]
entry=state["files"].get(identity)
if not entry:return self.reply(404,{"error":{"code":"not_found"}})
if path.endswith('/metadata'):return self.reply(200,entry[0])
return self.reply(200,b"corrupted" if state["corrupt"] else entry[1],True)
def do_POST(self):
if self.path=='/v1/projects':
payload=json.loads(self.rfile.read(int(self.headers['Content-Length'])))
project={**payload,'id':str(uuid.uuid4()),'encryption_state':'standard'}
state['projects'].append(project);return self.reply(200,project)
if self.path=='/v1/api-keys':
payload=json.loads(self.rfile.read(int(self.headers['Content-Length'])))
key={**payload,'id':str(uuid.uuid4())};state['keys'].append(key)
return self.reply(200,{**key,'api_key':'npk_'+uuid.uuid4().hex})
query=parse_qs(urlsplit(self.path).query)
body=self.rfile.read(int(self.headers['Content-Length']))
identity=query['id'][0]
info={"id":identity,"name":query['name'][0],"node_id":query.get('node_id',[None])[0],"size_bytes":len(body),"sha256":hashlib.sha256(body).hexdigest()}
state['files'][identity]=(info,body)
return self.reply(200,info)
def do_DELETE(self):
identity=urlsplit(self.path).path.split('/')[-1];state['files'].pop(identity,None)
return self.reply(200,{"archived":True})
server=ThreadingHTTPServer(('127.0.0.1',0),Handler)
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
store=CredentialStore(tmp_path/'.napseer')
store.login({"base_url":f"http://127.0.0.1:{server.server_port}","token":"synthetic-access","account_mode":"operator_account"})
atomic_json(store.selection,{"project_id":PROJECT})
try:yield state
finally:server.shutdown();server.server_close();thread.join(timeout=2)
def test_agent_upload_retries_download_and_export_preserve_binary_and_references(tmp_path,storage):
body=bytes([0,255,128,13,10,65]);source=tmp_path/'proof.bin';source.write_bytes(body)
args={"local_path":str(source),"node_id":NODE}
first=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_upload',{args!r})"))
assert first['ok'],first.get('code')
identity=first['result']['id']
again=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_upload',{args!r})"))
assert again['result']['id']==identity
assert len(storage['files'])==1
destination=tmp_path/'download.bin'
result=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_download',{{'id':{identity!r},'local_path':{str(destination)!r}}})"))
assert result['ok'];assert destination.read_bytes()==body
rejected=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_download',{{'id':{identity!r},'local_path':{str(source)!r}}})"))
assert rejected['code']=='destination_unavailable';assert source.read_bytes()==body
target=tmp_path/'export.zip'
env=dict(os.environ,NAPSEER_PROJECT_ROOT=str(tmp_path),NAPSEER_TELEMETRY='0')
for name in ['NAPSEER_AUTH_FILE','NAPSEER_BASE_URL','NAPSEER_PROJECT_ID']:env.pop(name,None)
exported=subprocess.run([sys.executable,str(SCRIPT),'export','--path',str(target)],cwd=tmp_path,env=env,capture_output=True,text=True,timeout=15)
assert exported.returncode==0
assert json.loads(exported.stdout)['status']=='exported'
with zipfile.ZipFile(target) as archive:
manifest=json.loads(archive.read('manifest.json'))
assert manifest['files'][0]['node_id']==NODE
assert archive.read('files/'+identity)==body
assert json.loads(archive.read('nodes/'+NODE+'.json'))['content_text']=='Recovery procedure'
assert not any('auth' in name for name in archive.namelist())
storage['corrupt']=True
rejected=outcome(worker(tmp_path,f"m.export_project({{'local_path':{str(tmp_path/'broken.zip')!r}}})"))
assert rejected['code']=='file_integrity_failed'
assert not (tmp_path/'broken.zip').exists()
assert not list(tmp_path.glob('.napseer-export-*'))
assert outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_archive',{{'id':{identity!r}}})"))['ok']
assert not storage['files']
def test_cli_key_issuance_writes_private_credential_without_printing_it(tmp_path,storage):
env=dict(os.environ,NAPSEER_PROJECT_ROOT=str(tmp_path),NAPSEER_TELEMETRY='0')
for name in ['NAPSEER_AUTH_FILE','NAPSEER_BASE_URL','NAPSEER_PROJECT_ID']:env.pop(name,None)
created=subprocess.run([sys.executable,str(SCRIPT),'auth','keys','create','--project-id',PROJECT,'--name','Test delegate'],cwd=tmp_path,env=env,capture_output=True,text=True,timeout=15)
assert created.returncode==0
assert 'npk_' not in created.stdout
result=json.loads(created.stdout);path=Path(result['credential_file'])
assert path.stat().st_mode & 0o777 == 0o600
assert json.loads(path.read_text())['token'].startswith('npk_')
assert not (tmp_path/'.napseer/auth.json').exists()
def test_two_agents_bootstrap_one_project_with_general_login(tmp_path,storage):
store=CredentialStore(tmp_path/'.napseer');store.selection.unlink()
first=worker(tmp_path,"m.resolve_project_id({})")
second=worker(tmp_path,"m.resolve_project_id({})")
one,two=outcome(first),outcome(second)
assert one['ok'] and two['ok']
assert one['result']==two['result']
assert len(storage['projects'])==1
assert json.loads(store.user.read_text())['account_mode']=='operator_account'
assert 'project_id' not in json.loads(store.user.read_text())
assert not store.legacy.exists()
assert (tmp_path/'.napseer/project.json').exists()
+26 -82
View File
@@ -29,37 +29,18 @@ def anonymous_auth(mod):
} }
def test_initial_enrollment_persists_refresh_credential(): def test_unconfigured_agent_requires_user_login_without_enrollment():
mod = load_module() mod = load_module()
mod.TOKEN = None mod.TOKEN = None
mod.AUTH = anonymous_auth(mod)
mod.BASE_URL = mod.AUTH["base_url"]
mod.DEFAULT_PROJECT_ID = None
saved = []
requests = [] requests = []
mod.save_auth = lambda payload: saved.append(dict(payload)) mod.request_json = lambda *args, **kwargs: requests.append(args)
try:
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"}) mod.ensure_enrolled({"slug": "example"})
except mod.SafeToolError as exc:
assert saved[0]["refresh_token"] == "refresh" assert exc.code == "auth_required"
assert "refresh_expires_at" in saved[0] else:
assert [(method, path) for method, path, _, _ in requests] == [ raise AssertionError("unconfigured agents must not enroll")
("POST", "/v1/enrollment/token") assert not requests
]
assert requests[0][3] is False
def test_failed_anonymous_refresh_uses_ssh_recovery(): def test_failed_anonymous_refresh_uses_ssh_recovery():
@@ -107,65 +88,28 @@ def test_failed_anonymous_refresh_uses_ssh_recovery():
assert saved[0]["refresh_token"] == "recovered-refresh" assert saved[0]["refresh_token"] == "recovered-refresh"
def test_failed_mixed_state_refresh_uses_ssh_recovery(): def test_oauth_failure_never_falls_back_to_anonymous_identity():
mod = load_module() mod = load_module()
mod.AUTH = { mod.AUTH = {**anonymous_auth(mod), "account_mode": "operator_project"}
**anonymous_auth(mod), mod.TOKEN = "expired-access"
"account_mode": "operator_project", mod.REFRESH_TOKEN = "revoked-refresh"
"oauth_client_id": "nap-cli",
"project_id": "project",
"ssh_private_key": "test-private-key",
"ssh_public_key": "ssh-ed25519 test-public-key",
}
mod.BASE_URL = mod.AUTH["base_url"]
mod.DEFAULT_PROJECT_ID = "project"
mod.REFRESH_TOKEN = "stale-refresh"
mod.REFRESH_EXPIRES_AT = None
mod.refresh_public_auth_state = lambda: mod.AUTH mod.refresh_public_auth_state = lambda: mod.AUTH
mod.auth_public_key = lambda: "ssh-ed25519 test-public-key" calls = []
mod.sign_with_auth_key = lambda *args: "test-signature" def rejected(*args, **kwargs):
saved = [] raise mod.SafeToolError("http_401", "Login rejected", status=401)
mod.save_auth = lambda payload: saved.append(dict(payload)) mod.request_form_json = rejected
mod.request_json = lambda *args, **kwargs: calls.append(args)
def request_form(method, path, payload=None, token_required=False): try:
assert (method, path, token_required) == ("POST", "/v1/oauth/token", False) mod.renew_auth()
raise RuntimeError("HTTP 401") except mod.SafeToolError as exc:
assert exc.code == "auth_required"
def request(method, path, payload=None, token_required=False): else:
if path == "/v1/enrollment/refresh": raise AssertionError("OAuth revocation must require user login")
raise RuntimeError("HTTP 401") assert not calls
if path == "/v1/enrollment/challenges":
return {"challenge_id": "challenge", "challenge_text": "sign-me"}
if path == "/v1/enrollment/verify":
return {
"token": {
"access_token": "recovered-access",
"refresh_token": "recovered-refresh",
"expires_at": "2099-01-01T00:00:00Z",
"refresh_expires_at": "2099-12-31T00:00:00Z",
},
"worker": {
"id": "worker",
"agent_id": "agent-id",
"name": "agent",
"device_fingerprint": "device",
"root_path": "/repo",
"project_id": "project",
},
}
raise AssertionError(path)
mod.request_form_json = request_form
mod.request_json = request
result = mod.renew_auth()
assert result["method"] == "anonymous_ssh_recovery"
assert result["project_id"] == "project"
assert saved[0]["refresh_token"] == "recovered-refresh"
if __name__ == "__main__": if __name__ == "__main__":
test_initial_enrollment_persists_refresh_credential() test_unconfigured_agent_requires_user_login_without_enrollment()
test_failed_anonymous_refresh_uses_ssh_recovery() test_failed_anonymous_refresh_uses_ssh_recovery()
test_failed_mixed_state_refresh_uses_ssh_recovery() test_oauth_failure_never_falls_back_to_anonymous_identity()
print("ok: anonymous auth recovery tests passed") print("ok: anonymous auth recovery tests passed")
+79
View File
@@ -0,0 +1,79 @@
"""Exercise the published `curl /install | python3 -` entry point over HTTP."""
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
def test_stdin_bootstrap_installs_verified_runtime_and_preserves_credentials(tmp_path):
source = Path(__file__).resolve().parents[1] / "resources/scripts"
spec = importlib.util.spec_from_file_location("bootstrap_contract", source / "nap_install.py")
installer = importlib.util.module_from_spec(spec)
spec.loader.exec_module(installer)
revision = "f" * 40
items, payloads = [], {}
for name in installer.SCRIPT_NAMES:
content = (source / installer.INSTALL_PATHS[name]).read_text()
item = {"name": name, "version": installer.CLI_RELEASE_VERSION,
"bytes": len(content.encode()), "sha256": hashlib.sha256(content.encode()).hexdigest(),
"source_repo": "https://github.com/napseer/nap-cli", "source_revision": revision,
"source_revision_status": "resolved", "contract_version": installer.CLI_DISTRIBUTION_CONTRACT_VERSION,
"minimum_contract_version": installer.CLI_MINIMUM_CONTRACT_VERSION,
"install_path": installer.INSTALL_PATHS[name], "mode": "0755"}
items.append(item)
payloads["/v1/scripts/" + name] = {**item, "content": content}
payloads["/v1/scripts"] = {"schema_version": installer.CLI_BUNDLE_SCHEMA_VERSION,
"bundle_id": "synthetic-bootstrap", "release_version": installer.CLI_RELEASE_VERSION,
"contract": {"current": installer.CLI_DISTRIBUTION_CONTRACT_VERSION, "minimum_supported": installer.CLI_MINIMUM_CONTRACT_VERSION},
"source": {"repo": "https://github.com/napseer/nap-cli", "revision": revision, "revision_status": "resolved"}, "items": items}
class Provider(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def do_GET(self):
assert self.path in payloads, "bootstrap must only download its declared bundle"
raw = json.dumps(payloads[self.path]).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
server = ThreadingHTTPServer(("127.0.0.1", 0), Provider)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
user_data = tmp_path / "user-data"
user_data.mkdir(exist_ok=True)
auth = user_data / "auth.json"
auth.write_text('{"refresh_token":"synthetic-preserved"}')
env = {"PATH": os.environ["PATH"], "NAPSEER_HOME": str(tmp_path / "runtime"),
"NAPSEER_BIN_DIR": str(tmp_path / "bin"), "NAPSEER_USER_DATA_DIR": str(user_data),
"NAPSEER_PROJECT_ROOT": str(tmp_path), "NAPSEER_BASE_URL": f"http://127.0.0.1:{server.server_port}"}
try:
result = subprocess.run([sys.executable, "-"], input=(source / "nap_install.py").read_text(),
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30)
assert result.returncode == 0, "the advertised stdin bootstrap must install without an explicit subcommand"
assert json.loads(result.stdout)["status"] == "installed"
for name in installer.SCRIPT_NAMES:
installed = tmp_path / "runtime/current" / installer.INSTALL_PATHS[name]
assert installed.read_bytes() == (source / installer.INSTALL_PATHS[name]).read_bytes()
assert auth.read_text() == '{"refresh_token":"synthetic-preserved"}'
# Import the actual activated modules, including the newly added dependency.
env["PYTHONPATH"] = str(tmp_path / "runtime/current")
imported = subprocess.run([sys.executable, "-c", "import napseer_credentials, napseer_mcp_server"],
cwd=tmp_path, env=env, capture_output=True, timeout=10)
assert imported.returncode == 0, "activated runtime must load all dependencies"
result = subprocess.run([sys.executable, str(tmp_path / "bin/nap"), "update"],
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30)
assert result.returncode == 0
assert auth.read_text() == '{"refresh_token":"synthetic-preserved"}'
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
+54 -8
View File
@@ -3,6 +3,7 @@ import hashlib
import json import json
import pathlib import pathlib
import time import time
import sys
import pytest import pytest
@@ -15,6 +16,8 @@ SCRIPT_PATH = (
def load_installer(): def load_installer():
if str(SCRIPT_PATH.parent) not in sys.path:
sys.path.insert(0, str(SCRIPT_PATH.parent))
spec = importlib.util.spec_from_file_location("nap_install_command_system_test", SCRIPT_PATH) spec = importlib.util.spec_from_file_location("nap_install_command_system_test", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) spec.loader.exec_module(module)
@@ -33,7 +36,7 @@ def test_root_help_exposes_only_canonical_commands(capsys):
output = capsys.readouterr().out output = capsys.readouterr().out
visible = [name for name, metadata in module.COMMAND_METADATA.items() if metadata["visible"]] visible = [name for name, metadata in module.COMMAND_METADATA.items() if metadata["visible"]]
assert visible == [name for name, _summary in module.CANONICAL_COMMANDS] assert visible == [name for name, _summary in module.CANONICAL_COMMANDS]
assert len(visible) == 10 assert len(visible) == 13
for name in visible: for name in visible:
assert f" {name}" in output assert f" {name}" in output
for hidden in ("chat", "plan", "lineage", "agent", "feedback", "where", "install"): for hidden in ("chat", "plan", "lineage", "agent", "feedback", "where", "install"):
@@ -101,6 +104,24 @@ def test_init_and_auth_repair_normalize_to_existing_handlers(monkeypatch):
] ]
def test_reindex_routes_to_worker_and_has_side_effect_free_help(monkeypatch, capsys):
module = load_installer()
calls = []
monkeypatch.setattr(
module,
"run_wrapper",
lambda command, args: calls.append((command, args)) or 0,
)
assert module.main(["nap", "reindex", "--limit", "50"]) == 0
assert calls == [("reindex", ["--limit", "50"])]
calls.clear()
assert module.main(["nap", "reindex", "--help"]) == 0
assert calls == []
assert "Usage: nap reindex [--limit N]" in capsys.readouterr().out
def test_update_does_not_overwrite_repository_source(monkeypatch, capsys): def test_update_does_not_overwrite_repository_source(monkeypatch, capsys):
module = load_installer() module = load_installer()
calls = [] calls = []
@@ -418,7 +439,7 @@ def test_bundle_install_stages_validates_and_activates_atomically(tmp_path, monk
result = module.install_assets() result = module.install_assets()
assert result["cli_distribution"]["release_version"] == "0.2.2" assert result["cli_distribution"]["release_version"] == module.CLI_RELEASE_VERSION
assert (install_dir / "current").is_symlink() assert (install_dir / "current").is_symlink()
assert (install_dir / "current" / "bundle-manifest.json").is_file() assert (install_dir / "current" / "bundle-manifest.json").is_file()
assert (install_dir / "current" / "napseer_mcp_server.py").is_file() assert (install_dir / "current" / "napseer_mcp_server.py").is_file()
@@ -475,10 +496,10 @@ def test_manifest_accepts_a_future_compatible_release():
module = load_installer() module = load_installer()
manifest, _payloads = fake_bundle(module) manifest, _payloads = fake_bundle(module)
manifest["release_version"] = "0.2.2" manifest["release_version"] = "0.2.2"
manifest["contract"]["current"] = "2026-08-02" manifest["contract"]["current"] = "2026-09-11"
for item in manifest["items"]: for item in manifest["items"]:
item["version"] = "0.2.2" item["version"] = "0.2.2"
item["contract_version"] = "2026-08-02" item["contract_version"] = "2026-09-11"
items = module.validated_manifest_items(manifest) items = module.validated_manifest_items(manifest)
@@ -488,11 +509,11 @@ def test_manifest_accepts_a_future_compatible_release():
def test_manifest_rejects_a_minimum_contract_newer_than_installer(): def test_manifest_rejects_a_minimum_contract_newer_than_installer():
module = load_installer() module = load_installer()
manifest, _payloads = fake_bundle(module) manifest, _payloads = fake_bundle(module)
manifest["contract"]["current"] = "2026-08-02" manifest["contract"]["current"] = "2026-09-11"
manifest["contract"]["minimum_supported"] = "2026-08-02" manifest["contract"]["minimum_supported"] = "2026-09-11"
for item in manifest["items"]: for item in manifest["items"]:
item["contract_version"] = "2026-08-02" item["contract_version"] = "2026-09-11"
item["minimum_contract_version"] = "2026-08-02" item["minimum_contract_version"] = "2026-09-11"
try: try:
module.validated_manifest_items(manifest) module.validated_manifest_items(manifest)
@@ -502,6 +523,31 @@ def test_manifest_rejects_a_minimum_contract_newer_than_installer():
raise AssertionError("incompatible minimum contract must be rejected") raise AssertionError("incompatible minimum contract must be rejected")
def test_legacy_update_preserves_active_runtime_and_bootstrap_recovers(tmp_path, monkeypatch):
module = load_installer()
monkeypatch.setattr(module, "INSTALL_DIR", tmp_path / "install")
monkeypatch.setattr(module, "BIN_DIR", tmp_path / "bin")
manifest, payloads = fake_bundle(module)
previous = module.INSTALL_DIR / "releases" / "previous"
previous.mkdir(parents=True)
(previous / "nap_install.py").write_text("# existing release\n")
(module.INSTALL_DIR / "current").symlink_to(previous, target_is_directory=True)
credentials = module.INSTALL_DIR / "auth.json"
credentials.write_text('{"refresh_token":"synthetic-preserved"}')
monkeypatch.setattr(module, "bundle_manifest", lambda: manifest)
monkeypatch.setattr(module, "fetch_script", lambda name: payloads[name])
current_contract = module.CLI_DISTRIBUTION_CONTRACT_VERSION
monkeypatch.setattr(module, "CLI_DISTRIBUTION_CONTRACT_VERSION", "2026-08-01")
with pytest.raises(RuntimeError, match="newer bootstrap installer"):
module.main(["nap", "update"])
assert (module.INSTALL_DIR / "current").resolve() == previous
assert list((module.INSTALL_DIR / "releases").iterdir()) == [previous]
monkeypatch.setattr(module, "CLI_DISTRIBUTION_CONTRACT_VERSION", current_contract)
assert module.main(["nap_install.py", "mcp", "install"]) == 0
assert (module.INSTALL_DIR / "current" / "napseer_credentials.py").is_file()
assert credentials.read_text() == '{"refresh_token":"synthetic-preserved"}'
def test_cli_log_compaction_preserves_live_writer_inode(tmp_path): def test_cli_log_compaction_preserves_live_writer_inode(tmp_path):
module = load_installer() module = load_installer()
log_path = tmp_path / "gateway.log" log_path = tmp_path / "gateway.log"
+16
View File
@@ -36,6 +36,22 @@ def expect_unknown(mod, argv, expected_fragment):
raise AssertionError(f"expected RuntimeError for {argv}") raise AssertionError(f"expected RuntimeError for {argv}")
def test_reindex_cli_rebuilds_with_bounded_page_size():
mod = load_module()
calls = []
mod.reindex_project = lambda args: calls.append(dict(args)) or {
"indexed": 7,
"graph_complete": True,
}
output = io.StringIO()
with contextlib.redirect_stdout(output):
mod.cli_main(["napseer_mcp_server.py", "reindex", "--limit", "50"])
assert calls == [{"limit": 50}]
assert '"indexed": 7' in output.getvalue()
def test_auth_login_rejects_history_expansion_tail_before_browser(): def test_auth_login_rejects_history_expansion_tail_before_browser():
mod = load_module() mod = load_module()
mod.operator_account_login = lambda _args: (_ for _ in ()).throw( mod.operator_account_login = lambda _args: (_ for _ in ()).throw(
+2
View File
@@ -15,6 +15,8 @@ def load_module():
def test_renew_auth_serializes_rotating_refresh_tokens(): def test_renew_auth_serializes_rotating_refresh_tokens():
mod = load_module() mod = load_module()
mod.TOKEN = "synthetic-configured-access"
mod.REFRESH_TOKEN = "synthetic-configured-refresh"
active = 0 active = 0
maximum = 0 maximum = 0
guard = threading.Lock() guard = threading.Lock()
+4 -2
View File
@@ -15,6 +15,8 @@ def load_module():
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
assert spec and spec.loader assert spec and spec.loader
spec.loader.exec_module(module) spec.loader.exec_module(module)
module.load_project_locator = lambda: None
module.require_project_locator_service = lambda _locator: None
return module return module
@@ -53,7 +55,7 @@ def run():
mod.DEFAULT_PROJECT_ID = None mod.DEFAULT_PROJECT_ID = None
assert mod.resolve_project_id({}) == "project-1" assert mod.resolve_project_id({}) == "project-1"
current = {} current = {"token": "configured-account", "account_mode": "operator_account"}
mod.DEFAULT_PROJECT_ID = "stale-project" mod.DEFAULT_PROJECT_ID = "stale-project"
bootstrap_calls = [] bootstrap_calls = []
@@ -73,7 +75,7 @@ def run():
} }
] ]
current = {} current = {"token": "configured-account", "account_mode": "operator_account"}
mod.DEFAULT_PROJECT_ID = None mod.DEFAULT_PROJECT_ID = None
def limited_project(_args): def limited_project(_args):
+303
View File
@@ -0,0 +1,303 @@
import importlib.util
import pathlib
import sys
import threading
import time
import pytest
def load_module():
script_path = (
pathlib.Path(__file__).resolve().parents[1]
/ "resources"
/ "scripts"
/ "napseer_mcp_server.py"
)
sys.path.insert(0, str(script_path.parent))
spec = importlib.util.spec_from_file_location("napseer_mcp_batch_test", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
module.DEFAULT_PROJECT_ID = "project-1"
module.CONFIGURED_TOOL_PROFILES = {"all"}
return module
@pytest.fixture
def mod(monkeypatch):
monkeypatch.delenv("NAPSEER_TOOL_PROFILES", raising=False)
return load_module()
def action(action_id, tool="nap_node_get", *, depends_on=None, arguments=None):
return {
"id": action_id,
"tool": tool,
"reason": f"Run {action_id} for batch verification",
"arguments": arguments or {},
"depends_on": depends_on or [],
}
def test_batch_is_a_conservatively_annotated_core_tool(mod):
tool = next(item for item in mod.tools() if item["name"] == "nap_batch")
assert tool["annotations"] == {
"title": "nap_batch",
"readOnlyHint": False,
"destructiveHint": True,
"idempotentHint": False,
"openWorldHint": False,
}
assert tool["napseer"]["lock_policy"] == "per-action-mutation-barrier"
assert tool["napseer"]["idempotency"] == "mixed-no-replay"
schema = tool["inputSchema"]
assert schema["properties"]["actions"]["maxItems"] == 32
assert schema["properties"]["max_parallel"]["maximum"] == 8
assert set(schema["properties"]["actions"]["items"]["required"]) == {
"id", "tool", "reason", "arguments",
}
def test_independent_reads_overlap_and_results_preserve_input_order(mod, monkeypatch):
state_lock = threading.Lock()
gate = threading.Barrier(2)
active = 0
max_active = 0
def fake_call(name, arguments):
nonlocal active, max_active
with state_lock:
active += 1
max_active = max(max_active, active)
gate.wait(timeout=1)
if arguments["value"] == "first":
time.sleep(0.04)
with state_lock:
active -= 1
return {"tool": name, "value": arguments["value"]}
monkeypatch.setattr(mod, "call_tool_impl", fake_call)
result = mod.execute_batch({
"max_parallel": 2,
"actions": [
action("first", arguments={"value": "first"}),
action("second", tool="nap_context", arguments={"value": "second"}),
],
})
assert result["ok"] is True
assert result["failure_policy"] == "continue_on_error"
assert result["parallel_read_groups"] == 1
assert max_active == 2
assert [item["id"] for item in result["results"]] == ["first", "second"]
assert [item["result"]["value"] for item in result["results"]] == ["first", "second"]
def test_mutation_is_an_exclusive_array_order_barrier(mod, monkeypatch):
state_lock = threading.Lock()
active_reads = 0
active_writes = 0
max_reads = 0
violations = []
completed = []
def fake_call(name, arguments):
nonlocal active_reads, active_writes, max_reads
is_read = mod.tool_side_effect(name) == "none"
with state_lock:
if is_read:
if active_writes:
violations.append("read overlapped write")
active_reads += 1
max_reads = max(max_reads, active_reads)
else:
if active_reads or active_writes:
violations.append("write was not exclusive")
active_writes += 1
time.sleep(0.04)
with state_lock:
if is_read:
active_reads -= 1
else:
active_writes -= 1
completed.append(arguments["value"])
return {"value": arguments["value"]}
monkeypatch.setattr(mod, "call_tool_impl", fake_call)
result = mod.execute_batch({
"actions": [
action("read-a", arguments={"value": "read-a"}),
action("read-b", tool="nap_context", arguments={"value": "read-b"}),
action("write", tool="nap_create_node", arguments={"value": "write"}),
action("read-c", arguments={"value": "read-c"}),
action("read-d", tool="nap_context", arguments={"value": "read-d"}),
],
})
assert result["ok"] is True
assert result["failure_policy"] == "stop_on_error"
assert max_reads == 2
assert violations == []
assert completed.index("write") > max(completed.index("read-a"), completed.index("read-b"))
assert completed.index("write") < min(completed.index("read-c"), completed.index("read-d"))
@pytest.mark.parametrize(
("actions", "error_code"),
[
(
[
action("a", depends_on=["b"]),
action("b", tool="nap_context", depends_on=["a"]),
],
"batch_dependency_cycle",
),
([action("a", depends_on=["missing"])], "unknown_batch_dependency"),
([action("a", tool="nap_gateway_status")], "batch_tool_not_allowed"),
([action("a", tool="nap_batch")], "batch_tool_not_allowed"),
],
)
def test_invalid_graphs_and_capabilities_fail_before_execution(
mod, monkeypatch, actions, error_code
):
calls = []
monkeypatch.setattr(mod, "call_tool_impl", lambda *args: calls.append(args))
with pytest.raises(mod.SafeToolError) as error:
mod.execute_batch({"actions": actions})
assert error.value.code == error_code
assert calls == []
def test_dependency_failure_skips_dependents_but_runs_independent_reads(
mod, monkeypatch
):
calls = []
def fake_call(_name, arguments):
calls.append(arguments["value"])
if arguments["value"] == "fails":
raise mod.SafeToolError("fixture_failure", "fixture failed safely")
return {"value": arguments["value"]}
monkeypatch.setattr(mod, "call_tool_impl", fake_call)
result = mod.execute_batch({
"failure_policy": "continue_on_error",
"actions": [
action("fails", arguments={"value": "fails"}),
action("dependent", depends_on=["fails"], arguments={"value": "dependent"}),
action("independent", tool="nap_context", arguments={"value": "independent"}),
],
})
assert result["ok"] is False
assert result["stopped_at"] is None
assert [item["status"] for item in result["results"]] == [
"failed", "skipped", "succeeded",
]
assert set(calls) == {"fails", "independent"}
def test_stop_on_error_does_not_schedule_a_later_read_wave(mod, monkeypatch):
calls = []
def fake_call(_name, arguments):
calls.append(arguments["value"])
raise mod.SafeToolError("fixture_failure", "fixture failed safely")
monkeypatch.setattr(mod, "call_tool_impl", fake_call)
result = mod.execute_batch({
"failure_policy": "stop_on_error",
"max_parallel": 1,
"actions": [
action("first", arguments={"value": "first"}),
action("second", tool="nap_context", arguments={"value": "second"}),
],
})
assert calls == ["first"]
assert result["stopped_at"] == "first"
assert [item["status"] for item in result["results"]] == ["failed", "skipped"]
def test_dry_run_validates_without_invoking_actions(mod, monkeypatch):
calls = []
monkeypatch.setattr(mod, "call_tool_impl", lambda *args: calls.append(args))
result = mod.execute_batch({
"dry_run": True,
"actions": [
action("read", arguments={"node_id": "node-1"}),
action("write", tool="nap_create_node", arguments={"name": "note"}),
],
})
assert calls == []
assert result["failure_policy"] == "dry_run"
assert result["contains_mutations"] is True
assert [item["status"] for item in result["results"]] == ["planned", "planned"]
def test_cancellation_prevents_unscheduled_actions_from_starting(mod, monkeypatch):
cancelled = threading.Event()
calls = []
def fake_call(_name, arguments):
calls.append(arguments["value"])
cancelled.set()
return {"ok": True}
monkeypatch.setattr(mod, "call_tool_impl", fake_call)
mod.MCP_REQUEST_LOCAL.cancelled = cancelled
try:
with pytest.raises(mod.SafeToolError) as error:
mod.execute_batch({
"max_parallel": 1,
"actions": [
action("first", arguments={"value": "first"}),
action("second", tool="nap_context", arguments={"value": "second"}),
],
})
finally:
del mod.MCP_REQUEST_LOCAL.cancelled
assert error.value.code == "request_cancelled"
assert calls == ["first"]
def test_protocol_lists_and_dry_runs_batch(mod):
listed = mod.handle({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
names = [item["name"] for item in listed["result"]["tools"]]
assert "nap_batch" in names
called = mod.handle({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "nap_batch",
"arguments": {
"dry_run": True,
"actions": [action("read", arguments={"node_id": "node-1"})],
},
},
})
payload = called["result"]["structuredContent"]
assert called["result"]["isError"] is False
assert payload["ok"] is True
assert payload["results"][0]["status"] == "planned"
def test_profile_gated_tool_is_rejected_before_execution(mod, monkeypatch):
mod.CONFIGURED_TOOL_PROFILES = set()
calls = []
monkeypatch.setattr(mod, "call_tool_impl", lambda *args: calls.append(args))
with pytest.raises(mod.SafeToolError) as error:
mod.execute_batch({"actions": [action("plan", tool="nap_plan_list_active")]})
assert error.value.code == "tool_profile_required"
assert calls == []
+90 -6
View File
@@ -147,6 +147,34 @@ def test_node_by_path_returns_canonical_identity_uri(mod):
assert "Path lookup is index/route resolution only" in result["warnings"][0] assert "Path lookup is index/route resolution only" in result["warnings"][0]
def test_node_by_path_resolves_project_anchor_shortcut(mod):
def fake_request(method, path, payload=None, **kwargs):
assert method == "GET"
assert path.endswith("/nodes/by-path?path=%40project")
return {
"identity": {
"node_id": "anchor-1",
"canonical_uri": "nap://napseer/project-1/anchor-1",
"project_id": "project-1",
"lookup_kind": "shortcut",
"lookup_value": "@project",
"legacy_full_path": "/routes/project/core",
},
"route": {
"id": "anchor-1",
"project_id": "project-1",
"full_path": "/routes/project/core",
},
}
mod.request_json = fake_request
result = mod.node_by_path({"shortcut": "@Project"})
assert result["identity"]["lookup_kind"] == "shortcut"
assert result["identity"]["lookup_value"] == "@project"
assert result["route"]["full_path"] == "/routes/project/core"
def test_path_resolver_then_read_uses_node_id_route(mod): def test_path_resolver_then_read_uses_node_id_route(mod):
calls = [] calls = []
@@ -255,7 +283,7 @@ def test_node_get_rejects_path_reads(mod):
def test_tools_include_node_by_path_descriptor(mod): def test_tools_include_node_by_path_descriptor(mod):
tool_by_name = {item["name"]: item for item in mod.tools()} tool_by_name = {item["name"]: item for item in mod.tools()}
assert len(tool_by_name) == 51 assert len(tool_by_name) == 58
assert len(tool_by_name) == len(mod.tools()) assert len(tool_by_name) == len(mod.tools())
get_tool = next(item for item in mod.tools() if item["name"] == "nap_node_get") get_tool = next(item for item in mod.tools() if item["name"] == "nap_node_get")
@@ -263,9 +291,19 @@ def test_tools_include_node_by_path_descriptor(mod):
assert "node_id" in get_tool["inputSchema"]["properties"] assert "node_id" in get_tool["inputSchema"]["properties"]
assert "uri" in get_tool["inputSchema"]["properties"] assert "uri" in get_tool["inputSchema"]["properties"]
assert "path" not in get_tool["inputSchema"]["properties"] assert "path" not in get_tool["inputSchema"]["properties"]
assert "node_id returned by nap_discover" in get_tool["description"]
discover_tool = next(item for item in mod.tools() if item["name"] == "nap_discover")
assert "node_id on every row" in discover_tool["description"]
assert "directly to nap_node_get" in discover_tool["description"]
tool = next(item for item in mod.tools() if item["name"] == "nap_node_by_path") tool = next(item for item in mod.tools() if item["name"] == "nap_node_by_path")
assert tool["inputSchema"]["required"] == ["path"] assert tool["inputSchema"].get("required") is None
assert tool["inputSchema"]["oneOf"] == [
{"required": ["path"]},
{"required": ["shortcut"]},
]
assert "shortcut" in tool["inputSchema"]["properties"]
assert "preview_chars" not in tool["inputSchema"]["properties"] assert "preview_chars" not in tool["inputSchema"]["properties"]
assert "does not read full node content" in tool["description"] assert "does not read full node content" in tool["description"]
@@ -425,9 +463,9 @@ def test_tee_and_bulk_create_by_path_update_by_identity(mod):
"content_text": payload.get("content_text", existing["content_text"]), "content_text": payload.get("content_text", existing["content_text"]),
} }
mod.index_node = lambda node: None mod.index_node = lambda node: None
mod.acquire_project_lock = lambda args: {"id": "lock-1", "lease_token": "lease-1"} mod.acquire_project_lock = lambda args, **kwargs: {"id": "lock-1", "lease_token": "lease-1"}
mod.lock_headers = lambda lock: {"X-Lock": lock["id"]} mod.lock_headers = lambda lock: {"X-Lock": lock["id"]}
mod.release_project_lock = lambda args: None mod.release_project_lock = lambda args, **kwargs: None
mod.request_json = lambda method, path, payload=None, **kwargs: writes.append((method, path, payload, ())) or { mod.request_json = lambda method, path, payload=None, **kwargs: writes.append((method, path, payload, ())) or {
**existing, **existing,
"content_text": payload.get("content_text", existing["content_text"]) if payload else existing["content_text"], "content_text": payload.get("content_text", existing["content_text"]) if payload else existing["content_text"],
@@ -591,6 +629,39 @@ def test_context_and_related_use_identity_inputs(mod):
mod.find_related_nodes({"path": "/notes/root"}) mod.find_related_nodes({"path": "/notes/root"})
def test_hybrid_context_resolves_path_inside_complete_local_snapshot(mod):
with tempfile.TemporaryDirectory() as tmp:
state_dir = pathlib.Path(tmp)
mod.AUTH_DIR = state_dir
mod.INDEX_PATH = state_dir / "index.sqlite"
mod.INDEX_LOCK_PATH = state_dir / "index.lock"
indexed = node("/notes/local-root", "active", node_type="note")
indexed["id"] = "local-root-123"
mod.index_node(indexed)
mod.set_local_graph_index_complete("project-1", True)
def unexpected_request(*_args, **_kwargs):
raise AssertionError("complete local context must not make an HTTP request")
mod.request_json = unexpected_request
result = mod.get_memory_context({
"path": "/notes/local-root",
"depth": 0,
"mode": "hybrid",
})
assert result["source"] == "local_index"
assert result["seed_node_ids"] == ["local-root-123"]
assert result["items"][0]["full_path"] == "/notes/local-root"
with mod.index_connect() as conn:
plan = conn.execute(
"EXPLAIN QUERY PLAN SELECT node_id FROM local_index_nodes "
"WHERE project_id = ? AND full_path = ? AND archived = 0",
("project-1", "/notes/local-root"),
).fetchall()
assert any("local_index_nodes_project_path" in row["detail"] for row in plan)
def test_local_helper_ui_forms_submit_identity_for_existing_node_actions(): def test_local_helper_ui_forms_submit_identity_for_existing_node_actions():
source = (pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py").read_text() source = (pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py").read_text()
assert "name='node_id' value='{html.escape(item.get('id') or '')}'" in source assert "name='node_id' value='{html.escape(item.get('id') or '')}'" in source
@@ -643,6 +714,7 @@ def test_recent_memory_defaults_to_paths(mod):
assert result["ok"] is True assert result["ok"] is True
assert result["view"] == "paths" assert result["view"] == "paths"
assert result["items"] == [{ assert result["items"] == [{
"node_id": "id-recent-title",
"full_path": "/notes/recent-title", "full_path": "/notes/recent-title",
"type": "note", "type": "note",
"status": "active", "status": "active",
@@ -671,6 +743,7 @@ def test_recent_memory_accepts_date_aliases_and_returns_dates(mod):
mod.request_json = fake_request mod.request_json = fake_request
result = mod.recent_memory({"since": "2026-05-05", "until": "2026-05-06"}) result = mod.recent_memory({"since": "2026-05-05", "until": "2026-05-06"})
assert result["items"] == [{ assert result["items"] == [{
"node_id": "id-recent-title",
"full_path": "/notes/recent-title", "full_path": "/notes/recent-title",
"type": "note", "type": "note",
"status": "active", "status": "active",
@@ -699,6 +772,7 @@ def test_find_defaults_to_paths_and_requests_compact_rest_view(mod):
assert result["ok"] is True assert result["ok"] is True
assert result["view"] == "paths" assert result["view"] == "paths"
assert result["items"] == [{ assert result["items"] == [{
"node_id": "id-found",
"full_path": "/plans/found", "full_path": "/plans/found",
"type": "plan", "type": "plan",
"status": "planned", "status": "planned",
@@ -721,6 +795,8 @@ def test_discover_uses_search_when_keywords_present(mod):
assert calls and calls[-1]["q"] == "gateway relay" assert calls and calls[-1]["q"] == "gateway relay"
assert calls[-1]["view"] == "summary" assert calls[-1]["view"] == "summary"
assert "date" in result["awareness"]["date_fields_in_rows"] assert "date" in result["awareness"]["date_fields_in_rows"]
assert "item.node_id" in result["awareness"]["next_step"]
assert "outside discovery" in result["awareness"]["next_step"]
def test_classify_query_points_exact_paths_to_context(mod): def test_classify_query_points_exact_paths_to_context(mod):
@@ -748,7 +824,11 @@ def test_title_view_requests_summary_rest_view_without_content(mod):
result = mod.list_project_nodes({"view": "titles"}) result = mod.list_project_nodes({"view": "titles"})
assert result["ok"] is True assert result["ok"] is True
assert result["view"] == "titles" assert result["view"] == "titles"
assert result["items"] == [{"title": "Readable Title", "full_path": "/notes/titled"}] assert result["items"] == [{
"node_id": "id-titled",
"title": "Readable Title",
"full_path": "/notes/titled",
}]
assert "content_text" not in result["items"][0] assert "content_text" not in result["items"][0]
@@ -781,7 +861,8 @@ def test_index_status_reports_sync_high_water_mark(mod):
status = mod.index_status({}) status = mod.index_status({})
assert status["count"] == 1 assert status["count"] == 1
assert status["latest_node_updated_at"] == "2026-05-06T12:00:00Z" assert status["latest_node_updated_at"] == "2026-05-06T12:00:00Z"
assert status["sync_recommended"] is False assert status["graph_complete"] is False
assert status["sync_recommended"] is True
def test_index_sync_fetches_incremental_changes_and_removes_archived_nodes(mod): def test_index_sync_fetches_incremental_changes_and_removes_archived_nodes(mod):
@@ -794,6 +875,7 @@ def test_index_sync_fetches_incremental_changes_and_removes_archived_nodes(mod):
existing["id"] = "existing-node" existing["id"] = "existing-node"
existing["updated_at"] = "2026-05-06T12:00:00Z" existing["updated_at"] = "2026-05-06T12:00:00Z"
mod.index_node(existing) mod.index_node(existing)
mod.set_local_graph_index_complete("project-1", True)
fresh = node("/notes/fresh", "active", content="fresh sync needle") fresh = node("/notes/fresh", "active", content="fresh sync needle")
fresh["id"] = "fresh-node" fresh["id"] = "fresh-node"
@@ -817,6 +899,8 @@ def test_index_sync_fetches_incremental_changes_and_removes_archived_nodes(mod):
assert result["seen"] == 2 assert result["seen"] == 2
assert result["indexed"] == 1 assert result["indexed"] == 1
assert result["removed"] == 1 assert result["removed"] == 1
assert result["mode"] == "incremental"
assert result["index"]["graph_complete"] is True
assert calls assert calls
local = mod.search_local_index({"q": "fresh needle"}) local = mod.search_local_index({"q": "fresh needle"})
assert [item["id"] for item in local["items"]] == ["fresh-node"] assert [item["id"] for item in local["items"]] == ["fresh-node"]
+15 -5
View File
@@ -194,8 +194,13 @@ def run():
"precondition": {"revision": "r1", "read_fingerprint": "fp1"}, "precondition": {"revision": "r1", "read_fingerprint": "fp1"},
"content_op": {"op": "replace_all", "content_text": "plaintext"}, "content_op": {"op": "replace_all", "content_text": "plaintext"},
}) })
except RuntimeError as exc: except mod.SafeToolError as exc:
assert "cannot safely patch encrypted content or metadata" in str(exc) assert exc.code == "encrypted_patch_requires_full_replacement"
assert "cannot safely patch client-E2E encrypted content or metadata" in str(exc)
assert "nap_bulk" in str(exc)
assert "fresh read" in str(exc)
assert "nap_tee" not in str(exc)
assert "nap_patch" not in str(exc)
else: else:
raise AssertionError("nap_node_patch content updates must fail closed for encrypted projects") raise AssertionError("nap_node_patch content updates must fail closed for encrypted projects")
@@ -296,7 +301,12 @@ def run():
active = mod.list_project_nodes({"folder_path": "/plans", "active_only": True}) active = mod.list_project_nodes({"folder_path": "/plans", "active_only": True})
assert [item["full_path"] for item in active["items"]] == ["/plans/active"] assert [item["full_path"] for item in active["items"]] == ["/plans/active"]
status_filtered = mod.list_project_nodes({"folder_path": "/plans", "status": ["completed"], "view": "paths"}) status_filtered = mod.list_project_nodes({"folder_path": "/plans", "status": ["completed"], "view": "paths"})
assert status_filtered["items"] == [{"full_path": "/plans/done", "type": "note", "status": "completed"}] assert status_filtered["items"] == [{
"node_id": "done",
"full_path": "/plans/done",
"type": "note",
"status": "completed",
}]
def search_response(method, path, payload=None, **kwargs): def search_response(method, path, payload=None, **kwargs):
assert "q=" in path assert "q=" in path
@@ -383,9 +393,9 @@ def run():
assert patch_body["encrypted_content_envelope"]["aad_subject"] == "node_id:node-1" assert patch_body["encrypted_content_envelope"]["aad_subject"] == "node_id:node-1"
bulk_writes = [] bulk_writes = []
mod.acquire_project_lock = lambda args: {"id": "lock-1", "lease_token": "lease-1"} mod.acquire_project_lock = lambda args, **kwargs: {"id": "lock-1", "lease_token": "lease-1"}
mod.lock_headers = lambda lock: {"X-Lock": lock["id"]} mod.lock_headers = lambda lock: {"X-Lock": lock["id"]}
mod.release_project_lock = lambda args: None mod.release_project_lock = lambda args, **kwargs: None
mod.try_get_node_by_path = lambda path, allow_agent=False: None mod.try_get_node_by_path = lambda path, allow_agent=False: None
mod.request_json = lambda method, path, payload=None, **kwargs: bulk_writes.append((method, path, payload, kwargs)) or { mod.request_json = lambda method, path, payload=None, **kwargs: bulk_writes.append((method, path, payload, kwargs)) or {
"id": "node-bulk", "id": "node-bulk",
+419 -3
View File
@@ -2,6 +2,7 @@
"""Contract tests for the reduced, bounded agent-memory surface.""" """Contract tests for the reduced, bounded agent-memory surface."""
import importlib.util import importlib.util
import json
import pathlib import pathlib
import sys import sys
import tempfile import tempfile
@@ -24,15 +25,19 @@ def load_module():
def mod(monkeypatch): def mod(monkeypatch):
monkeypatch.delenv("NAPSEER_TOOL_PROFILES", raising=False) monkeypatch.delenv("NAPSEER_TOOL_PROFILES", raising=False)
module = load_module() module = load_module()
module._auth_state = tempfile.TemporaryDirectory()
module.AUTH_PATH = pathlib.Path(module._auth_state.name) / "auth.json"
module.AUTH = {"account_id": "acct-1", "token": "token"}
module.CONFIGURED_TOOL_PROFILES = None module.CONFIGURED_TOOL_PROFILES = None
return module return module
def test_default_surface_is_exactly_the_core_fourteen(mod): def test_default_surface_includes_knowledge_files_and_coordination(mod):
assert [tool["name"] for tool in mod.tools()] == [ assert [tool["name"] for tool in mod.tools()] == [
"nap_apropos", "nap_man", "nap_doctor", "nap_whoami", "nap_apropos", "nap_man", "nap_doctor", "nap_whoami",
"nap_operation_list", "nap_operation", "nap_file_list", "nap_file_upload", "nap_file_download", "nap_file_archive",
"nap_discover", "nap_context", "nap_node_by_path", "nap_node_get", "nap_discover", "nap_context", "nap_node_by_path", "nap_node_get",
"nap_create_node", "nap_node_patch", "nap_bulk", "nap_ln", "nap_create_node", "nap_node_patch", "nap_bulk", "nap_batch", "nap_ln",
"nap_mv", "nap_rm", "nap_mv", "nap_rm",
] ]
@@ -64,6 +69,67 @@ def test_pruned_and_profile_gated_calls_are_actionable(mod):
assert "workflow" in gated.value.safe_message assert "workflow" in gated.value.safe_message
def test_removed_write_tools_and_unknown_manual_lookups_are_actionable(mod):
tee = mod.man_tool({"tool": "nap_tee"})
assert tee["removed"] is True
assert "nap_create_node" in tee["replacement"]
assert "nap_bulk" in tee["replacement"]
patch = mod.man_tool({"tool": "nap_patch"})
assert patch["removed"] is True
assert "nap_node_patch" in patch["replacement"]
assert "nap_bulk" in patch["replacement"]
for name in ("nap_tee", "nap_patch"):
with pytest.raises(mod.SafeToolError) as removed:
mod.call_tool_impl(name, {})
assert removed.value.code == "tool_removed"
with pytest.raises(mod.SafeToolError) as unknown:
mod.man_tool({"tool": "not_a_real_tool"})
assert unknown.value.code == "unknown_tool"
assert "nap_apropos" in unknown.value.safe_message
def test_node_patch_rejects_malformed_arguments_before_project_or_node_reads(mod):
mod.resolve_project_id = lambda args: (_ for _ in ()).throw(
AssertionError("invalid arguments must fail before project resolution")
)
mod.get_node_by_id = lambda args: (_ for _ in ()).throw(
AssertionError("invalid arguments must fail before node reads")
)
base = {
"node_id": "node-1",
"precondition": {"revision": "r1", "read_fingerprint": "fp1"},
}
with pytest.raises(mod.SafeToolError) as legacy_metadata:
mod.guarded_node_patch({**base, "metadata_patch": {"set": {"status": "done"}}})
assert legacy_metadata.value.code == "invalid_arguments"
assert "metadata_patch" in legacy_metadata.value.safe_message
with pytest.raises(mod.SafeToolError) as legacy_content:
mod.guarded_node_patch({
**base,
"content_op": {"kind": "replace_all", "content_text": "updated"},
})
assert legacy_content.value.code == "invalid_arguments"
assert "use 'op'" in legacy_content.value.safe_message
def test_node_patch_argument_validation_accepts_canonical_shapes(mod):
mod.validate_node_patch_arguments({
"node_id": "node-1",
"precondition": {"revision": "r1", "read_fingerprint": "fp1"},
"content_op": {"op": "replace_all", "content_text": "updated"},
"merge_metadata": {"status": "done"},
"tag_ops": {"add": ["verified"]},
"link_ops": [
{"op": "add", "path": "/plans/example", "relation": "implements"}
],
})
def test_context_and_node_read_schemas_are_bounded(mod): def test_context_and_node_read_schemas_are_bounded(mod):
by_name = {tool["name"]: tool for tool in mod.all_tools()} by_name = {tool["name"]: tool for tool in mod.all_tools()}
context = by_name["nap_context"]["inputSchema"]["properties"] context = by_name["nap_context"]["inputSchema"]["properties"]
@@ -73,6 +139,10 @@ def test_context_and_node_read_schemas_are_bounded(mod):
assert "relations" in context assert "relations" in context
assert "max_edges" in context assert "max_edges" in context
assert "max_per_level" in context assert "max_per_level" in context
assert context["node_ids"]["maxItems"] == 10
assert context["paths"]["maxItems"] == 10
assert context["shortcuts"]["maxItems"] == 10
assert context["uris"]["maxItems"] == 10
node_get = by_name["nap_node_get"]["inputSchema"]["properties"] node_get = by_name["nap_node_get"]["inputSchema"]["properties"]
assert node_get["view"]["enum"] == ["content", "outline", "edit"] assert node_get["view"]["enum"] == ["content", "outline", "edit"]
@@ -83,10 +153,47 @@ def test_tool_text_does_not_duplicate_large_structured_payload(mod):
payload = {"ok": True, "items": [{"content_text": "x" * 20_000}]} payload = {"ok": True, "items": [{"content_text": "x" * 20_000}]}
result = mod.tool_result(payload) result = mod.tool_result(payload)
assert result["structuredContent"] == payload assert result["structuredContent"] == payload
assert len(result["content"][0]["text"]) < 200 assert len(result["content"][0]["text"]) < 2_000
assert "20000" not in result["content"][0]["text"] assert "20000" not in result["content"][0]["text"]
def test_tool_text_keeps_small_structured_results_useful_for_text_only_clients(mod):
payload = {
"status": "current",
"full_path": "/docs/useful",
"view": "content",
"content_text": "A small useful body.",
}
result = mod.tool_result(payload)
assert json.loads(result["content"][0]["text"]) == payload
def test_large_graph_text_fallback_contains_bounded_node_and_edge_previews(mod):
payload = {
"ok": True,
"nodes": [
{"id": f"node-{index}", "full_path": f"/docs/{index}", "title": f"Node {index}"}
for index in range(100)
],
"edges": [
{"source": f"node-{index}", "target": f"node-{index + 1}", "relation": "references"}
for index in range(99)
],
"warnings": ["Use first-class links."],
}
fallback = json.loads(mod.tool_result(payload)["content"][0]["text"])
assert fallback["nodes_count"] == 100
assert fallback["edges_count"] == 99
assert len(fallback["nodes_preview"]) == 5
assert len(fallback["edges_preview"]) == 5
assert fallback["warnings"] == ["Use first-class links."]
assert fallback["structured_result"] is True
def test_local_context_returns_title_radius_without_node_bodies(mod): def test_local_context_returns_title_radius_without_node_bodies(mod):
state = tempfile.TemporaryDirectory() state = tempfile.TemporaryDirectory()
mod._surface_state = state mod._surface_state = state
@@ -123,6 +230,315 @@ def test_local_context_returns_title_radius_without_node_bodies(mod):
assert result["source"] == "local_index" assert result["source"] == "local_index"
def test_local_context_resolves_anchor_shortcut_without_extra_tool(mod):
state = tempfile.TemporaryDirectory()
mod._surface_shortcut_state = state
mod.AUTH_DIR = pathlib.Path(state.name)
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
mod.index_node({
"id": "anchor", "project_id": "project-1",
"full_path": "/routes/project/core", "folder_path": "/routes/project",
"name": "core", "type": "anchor_route", "aliases": ["shortcut.project"],
"metadata": {"title": "Project"}, "tags": [],
"links": [{"node_id": "definition", "relation": "defines"}],
"content_text": "route body", "updated_at": "2026-08-16T12:00:00Z",
})
mod.index_node({
"id": "definition", "project_id": "project-1",
"full_path": "/documentation/product/what-we-are-building",
"folder_path": "/documentation/product", "name": "what-we-are-building",
"type": "definition", "aliases": [], "metadata": {"title": "Product"},
"tags": [], "links": [], "content_text": "definition body",
"updated_at": "2026-08-16T12:01:00Z",
})
result = mod.get_memory_context({
"shortcut": "@Project", "mode": "local", "depth": 1,
"direction": "outgoing",
})
assert result["seed_node_ids"] == ["anchor"]
assert [item["id"] for item in result["nodes"]] == ["anchor", "definition"]
assert all("content_text" not in item for item in result["nodes"])
def test_duplicate_local_anchor_shortcuts_fail_closed(mod):
state = tempfile.TemporaryDirectory()
mod._surface_duplicate_shortcut_state = state
mod.AUTH_DIR = pathlib.Path(state.name)
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
for index in range(2):
mod.index_node({
"id": f"anchor-{index}", "project_id": "project-1",
"full_path": f"/routes/project/{index}", "folder_path": "/routes/project",
"name": str(index), "type": "anchor_route", "aliases": ["shortcut.project"],
"metadata": {}, "tags": [], "links": [], "content_text": "",
"updated_at": f"2026-08-16T12:0{index}:00Z",
})
with pytest.raises(mod.SafeToolError) as ambiguous:
mod.get_memory_context({"shortcut": "@project", "mode": "local"})
assert ambiguous.value.code == "anchor_shortcut_ambiguous"
def test_anchor_integrity_reports_budgets_duplicates_and_broken_links(mod):
state = tempfile.TemporaryDirectory()
mod._surface_anchor_health_state = state
mod.AUTH_DIR = pathlib.Path(state.name)
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
for index in range(2):
mod.index_node({
"id": f"anchor-{index}", "project_id": "project-1",
"full_path": f"/routes/project/{index}", "folder_path": "/routes/project",
"name": str(index), "type": "anchor_route", "aliases": ["shortcut.project"],
"metadata": {}, "tags": [],
"links": ([{"path": "/missing", "relation": "routes-to"}] if index == 0 else []),
"content_text": "", "updated_at": f"2026-08-16T12:0{index}:00Z",
})
mod.set_local_graph_index_complete("project-1", True)
report = mod.local_anchor_integrity()
assert report["ok"] is False
assert report["graph_complete"] is True
assert report["duplicate_shortcuts"][0]["shortcut"] == "@project"
assert report["broken_anchor_links"] == [{
"source": "/routes/project/0",
"target": "/missing",
"relation": "routes-to",
}]
def test_local_context_reports_partial_and_total_missing_seeds_actionably(mod):
state = tempfile.TemporaryDirectory()
mod._surface_missing_seed_state = state
mod.AUTH_DIR = pathlib.Path(state.name)
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
mod.index_node({
"id": "root", "project_id": "project-1", "full_path": "/docs/root",
"folder_path": "/docs", "name": "root", "type": "note",
"metadata": {"title": "Root"}, "tags": [], "links": [],
"content_text": "body", "updated_at": "2026-08-16T12:00:00Z",
})
partial = mod.get_memory_context({
"node_ids": ["root", "missing"], "mode": "local", "depth": 0,
})
assert partial["requested_seed_node_ids"] == ["root", "missing"]
assert partial["seed_node_ids"] == ["root"]
assert partial["missing_seed_node_ids"] == ["missing"]
assert any("active project" in warning for warning in partial["warnings"])
with pytest.raises(mod.SafeToolError) as missing:
mod.get_memory_context({"node_id": "missing", "mode": "local"})
assert missing.value.code == "context_seed_not_found"
assert "nap_whoami" in missing.value.safe_message
assert "nap_discover" in missing.value.safe_message
def test_context_translates_server_not_found_into_seed_diagnostic(mod):
def missing_request(*_args, **_kwargs):
raise mod.SafeToolError(
"http_404",
"Napseer GET request failed with HTTP 404.",
status=404,
service_code="not_found",
)
mod.request_json = missing_request
with pytest.raises(mod.SafeToolError) as missing:
mod.get_memory_context({"node_id": "missing", "mode": "server"})
assert missing.value.code == "context_seed_not_found"
assert missing.value.status == 404
assert mod.safe_exception_payload(missing.value)["service_code"] == "not_found"
def test_context_rejects_cross_project_uri_before_graph_reads(mod):
with pytest.raises(mod.SafeToolError) as mismatch:
mod.get_memory_context({
"uri": "nap://other-project/project-2/node-1",
"mode": "server",
})
assert mismatch.value.code == "context_project_mismatch"
assert "nap_whoami" in mismatch.value.safe_message
def test_local_context_projects_requested_view_and_bounds_frontier(mod):
state = tempfile.TemporaryDirectory()
mod._surface_view_state = state
mod.AUTH_DIR = pathlib.Path(state.name)
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
children = []
for index in range(5):
children.append({
"id": f"child-{index}", "project_id": "project-1",
"full_path": f"/docs/child-{index}", "folder_path": "/docs",
"name": f"child-{index}", "type": "note", "metadata": {},
"tags": [], "links": [], "content_text": "",
"updated_at": f"2026-08-16T12:0{index + 1}:00Z",
})
mod.index_node({
"id": "root", "project_id": "project-1", "full_path": "/docs/root",
"folder_path": "/docs", "name": "root", "type": "note",
"metadata": {"title": "Root"}, "tags": [],
"links": [
{"node_id": child["id"], "relation": "references"}
for child in children
],
"content_text": "", "updated_at": "2026-08-16T12:00:00Z",
})
for child in children:
mod.index_node(child)
result = mod.get_memory_context({
"node_id": "root", "mode": "local", "depth": 1,
"max_nodes": 2, "max_per_level": 1, "view": "paths",
})
assert result["view"] == "paths"
assert "title" not in result["nodes"][0]
assert set(result["nodes"][0]) == {
"id", "full_path", "type", "status", "updated_at", "depth"
}
assert len(result["frontier"]) <= 1
assert "max_frontier" in result["truncation_reasons"]
assert result["node_count"] == 2
assert result["edge_count"] == 1
def test_context_explains_empty_first_class_graph(mod):
state = tempfile.TemporaryDirectory()
mod._surface_empty_graph_state = state
mod.AUTH_DIR = pathlib.Path(state.name)
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
mod.index_node({
"id": "root", "project_id": "project-1", "full_path": "/docs/root",
"folder_path": "/docs", "name": "root", "type": "note",
"metadata": {"title": "Root"}, "tags": [], "links": [],
"content_text": "mentions /docs/other only in prose",
"updated_at": "2026-08-16T12:00:00Z",
})
result = mod.get_memory_context({"node_id": "root", "mode": "local", "depth": 1})
assert any("Prose mentions are not traversed" in warning for warning in result["warnings"])
assert "nap_ln" in result["next_action"]
def test_hybrid_context_falls_back_until_local_graph_coverage_is_complete(mod):
state = tempfile.TemporaryDirectory()
mod._surface_fallback_state = state
mod.AUTH_DIR = pathlib.Path(state.name)
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
mod.index_node({
"id": "root", "project_id": "project-1", "full_path": "/docs/root",
"folder_path": "/docs", "name": "root", "type": "note",
"metadata": {"title": "Root"}, "tags": [], "links": [],
"content_text": "body", "updated_at": "2026-08-16T12:00:00Z",
})
calls = []
def server_context(_args, seed_ids):
calls.append(seed_ids)
return {"ok": True, "nodes": [{"id": "root"}], "edges": [], "source": "server"}
mod.server_memory_context = server_context
result = mod.get_memory_context({"node_id": "root", "mode": "hybrid"})
assert result["source"] == "server"
assert result["local_fallback"]
assert calls == [["root"]]
def test_local_context_does_not_emit_edges_beyond_requested_depth(mod):
state = tempfile.TemporaryDirectory()
mod._surface_depth_state = state
mod.AUTH_DIR = pathlib.Path(state.name)
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
nodes = [
{
"id": "root", "project_id": "project-1", "full_path": "/docs/root",
"folder_path": "/docs", "name": "root", "type": "note", "metadata": {},
"tags": [], "links": [
{"node_id": "left", "relation": "references"},
{"node_id": "right", "relation": "references"},
], "content_text": "", "updated_at": "2026-08-16T12:00:00Z",
},
{
"id": "left", "project_id": "project-1", "full_path": "/docs/left",
"folder_path": "/docs", "name": "left", "type": "note", "metadata": {},
"tags": [], "links": [{"node_id": "right", "relation": "explains"}],
"content_text": "", "updated_at": "2026-08-16T12:01:00Z",
},
{
"id": "right", "project_id": "project-1", "full_path": "/docs/right",
"folder_path": "/docs", "name": "right", "type": "note", "metadata": {},
"tags": [], "links": [], "content_text": "",
"updated_at": "2026-08-16T12:02:00Z",
},
]
for item in nodes:
mod.index_node(item)
result = mod.get_memory_context({
"node_id": "root", "mode": "local", "depth": 1, "direction": "both",
})
assert {item["id"] for item in result["nodes"]} == {"root", "left", "right"}
assert result["edges"] == [
{"source": "root", "target": "left", "relation": "references"},
{"source": "root", "target": "right", "relation": "references"},
]
def test_index_sync_upgrades_pre_graph_index_with_full_reindex(mod):
state = tempfile.TemporaryDirectory()
mod._surface_upgrade_state = state
mod.AUTH_DIR = pathlib.Path(state.name)
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
mod.index_node({
"id": "old", "project_id": "project-1", "full_path": "/docs/old",
"folder_path": "/docs", "name": "old", "type": "note",
"metadata": {}, "tags": [], "links": [], "content_text": "old",
"updated_at": "2026-08-16T12:00:00Z",
})
fresh = {
"id": "fresh", "project_id": "project-1", "full_path": "/docs/fresh",
"folder_path": "/docs", "name": "fresh", "type": "note",
"metadata": {}, "tags": [],
"links": [{"node_id": "target", "relation": "references"}],
"content_text": "fresh", "updated_at": "2026-08-16T12:01:00Z",
}
mod.request_json = lambda *_args, **_kwargs: {"items": [fresh], "next_cursor": None}
result = mod.sync_local_index({})
assert result["mode"] == "full_reindex"
assert result["reason"] == "local_graph_index_incomplete"
assert result["graph_complete"] is True
assert result["index"]["graph_complete"] is True
with mod.index_connect() as conn:
assert conn.execute(
"SELECT COUNT(*) FROM local_index_edges WHERE project_id = ?",
("project-1",),
).fetchone()[0] == 1
def test_discovery_sort_and_returned_item_facets(mod): def test_discovery_sort_and_returned_item_facets(mod):
mod.list_project_nodes = lambda args: { mod.list_project_nodes = lambda args: {
"items": [ "items": [
+445 -1
View File
@@ -1,7 +1,11 @@
import importlib.util import importlib.util
import pathlib import pathlib
import sys import sys
import threading
import time import time
from concurrent.futures import ThreadPoolExecutor
import pytest
def load_module(): def load_module():
@@ -30,7 +34,14 @@ def test_natural_language_discovery_uses_one_backend_search_request(tmp_path):
def request(method, path, payload=None, **_kwargs): def request(method, path, payload=None, **_kwargs):
calls.append((method, path, payload)) calls.append((method, path, payload))
return {"items": [], "next_cursor": None, "query_analysis": {"mode": "terms"}} return {
"items": [],
"next_cursor": None,
"query_analysis": {"mode": "terms"},
"search_strategy": "identity_fts",
"search_coverage": "identity_only",
"search_projection": "postgres_identity_v1",
}
mod.request_json = request mod.request_json = request
@@ -46,6 +57,9 @@ def test_natural_language_discovery_uses_one_backend_search_request(tmp_path):
assert len(calls) == 1 assert len(calls) == 1
assert calls[0][0] == "GET" assert calls[0][0] == "GET"
assert "/nodes?" in calls[0][1] assert "/nodes?" in calls[0][1]
assert result["search_strategy"] == "identity_fts"
assert result["search_coverage"] == "identity_only"
assert result["search_projection"] == "postgres_identity_v1"
def test_sparse_index_search_latency_does_not_scale_with_query_variants(tmp_path): def test_sparse_index_search_latency_does_not_scale_with_query_variants(tmp_path):
@@ -74,3 +88,433 @@ def test_sparse_index_search_latency_does_not_scale_with_query_variants(tmp_path
elapsed = time.monotonic() - started elapsed = time.monotonic() - started
assert len(calls) == 1 assert len(calls) == 1
assert elapsed < 0.15 assert elapsed < 0.15
def test_complete_local_search_skips_remote_encryption_preflight_and_search(tmp_path):
mod = load_module()
mod.INDEX_PATH = tmp_path / "index.sqlite"
mod.INDEX_PATH.touch()
mod.resolve_project_id = lambda _args: "project-1"
mod.project_encryption_state = lambda _project_id: "unknown"
items = [
{
"id": f"node-{index}",
"project_id": "project-1",
"full_path": f"/notes/result-{index}",
"name": f"result-{index}",
"type": "note",
"tags": [],
"updated_at": "2026-08-22T00:00:00Z",
}
for index in range(5)
]
mod.local_index_diagnostics = lambda _project_id: {
"exists": True,
"graph_complete": True,
}
mod.search_local_index = lambda _args: {
"items": items,
"query": {"terms": ["result"]},
"search_strategies": [],
"index": {"exists": True, "graph_complete": True, "used": True},
}
def unexpected_request(*_args, **_kwargs):
raise AssertionError("complete local search must not make an HTTP request")
mod.request_json = unexpected_request
result = mod.search_memory({"q": "result", "limit": 5, "view": "summary"})
assert result["sources"] == ["local"]
assert result["search_strategy"] == "local_fts"
assert result["search_coverage"] == "identity_and_local_body"
assert result["search_projection"] == "sqlite_fts5_current"
assert len(result["items"]) == 5
assert all(item["node_id"].startswith("node-") for item in result["items"])
def indexed_node(path, *, node_type="note", tags=None, status="active", content=""):
name = path.rsplit("/", 1)[-1]
return {
"id": f"node-{path.strip('/').replace('/', '-')}",
"project_id": "project-1",
"full_path": path,
"folder_path": path.rsplit("/", 1)[0] or None,
"name": name,
"title": name,
"type": node_type,
"tags": tags or [],
"aliases": [],
"links": [],
"metadata": {"status": status},
"content_text": content,
"encryption_state": "legacy_plaintext",
"updated_at": "2026-08-22T00:00:00Z",
}
def configure_local_index(mod, tmp_path):
mod.AUTH_DIR = tmp_path
mod.INDEX_PATH = tmp_path / "index.sqlite"
mod.INDEX_LOCK_PATH = tmp_path / "index.lock"
mod.resolve_project_id = lambda _args: "project-1"
def test_single_pass_ranking_prefers_exact_canonical_identity(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
mod.index_node(
indexed_node(
"/rules/memory-source-policy",
node_type="rule",
tags=["memory", "policy"],
content="Canonical memory source rule.",
)
)
for index in range(12):
mod.index_node(
indexed_node(
f"/work/current/incidental-{index}",
content="memory source policy " * (index + 2),
)
)
result = mod.search_local_index({"q": "memory source policy", "limit": 5})
assert result["items"][0]["full_path"] == "/rules/memory-source-policy"
assert "exact_identity" in result["items"][0]["match_reason"]
assert "full_path" in result["items"][0]["matched_fields"]
assert result["search_strategies"] == [
{
"strategy": "single_pass_weighted_candidates",
"matched": 13,
"returned": 5,
"query_count": 1,
"candidate_limit": 30,
}
]
def test_complete_local_compact_browse_uses_keyset_cursor_without_http(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
for index in range(7):
item = indexed_node(
f"/plans/item-{index}",
node_type="plan",
tags=["performance"],
status="active",
)
item["updated_at"] = f"2026-08-2{index}T00:00:00Z"
mod.index_node(item)
mod.set_local_graph_index_complete("project-1", True)
def unexpected_request(*_args, **_kwargs):
raise AssertionError("complete local compact browse must not make an HTTP request")
mod.request_json = unexpected_request
first = mod.discover_memory(
{
"folder_path": "/plans",
"tag": "performance",
"limit": 3,
"view": "paths",
}
)
second = mod.discover_memory(
{
"folder_path": "/plans",
"tag": "performance",
"limit": 3,
"view": "paths",
"cursor": first["next_cursor"],
}
)
assert first["source"] == "local_index"
assert first["search_strategy"] == "local_snapshot_browse"
assert first["search_projection"] == "sqlite_compact_identity_v1"
assert first["freshness"]["graph_complete"] is True
assert first["next_cursor"].startswith(mod.LOCAL_BROWSE_CURSOR_PREFIX)
assert [item["updated_at"] for item in first["items"]] == sorted(
(item["updated_at"] for item in first["items"]), reverse=True
)
assert not ({item["node_id"] for item in first["items"]} & {item["node_id"] for item in second["items"]})
assert all(item["node_id"] for item in first["items"] + second["items"])
def test_local_compact_browse_cursor_is_bound_to_filters(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
for index in range(3):
mod.index_node(indexed_node(f"/plans/item-{index}", node_type="plan"))
mod.set_local_graph_index_complete("project-1", True)
first = mod.discover_memory({"folder_path": "/plans", "limit": 1, "view": "summary"})
with pytest.raises(RuntimeError, match="cursor filters do not match"):
mod.discover_memory(
{
"folder_path": "/decisions",
"limit": 1,
"view": "summary",
"cursor": first["next_cursor"],
}
)
def test_local_compact_browse_title_order_is_global_across_pages(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
for title in ["Zulu", "alpha", "Mike", "bravo"]:
item = indexed_node(f"/notes/{title}")
item["title"] = title
mod.index_node(item)
mod.set_local_graph_index_complete("project-1", True)
first = mod.discover_memory({"limit": 2, "view": "titles", "sort": "title_asc"})
second = mod.discover_memory(
{"limit": 2, "view": "titles", "sort": "title_asc", "cursor": first["next_cursor"]}
)
assert [item["title"] for item in first["items"] + second["items"]] == [
"alpha",
"bravo",
"Mike",
"Zulu",
]
def test_compact_browse_falls_back_once_when_local_contract_is_incomplete(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
mod.index_node(indexed_node("/plans/local", node_type="plan"))
calls = []
def request(method, path, payload=None, **_kwargs):
calls.append((method, path, payload))
return {"items": [], "next_cursor": None}
mod.request_json = request
result = mod.discover_memory({"folder_path": "/plans", "limit": 5, "view": "paths"})
assert result["ok"] is True
assert len(calls) == 1
assert calls[0][0] == "GET"
assert "/nodes?" in calls[0][1]
with pytest.raises(RuntimeError, match="complete active-node index"):
mod.discover_memory(
{"folder_path": "/plans", "limit": 5, "view": "paths", "mode": "local"}
)
def test_local_search_reads_overlap_without_writer_file_lock(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
for index in range(4):
mod.index_node(indexed_node(f"/notes/result-{index}", content="shared needle"))
mod.set_local_graph_index_complete("project-1", True)
def unexpected_writer_lock(*_args, **_kwargs):
raise AssertionError("read-only local search must not acquire the writer file lock")
mod.index_file_lock = unexpected_writer_lock
original_rank = mod.rank_search_items
rendezvous = threading.Barrier(2)
def overlapping_rank(items, analysis, limit=None):
rendezvous.wait(timeout=1)
return original_rank(items, analysis, limit=limit)
mod.rank_search_items = overlapping_rank
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(mod.search_local_index, {"q": "shared needle", "limit": 2})
for _ in range(2)
]
results = [future.result(timeout=2) for future in futures]
assert all(len(result["items"]) == 2 for result in results)
def test_index_schema_initializes_once_and_revalidates_replacement(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
original_initialize = mod.initialize_index_schema
calls = []
def counted_initialize(conn):
calls.append(str(mod.INDEX_PATH))
return original_initialize(conn)
mod.initialize_index_schema = counted_initialize
with mod.index_connect():
pass
with mod.index_connect():
pass
assert len(calls) == 1
replacement = tmp_path / "replacement.sqlite"
replacement.touch()
replacement.replace(mod.INDEX_PATH)
with mod.index_connect():
pass
assert len(calls) == 2
def test_discover_contract_advertises_local_routing_and_pagination():
mod = load_module()
tool = next(item for item in mod.tools() if item["name"] == "nap_discover")
properties = tool["inputSchema"]["properties"]
assert "cursor" in properties
assert properties["mode"]["enum"] == ["hybrid", "server", "local"]
def test_query_planner_normalizes_plurals_and_reports_intent():
mod = load_module()
analysis = mod.analyze_search_query(
"where did we decide how search queries and policies should work"
)
assert "query" in analysis["terms"]
assert "querie" not in analysis["terms"]
assert analysis["inferred_intent"] == "decision"
assert len(mod.fts_query_variants(analysis["raw"])[1]) == 1
def test_local_search_pushes_structured_filters_into_candidate_query(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
nodes = [
indexed_node(
"/decisions/search-governance",
node_type="decision",
tags=["search"],
content="search governance",
),
indexed_node(
"/decisions/old-search-governance",
node_type="decision",
tags=["search"],
status="superseded",
content="search governance",
),
indexed_node(
"/plans/search-governance",
node_type="plan",
tags=["search"],
content="search governance",
),
indexed_node(
"/decisions/other-search",
node_type="decision",
tags=["other"],
content="search governance",
),
]
for node in nodes:
mod.index_node(node)
result = mod.search_local_index(
{
"q": "search governance",
"folder_path": "/decisions",
"tag": "search",
"type": "decision",
"status": "active",
"limit": 10,
}
)
assert [item["full_path"] for item in result["items"]] == [
"/decisions/search-governance"
]
assert result["index"]["query_count"] == 1
assert set(result["index"]["filters_applied"]) >= {
"folder_path",
"tag",
"type",
"status",
}
def test_explicit_intent_boosts_without_filtering(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
mod.index_node(
indexed_node(
"/decisions/gateway-relay-authentication",
node_type="decision",
content="gateway relay authentication",
)
)
mod.index_node(
indexed_node(
"/work/current/gateway-relay-authentication",
content="gateway relay authentication",
)
)
result = mod.search_local_index(
{"q": "gateway relay authentication", "intent": "decision", "limit": 10}
)
assert result["items"][0]["full_path"].startswith("/decisions/")
assert len(result["items"]) == 2
def test_complete_filtered_local_search_stays_off_network(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
mod.index_node(
indexed_node(
"/documentation/facturacion-electronica",
tags=["billing"],
content="facturación electrónica",
)
)
mod.index_node(
indexed_node(
"/work/current/facturacion-migration",
tags=["migration"],
content="facturación electrónica",
)
)
mod.set_local_graph_index_complete("project-1", True)
def unexpected_request(*_args, **_kwargs):
raise AssertionError("a complete filtered local result must not use the network")
mod.request_json = unexpected_request
result = mod.search_memory(
{
"q": "facturación",
"tag": "billing",
"limit": 1,
"view": "summary",
}
)
assert result["sources"] == ["local"]
assert [item["full_path"] for item in result["items"]] == [
"/documentation/facturacion-electronica"
]
def test_discovery_schema_documents_dynamic_sort_and_intent():
mod = load_module()
tool = next(item for item in mod.tools() if item["name"] == "nap_discover")
properties = tool["inputSchema"]["properties"]
assert properties["intent"]["enum"] == [
"any",
"decision",
"rule",
"plan",
"task",
"implementation",
"documentation",
]
assert "default" not in properties["sort"]
assert "relevance" in properties["sort"]["description"]
+244 -2
View File
@@ -70,10 +70,26 @@ for line in sys.stdin:
class SupervisorClient: class SupervisorClient:
def __init__(self, worker_path: pathlib.Path, project_root: pathlib.Path | None = None): def __init__(
self,
worker_path: pathlib.Path,
project_root: pathlib.Path | None = None,
watch_paths: tuple[pathlib.Path, ...] = (),
response_timeout: int = 5,
cancel_grace: int = 5,
max_in_flight: int = 32,
write_timeout: int = 5,
):
environment = os.environ.copy() environment = os.environ.copy()
environment["NAPSEER_MCP_WORKER_PATH"] = str(worker_path) environment["NAPSEER_MCP_WORKER_PATH"] = str(worker_path)
environment["NAPSEER_MCP_RESPONSE_TIMEOUT_SECONDS"] = "5" if watch_paths:
environment["NAPSEER_MCP_WORKER_WATCH_PATHS"] = os.pathsep.join(
str(path) for path in watch_paths
)
environment["NAPSEER_MCP_RESPONSE_TIMEOUT_SECONDS"] = str(response_timeout)
environment["NAPSEER_MCP_CANCEL_GRACE_SECONDS"] = str(cancel_grace)
environment["NAPSEER_MCP_MAX_IN_FLIGHT_REQUESTS"] = str(max_in_flight)
environment["NAPSEER_MCP_WRITE_TIMEOUT_SECONDS"] = str(write_timeout)
environment["NAPSEER_TELEMETRY"] = "0" environment["NAPSEER_TELEMETRY"] = "0"
if project_root is not None: if project_root is not None:
environment["NAPSEER_PROJECT_ROOT"] = str(project_root) environment["NAPSEER_PROJECT_ROOT"] = str(project_root)
@@ -83,6 +99,7 @@ class SupervisorClient:
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
env=environment, env=environment,
bufsize=0,
) )
def request(self, request_id: int, method: str, params=None): def request(self, request_id: int, method: str, params=None):
@@ -272,6 +289,33 @@ def test_reloads_replaced_worker_between_requests(tmp_path):
client.close() client.close()
def test_reloads_stable_launcher_when_watched_runtime_is_replaced(tmp_path):
runtime_path = tmp_path / "runtime.py"
launcher_path = tmp_path / "launcher.py"
write_worker(runtime_path, 1)
launcher_path.write_text(
"import runpy\n"
f"runpy.run_path({str(runtime_path)!r}, run_name='__main__')\n",
encoding="utf-8",
)
client = SupervisorClient(launcher_path, watch_paths=(runtime_path,))
try:
first = client.request(1, "initialize")
launcher_identity = launcher_path.stat().st_mtime_ns
time.sleep(0.01)
write_worker(runtime_path, 2)
os.utime(runtime_path, None)
second = client.request(2, "tools/list")
assert first["result"]["version"] == 1
assert second["result"]["version"] == 2
assert launcher_path.stat().st_mtime_ns == launcher_identity
finally:
client.close()
def test_worker_crash_returns_error_without_closing_transport(tmp_path): def test_worker_crash_returns_error_without_closing_transport(tmp_path):
worker_path = tmp_path / "worker.py" worker_path = tmp_path / "worker.py"
write_worker(worker_path, 1) write_worker(worker_path, 1)
@@ -285,3 +329,201 @@ def test_worker_crash_returns_error_without_closing_transport(tmp_path):
assert client.process.poll() is None assert client.process.poll() is None
finally: finally:
client.close() client.close()
def write_lifecycle_worker(path, version=1):
# The actual worker dispatcher owns cancellation/completion; the handler
# supplies deterministic slow and uncooperative work without credentials.
path.write_text(f'''import json, os, pathlib, runpy, sys, time
sys.path.insert(0, {str(REAL_WORKER.parent)!r})
mod = runpy.run_path({str(REAL_WORKER)!r})
root = pathlib.Path({str(path.parent)!r})
def handle(message):
name = (message.get("params") or {{}}).get("name")
if name in ("slow", "stuck"):
(root / (str(message["id"]) + ".started")).write_text("started")
try:
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if name == "slow":
mod["raise_if_mcp_request_cancelled"]()
time.sleep(0.01)
finally:
time.sleep(0.3)
(root / (str(message["id"]) + ".cleaned")).write_text("cleaned")
return mod["rpc_result"](message.get("id"), {{"pid": os.getpid(), "version": {version}}})
runtime = mod["McpRequestRuntime"](handler=handle)
try:
for line in sys.stdin:
runtime.dispatch(json.loads(line))
finally:
runtime.close()
''')
def wait_for_path(path, timeout=3):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if path.exists():
return
time.sleep(0.01)
raise AssertionError("worker did not reach the expected lifecycle boundary")
def test_cancelled_work_counts_until_cleanup_before_source_replacement(tmp_path):
worker_path = tmp_path / "worker.py"
write_lifecycle_worker(worker_path)
client = SupervisorClient(worker_path, project_root=tmp_path)
try:
client.send(1, "tools/call", {"name": "slow"})
wait_for_path(tmp_path / "1.started")
client.notify("notifications/cancelled", {"requestId": 1})
write_lifecycle_worker(worker_path, version=2)
during_cleanup = client.request(2, "tools/list")
assert during_cleanup["result"]["version"] == 1
wait_for_path(tmp_path / "1.cleaned")
time.sleep(0.05)
after_cleanup = client.request(3, "tools/list")
assert after_cleanup["result"]["version"] == 2
assert after_cleanup["result"]["pid"] != during_cleanup["result"]["pid"]
finally:
client.close()
def test_timeout_cancels_work_and_keeps_transport_usable(tmp_path):
worker_path = tmp_path / "worker.py"
write_lifecycle_worker(worker_path)
client = SupervisorClient(worker_path, project_root=tmp_path, response_timeout=1)
try:
before = client.request(1, "tools/list")
failed = client.request(2, "tools/call", {"name": "slow"})
assert failed["error"]["code"] == -32098
wait_for_path(tmp_path / "2.cleaned")
recovered = client.request(3, "tools/list")
assert recovered["result"]["pid"] == before["result"]["pid"]
finally:
client.close()
def test_cancelled_work_cannot_bypass_admission_limit(tmp_path):
worker_path = tmp_path / "worker.py"
write_lifecycle_worker(worker_path)
client = SupervisorClient(worker_path, project_root=tmp_path, max_in_flight=2)
try:
client.send(1, "tools/call", {"name": "stuck"})
wait_for_path(tmp_path / "1.started")
client.notify("notifications/cancelled", {"requestId": 1})
client.send(2, "tools/call", {"name": "stuck"})
wait_for_path(tmp_path / "2.started")
client.send(3, "tools/call", {"name": "stuck"})
rejected = client.receive(timeout=1)
assert rejected["id"] == 3
assert rejected["error"]["code"] == -32097
assert not (tmp_path / "3.started").exists()
finally:
client.close()
def test_uncooperative_timeout_restarts_worker_with_no_replay(tmp_path):
worker_path = tmp_path / "worker.py"
write_lifecycle_worker(worker_path)
client = SupervisorClient(worker_path, project_root=tmp_path,
response_timeout=1, cancel_grace=1)
try:
before = client.request(1, "tools/list")
failed = client.request(2, "tools/call", {"name": "stuck"})
assert failed["error"]["code"] == -32098
time.sleep(1.3)
recovered = client.request(3, "tools/list")
assert recovered["result"]["pid"] != before["result"]["pid"]
assert not (tmp_path / "2.cleaned").exists()
assert client.process.poll() is None
finally:
client.close()
def test_reused_id_is_rejected_until_cancelled_request_finishes(tmp_path):
worker_path = tmp_path / "worker.py"
write_lifecycle_worker(worker_path)
client = SupervisorClient(worker_path, project_root=tmp_path)
try:
client.send(1, "tools/call", {"name": "slow"})
wait_for_path(tmp_path / "1.started")
client.notify("notifications/cancelled", {"requestId": 1})
rejected = client.request(1, "tools/list")
assert rejected["error"]["code"] == -32097
wait_for_path(tmp_path / "1.cleaned")
time.sleep(0.05)
assert client.request(1, "tools/list")["result"]["version"] == 1
finally:
client.close()
def test_blocked_worker_stdin_has_a_deadline_and_recovers(tmp_path):
worker_path = tmp_path / "worker.py"
worker_path.write_text("import time\ntime.sleep(60)\n")
client = SupervisorClient(worker_path, project_root=tmp_path, write_timeout=1)
try:
client.send(1, "tools/call", {"name": "large", "arguments": {"data": "x" * 262144}})
failed = client.receive(timeout=3)
assert failed["error"]["code"] == -32098
write_worker(worker_path, 2)
assert client.request(2, "tools/list")["result"]["version"] == 2
finally:
client.close()
def test_eof_stops_worker_with_unfinished_requests(tmp_path):
worker_path = tmp_path / "worker.py"
write_lifecycle_worker(worker_path)
client = SupervisorClient(worker_path, project_root=tmp_path)
try:
worker_pid = client.request(1, "tools/list")["result"]["pid"]
client.send(2, "tools/call", {"name": "stuck"})
wait_for_path(tmp_path / "2.started")
client.close()
assert client.process.returncode == 0
try:
os.kill(worker_pid, 0)
except ProcessLookupError:
pass
else:
raise AssertionError("worker survived supervisor EOF")
finally:
client.close()
def test_initialize_timeout_retires_uncancellable_worker(tmp_path):
worker_path = tmp_path / "worker.py"
worker_path.write_text('''import json, os, sys, time
for line in sys.stdin:
msg = json.loads(line)
if msg.get("method") == "initialize":
time.sleep(10)
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"pid": os.getpid()}}), flush=True)
''')
client = SupervisorClient(worker_path, project_root=tmp_path, response_timeout=1)
try:
before = client.request(1, "tools/list")
failed = client.request(2, "initialize")
assert failed["error"]["code"] == -32098
recovered = client.request(3, "tools/list")
assert recovered["result"]["pid"] != before["result"]["pid"]
finally:
client.close()
def test_real_worker_contract_exposes_recovery_without_private_notifications(tmp_path, monkeypatch):
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "maintenance")
client = SupervisorClient(REAL_WORKER, project_root=tmp_path)
try:
assert "result" in client.request(1, "initialize")
response = client.request(2, "tools/call", {"name": "nap_contract", "arguments": {}})
contract = response["result"]["structuredContent"]["request_lifecycle"]
assert contract["max_unfinished_requests"] == 32
assert contract["supervised"] is True
assert contract["rejected_error_code"] == -32097
assert contract["uncertain_error_code"] == -32098
assert not (tmp_path / ".napseer").exists()
finally:
client.close()
+160
View File
@@ -4,6 +4,8 @@ import sys
import threading import threading
import time import time
import pytest
def load_module(): def load_module():
script_path = ( script_path = (
@@ -164,6 +166,7 @@ def test_mutating_tools_are_serialized_while_reads_can_overlap(monkeypatch):
def test_concurrent_first_reads_share_one_project_bootstrap(tmp_path, monkeypatch): def test_concurrent_first_reads_share_one_project_bootstrap(tmp_path, monkeypatch):
mod = load_module() mod = load_module()
mod.TOKEN = "synthetic-configured-access"
mod.AUTH_PATH = tmp_path / "missing-auth.json" mod.AUTH_PATH = tmp_path / "missing-auth.json"
mod.DEFAULT_PROJECT_ID = None mod.DEFAULT_PROJECT_ID = None
monkeypatch.setattr(mod, "refresh_public_auth_state", lambda: None) monkeypatch.setattr(mod, "refresh_public_auth_state", lambda: None)
@@ -242,3 +245,160 @@ def test_auth_renewal_reuses_token_refreshed_by_another_request(monkeypatch):
"refresh_expires_at": "later", "refresh_expires_at": "later",
"project_id": "project-1", "project_id": "project-1",
} }
def test_tool_call_notification_cannot_execute_a_mutation(monkeypatch):
mod = load_module()
calls = []
monkeypatch.setattr(mod, "call_tool", lambda *args: calls.append(args))
assert mod.handle({"jsonrpc": "2.0", "method": "tools/call", "params": {
"name": "nap_create_node", "arguments": {"name": "unacknowledged"},
}}) is None
assert calls == []
def test_worker_admission_is_bounded_and_recovers():
mod = load_module()
release = threading.Event()
emitted = []
executed = []
def handler(message):
executed.append(message["id"])
release.wait(2)
return mod.rpc_result(message["id"], {"ok": True})
runtime = mod.McpRequestRuntime(handler=handler, sender=emitted.append,
max_workers=2, max_in_flight=2)
try:
for request_id in (1, 2, 3):
runtime.dispatch({"jsonrpc": "2.0", "id": request_id, "method": "tools/call"})
wait_until(lambda: any(item["id"] == 3 for item in emitted))
rejection = next(item for item in emitted if item["id"] == 3)
assert rejection["error"]["code"] == -32097
assert 3 not in executed
release.set()
wait_until(lambda: len(emitted) == 3)
runtime.dispatch({"jsonrpc": "2.0", "id": 4, "method": "tools/call"})
wait_until(lambda: any(item["id"] == 4 for item in emitted))
finally:
release.set()
runtime.close()
def test_cancelled_mutation_waiting_for_lock_never_starts(monkeypatch):
mod = load_module()
cancelled = threading.Event()
waiting = threading.Event()
calls = []
errors = []
monkeypatch.setattr(mod, "call_tool_impl", lambda *args: calls.append(args))
monkeypatch.setattr(mod, "send_telemetry_event_async", lambda *a, **kw: None)
def run():
mod.MCP_REQUEST_LOCAL.cancelled = cancelled
waiting.set()
try:
mod.call_tool("nap_create_node", {})
except mod.SafeToolError as exc:
errors.append(exc)
with mod.MCP_MUTATION_LOCK:
thread = threading.Thread(target=run)
thread.start()
assert waiting.wait(1)
cancelled.set()
thread.join(2)
assert not thread.is_alive()
assert calls == []
assert len(errors) == 1
def test_cancelled_queue_entries_remain_bounded_until_consumed():
mod = load_module()
release = threading.Event()
executed = []
emitted = []
def handler(message):
executed.append(message["id"])
release.wait(2)
return mod.rpc_result(message["id"], {"ok": True})
runtime = mod.McpRequestRuntime(handler=handler, sender=emitted.append,
max_workers=2, max_in_flight=3)
try:
for request_id in (1, 2, 3):
runtime.dispatch({"id": request_id, "method": "tools/call"})
wait_until(lambda: len(executed) == 2)
runtime.cancel(3)
runtime.dispatch({"id": 4, "method": "tools/call"})
wait_until(lambda: any(item["id"] == 4 for item in emitted))
assert emitted[0]["error"]["code"] == -32097
release.set()
wait_until(lambda: len(emitted) == 3)
assert executed == [1, 2]
finally:
release.set()
runtime.close()
@pytest.mark.parametrize("cancel_at", ["acquire", "operation"])
def test_cancelled_write_releases_its_original_project_lock(tmp_path, monkeypatch, cancel_at):
import http.server
import json
monkeypatch.setenv("NAPSEER_PROJECT_ROOT", str(tmp_path))
mod = load_module()
cancelled = threading.Event()
requests = []
mutation_ran = []
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length", 0)))
requests.append(("POST", self.path))
if cancel_at == "acquire":
cancelled.set()
body = json.dumps({"id": "lock-test", "lease_token": "synthetic-lease"}).encode()
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_DELETE(self):
requests.append(("DELETE", self.path))
self.send_response(204)
self.end_headers()
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
mod.BASE_URL = f"http://127.0.0.1:{server.server_port}"
mod.TOKEN = "synthetic-access"
mod.DEFAULT_PROJECT_ID = "original-project"
monkeypatch.setattr(mod, "refresh_public_auth_state", lambda: None)
mod.MCP_REQUEST_LOCAL.cancelled = cancelled
def operation(_headers):
mutation_ran.append(True)
mod.DEFAULT_PROJECT_ID = "different-project"
cancelled.set()
mod.raise_if_mcp_request_cancelled()
try:
with pytest.raises(mod.SafeToolError):
mod.with_project_lock({}, operation)
assert requests == [
("POST", "/v1/projects/original-project/locks"),
("DELETE", "/v1/projects/original-project/locks/lock-test"),
]
assert mutation_ran == ([] if cancel_at == "acquire" else [True])
finally:
del mod.MCP_REQUEST_LOCAL.cancelled
server.shutdown()
server.server_close()
thread.join(2)
+8 -2
View File
@@ -2,6 +2,7 @@
"""Smoke-test existing-project OAuth attachment for folder bootstrap.""" """Smoke-test existing-project OAuth attachment for folder bootstrap."""
import importlib.util import importlib.util
import json
import pathlib import pathlib
import sys import sys
import tempfile import tempfile
@@ -17,6 +18,10 @@ def load_module():
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
assert spec and spec.loader assert spec and spec.loader
spec.loader.exec_module(module) spec.loader.exec_module(module)
module.load_project_locator = lambda: None
module.require_project_locator_service = lambda _locator: None
module.write_project_locator = lambda *_args, **_kwargs: {"declared": False}
module.project_locator_status = lambda _locator=None: {"declared": False}
return module return module
@@ -55,12 +60,12 @@ def run():
result = mod.operator_project_attach({"open_browser": False}) result = mod.operator_project_attach({"open_browser": False})
saves.insert(0, json.loads(pathlib.Path(result["auth_path"]).read_text()))
assert saves[0]["token"] == "oauth-token" assert saves[0]["token"] == "oauth-token"
assert saves[0]["refresh_token"] == "oauth-refresh-token" assert saves[0]["refresh_token"] == "oauth-refresh-token"
assert saves[0]["project_id"] == PROJECT_ID assert saves[0]["project_id"] == PROJECT_ID
assert saves[0]["account_mode"] == "operator_project" assert saves[0]["account_mode"] == "operator_project"
assert replacements[0] == mod.WORKER_BINDING_AUTH_KEYS assert mod.WORKER_BINDING_AUTH_KEYS.isdisjoint(saves[0])
assert "project_id" not in replacements[0]
assert "napseer.projects.read" in saves[0]["oauth_scope"] assert "napseer.projects.read" in saves[0]["oauth_scope"]
assert saves[1]["project_slug"] == "existing-project" assert saves[1]["project_slug"] == "existing-project"
assert result["project_id"] == PROJECT_ID assert result["project_id"] == PROJECT_ID
@@ -77,6 +82,7 @@ def run():
"scope": args["scope"], "scope": args["scope"],
} }
result = mod.operator_account_login({"open_browser": False}) result = mod.operator_account_login({"open_browser": False})
saves.insert(0, json.loads(pathlib.Path(result["auth_path"]).read_text()))
assert saves[0]["refresh_token"] == "account-refresh-token" assert saves[0]["refresh_token"] == "account-refresh-token"
assert saves[0]["account_mode"] == "operator_account" assert saves[0]["account_mode"] == "operator_account"
assert "project_id" not in saves[0] assert "project_id" not in saves[0]
+8 -3
View File
@@ -59,6 +59,7 @@ def test_account_login_requests_create_and_runtime_scopes():
result = mod.operator_account_login({"open_browser": False}) result = mod.operator_account_login({"open_browser": False})
saves.append(json.loads(pathlib.Path(result["auth_path"]).read_text()))
requested = set(captured[0]["scope"].split()) requested = set(captured[0]["scope"].split())
assert "napseer.projects.write" in requested assert "napseer.projects.write" in requested
assert set(mod.LOCAL_PROJECT_OAUTH_SCOPE.split()).issubset(requested) assert set(mod.LOCAL_PROJECT_OAUTH_SCOPE.split()).issubset(requested)
@@ -84,23 +85,27 @@ def test_legacy_account_scope_fails_before_http_with_recovery():
assert not requests assert not requests
def test_successful_create_transitions_local_state_to_project_mode(): def test_successful_create_preserves_general_account_mode():
mod = load_module() mod = load_module()
configure_account_state(mod, mod.OPERATOR_ACCOUNT_OAUTH_SCOPE) configure_account_state(mod, mod.OPERATOR_ACCOUNT_OAUTH_SCOPE)
saves = [] saves = []
locators = []
mod.request_json = lambda method, path, payload=None: { mod.request_json = lambda method, path, payload=None: {
"id": PROJECT_ID, "id": PROJECT_ID,
"slug": payload["slug"], "slug": payload["slug"],
"name": payload["name"], "name": payload["name"],
} }
mod.save_auth = lambda updates: saves.append(dict(updates)) mod.save_auth = lambda updates: saves.append(dict(updates))
mod.write_project_locator = lambda project: locators.append(dict(project))
project, status, _message = mod.create_project_with_state(project_args()) project, status, _message = mod.create_project_with_state(project_args())
assert status == "created" assert status == "created"
assert project["id"] == PROJECT_ID assert project["id"] == PROJECT_ID
assert saves[0]["project_id"] == PROJECT_ID assert saves[0]["project_id"] == PROJECT_ID
assert saves[0]["account_mode"] == "operator_project" assert "account_mode" not in saves[0]
assert mod.AUTH["account_mode"] == "operator_account"
assert locators == [project]
def test_server_scope_denial_is_actionable(): def test_server_scope_denial_is_actionable():
@@ -159,7 +164,7 @@ def test_anonymous_limits_are_actionable_without_exposing_response_details():
if __name__ == "__main__": if __name__ == "__main__":
test_account_login_requests_create_and_runtime_scopes() test_account_login_requests_create_and_runtime_scopes()
test_legacy_account_scope_fails_before_http_with_recovery() test_legacy_account_scope_fails_before_http_with_recovery()
test_successful_create_transitions_local_state_to_project_mode() test_successful_create_preserves_general_account_mode()
test_server_scope_denial_is_actionable() test_server_scope_denial_is_actionable()
test_anonymous_limits_are_actionable_without_exposing_response_details() test_anonymous_limits_are_actionable_without_exposing_response_details()
print("ok: project create OAuth lifecycle passed") print("ok: project create OAuth lifecycle passed")
+4
View File
@@ -16,6 +16,10 @@ def load_module():
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
assert spec and spec.loader assert spec and spec.loader
spec.loader.exec_module(module) spec.loader.exec_module(module)
module.load_project_locator = lambda: None
module.require_project_locator_service = lambda _locator: None
module.write_project_locator = lambda *_args, **_kwargs: {"declared": False}
module.project_locator_status = lambda _locator=None: {"declared": False}
return module return module
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Contract tests for the committed, non-secret Napseer project locator."""
import importlib.util
import json
import pathlib
import subprocess
import sys
import tempfile
PROJECT_ID = "33333333-3333-3333-3333-333333333333"
OTHER_PROJECT_ID = "44444444-4444-4444-4444-444444444444"
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
sys.path.insert(0, str(script_path.parent))
spec = importlib.util.spec_from_file_location("napseer_mcp_server_project_locator_test", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def configure_state(mod, root):
state_dir = pathlib.Path(root) / ".napseer"
mod.AUTH_DIR = state_dir
mod.AUTH_PATH = state_dir / "auth.json"
mod.BASE_URL = "https://api.example.test"
mod.TOKEN = None
mod.DEFAULT_PROJECT_ID = None
mod.AUTH = {}
return state_dir
def test_locator_is_deterministic_committable_and_secret_free():
mod = load_module()
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
subprocess.run(["git", "init", "-q"], cwd=root, check=True)
state_dir = configure_state(mod, root)
mod.DEFAULT_PROJECT_ID = PROJECT_ID
result = mod.write_project_locator({"id": PROJECT_ID, "slug": "portable-project"})
locator = json.loads((state_dir / "project.json").read_text(encoding="utf-8"))
assert locator == {
"schema": "napseer.project.v1",
"base_url": "https://api.example.test",
"project_id": PROJECT_ID,
"project_slug": "portable-project",
}
assert result["declared"] is True
assert result["matches_local_auth"] is True
forbidden = {
"token", "access_token", "refresh_token", "account_id", "agent_id",
"worker_id", "claim_token", "claim_url", "passphrase", "encryption_state",
}
assert forbidden.isdisjoint(locator)
(state_dir / "auth.json").write_text('{"token":"private"}\n', encoding="utf-8")
(state_dir / "gateway-auth.json").write_text('{"token":"private"}\n', encoding="utf-8")
assert subprocess.run(
["git", "check-ignore", "--quiet", ".napseer/auth.json"], cwd=root
).returncode == 0
assert subprocess.run(
["git", "check-ignore", "--quiet", ".napseer/gateway-auth.json"], cwd=root
).returncode == 0
assert subprocess.run(
["git", "check-ignore", "--quiet", ".napseer/project.json"], cwd=root
).returncode == 1
assert subprocess.run(
["git", "check-ignore", "--quiet", ".napseer/.gitignore"], cwd=root
).returncode == 1
def test_locator_rejects_extra_secret_fields_and_mismatched_auth():
mod = load_module()
with tempfile.TemporaryDirectory() as directory:
state_dir = configure_state(mod, directory)
state_dir.mkdir(parents=True)
(state_dir / "project.json").write_text(
json.dumps({
"schema": "napseer.project.v1",
"base_url": "https://api.example.test",
"project_id": PROJECT_ID,
"project_slug": "portable-project",
"token": "must-not-be-accepted",
}),
encoding="utf-8",
)
try:
mod.load_project_locator()
except mod.SafeToolError as exc:
assert exc.code == "project_locator_invalid"
else:
raise AssertionError("locator with an extra secret field must be rejected")
(state_dir / "project.json").unlink()
mod.write_project_locator({"id": PROJECT_ID, "slug": "portable-project"})
mod.DEFAULT_PROJECT_ID = OTHER_PROJECT_ID
mod.refresh_public_auth_state = lambda: None
try:
mod.resolve_project_id({})
except mod.SafeToolError as exc:
assert exc.code == "project_locator_mismatch"
else:
raise AssertionError("local auth/locator mismatch must fail closed")
def test_fresh_clone_requires_attach_and_never_bootstraps_anonymous_project():
mod = load_module()
with tempfile.TemporaryDirectory() as directory:
configure_state(mod, directory)
mod.write_project_locator({"id": PROJECT_ID, "slug": "portable-project"})
enrollment_calls = []
bootstrap_calls = []
mod.ensure_enrolled = lambda args: enrollment_calls.append(dict(args))
mod.bootstrap_project = lambda args: bootstrap_calls.append(dict(args))
result = mod.cli_project_init([])
assert result["status"] == "project_access_required"
assert result["project_id"] == PROJECT_ID
assert result["next"]["attach_project"] == "nap project attach"
assert enrollment_calls == []
assert bootstrap_calls == []
mod.refresh_public_auth_state = lambda: None
try:
mod.resolve_project_id({})
except mod.SafeToolError as exc:
assert exc.code == "project_access_required"
assert "will not create a duplicate anonymous project" in exc.safe_message
else:
raise AssertionError("first MCP project resolution must require attach")
def test_attach_forwards_and_verifies_repository_project_hint():
mod = load_module()
with tempfile.TemporaryDirectory() as directory:
configure_state(mod, directory)
mod.write_project_locator({"id": PROJECT_ID, "slug": "portable-project"})
oauth_calls = []
saves = []
def oauth(args):
oauth_calls.append(dict(args))
return {
"api_base_url": "https://api.example.test",
"access_token": "local-test-token",
"refresh_token": "local-test-refresh",
"expires_in": 3600,
"scope": args["scope"],
"project_id": PROJECT_ID,
}
mod.oauth_loopback_authorize = oauth
mod.replace_public_auth_state = lambda updates, clear_keys=(): saves.append(dict(updates))
mod.save_auth_file_credentials = lambda updates: saves.append(dict(updates))
mod.request_json = lambda method, path: {
"id": PROJECT_ID,
"slug": "portable-project",
"name": "Portable Project",
"encryption_state": "standard",
}
result = mod.operator_project_attach({"open_browser": False})
assert oauth_calls[0]["project_id_hint"] == PROJECT_ID
assert result["project_id"] == PROJECT_ID
assert result["project_locator"]["project_id"] == PROJECT_ID
credentials = json.loads(pathlib.Path(result["auth_path"]).read_text())
assert credentials["project_id"] == PROJECT_ID
if __name__ == "__main__":
test_locator_is_deterministic_committable_and_secret_free()
test_locator_rejects_extra_secret_fields_and_mismatched_auth()
test_fresh_clone_requires_attach_and_never_bootstraps_anonymous_project()
test_attach_forwards_and_verifies_repository_project_hint()
print("ok: committed project locator contract passed")
+1
View File
@@ -23,6 +23,7 @@ SMOKES = [
"test_project_attach_oauth.py", "test_project_attach_oauth.py",
"test_project_encryption_enable_payload.py", "test_project_encryption_enable_payload.py",
"test_project_init_wizard.py", "test_project_init_wizard.py",
"test_project_locator.py",
"test_two_passphrase_boundary.py", "test_two_passphrase_boundary.py",
] ]
+284
View File
@@ -0,0 +1,284 @@
"""Real workers and synthetic HTTP provider: user sessions across repositories."""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs
import pytest
SCRIPT = Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py"
sys.path.insert(0, str(SCRIPT.parent))
from napseer_credentials import CredentialStore, atomic_json, read_json
@pytest.fixture
def provider():
state = {"refreshes": 0, "requests": 0, "failure": None, "revoked": False}
mutex = threading.Lock()
state["refresh_started"] = threading.Event()
state["refresh_release"] = threading.Event()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def reply(self, status, value):
data = json.dumps(value).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
with mutex:
state["requests"] += 1
if self.headers.get("Authorization") == "Bearer fresh-access" and not state["revoked"]:
self.reply(200, {"ok": True})
else:
self.reply(401, {"error": {"code": "unauthorized"}})
def do_POST(self):
form = parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode())
if self.path == "/v1/oauth/revoke":
state["revoked"] = True
return self.reply(200, {})
assert self.path == "/v1/oauth/token", "no enrollment fallback is allowed"
if state["failure"] == "transport":
return self.reply(503, {"error": {"code": "unavailable"}})
if state["failure"] == "malformed":
return self.reply(200, {"access_token": "incomplete"})
if form.get("refresh_token") != ["old-refresh"] or state["refreshes"]:
return self.reply(401, {"error": {"code": "invalid_grant"}})
if state.get("hold_refresh"):
state["refresh_started"].set()
assert state["refresh_release"].wait(5)
with mutex:
state["refreshes"] += 1
self.reply(200, {"access_token": "fresh-access", "refresh_token": "fresh-refresh",
"expires_in": 3600, "refresh_expires_at": "2099-01-01T00:00:00Z"})
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}", state
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
def seed(root, url):
root.mkdir(exist_ok=True)
store = CredentialStore(root / ".napseer")
store.login({"base_url": url, "token": "old-access", "refresh_token": "old-refresh",
"account_mode": "operator_account", "oauth_client_id": "nap-cli"})
return store
def worker(root, expression):
code = "import importlib.util, json, sys\n" + (
"spec = importlib.util.spec_from_file_location('worker', sys.argv[1])\n"
"m = importlib.util.module_from_spec(spec)\nspec.loader.exec_module(m)\n"
"try:\n result = " + expression + "\n print(json.dumps({'ok': True, 'result': result}))\n"
"except Exception as exc:\n print(json.dumps({'ok': False, 'code': getattr(exc, 'code', type(exc).__name__)}))\n"
)
env = dict(os.environ, NAPSEER_PROJECT_ROOT=str(root), NAPSEER_TELEMETRY="0")
for key in ("NAPSEER_AUTH_FILE", "NAPSEER_BASE_URL", "NAPSEER_PROJECT_ID"):
env.pop(key, None)
return subprocess.Popen([sys.executable, "-c", code, str(SCRIPT)], cwd=root, env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
def outcome(process):
out, _err = process.communicate(timeout=15)
assert process.returncode == 0, "worker must exit without exposing credential state"
return json.loads(out)
def test_shared_login_refreshes_once_across_real_processes_and_survives_restart(tmp_path, provider):
url, state = provider
first, second = tmp_path / "first", tmp_path / "second"
store = seed(first, url)
second.mkdir()
processes = [worker(root, "m.request_json('GET', '/v1/projects')") for root in [first, second]]
assert all(outcome(p)["ok"] for p in processes)
assert state["refreshes"] == 1
assert outcome(worker(second, "m.request_json('GET', '/v1/projects')"))["ok"]
assert state["refreshes"] == 1
assert store.user.stat().st_mode & 0o777 == 0o600
assert not (first / ".napseer/auth.json").exists()
assert not (second / ".napseer/auth.json").exists()
@pytest.mark.parametrize("failure", ["transport", "malformed"])
def test_refresh_failure_preserves_credentials_and_recovers(tmp_path, provider, failure):
url, state = provider
store = seed(tmp_path, url)
before = store.user.read_bytes()
state["failure"] = failure
assert not outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"]
assert store.user.read_bytes() == before
state["failure"] = None
assert outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"]
def test_project_override_does_not_fall_back_to_user_authority(tmp_path, provider):
url, state = provider
store = seed(tmp_path, url)
store.login({"base_url": url, "token": "npk_expired", "account_mode": "api_key"}, project=True)
result = outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))
assert not result["ok"]
assert state["refreshes"] == 0
assert read_json(store.user)["token"] == "old-access"
store.logout(project=True)
assert not outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"]
assert state["refreshes"] == 0
def test_unconfigured_agent_never_enrolls_or_creates_project(tmp_path):
result = outcome(worker(tmp_path, "m.resolve_project_id({})"))
assert result == {"ok": False, "code": "auth_required"}
assert not (tmp_path / ".napseer").exists()
def test_logout_revokes_and_next_worker_observes_tombstone(tmp_path, provider):
url, state = provider
store = seed(tmp_path, url)
assert outcome(worker(tmp_path, "m.operator_logout({})"))["ok"]
assert state["revoked"]
assert read_json(store.user)["logged_out"]
assert not outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"]
assert state["requests"] == 0
def test_global_credentials_keep_repository_selections_independent(tmp_path, provider):
url, _ = provider
first = tmp_path / "first"
store = seed(first, url)
other = CredentialStore(tmp_path / "second/.napseer")
store.write({**store.read(), "project_id": "first"})
other.write({**other.read(), "project_id": "second"})
assert "project_id" not in read_json(store.user)
assert store.read()["project_id"] == "first"
assert other.read()["project_id"] == "second"
def test_logout_waits_for_inflight_rotation_and_cannot_be_undone(tmp_path, provider):
url,state=provider
store=seed(tmp_path,url)
state["hold_refresh"]=True
refresh=worker(tmp_path,"m.request_json('GET','/v1/projects')")
assert state["refresh_started"].wait(5)
logout=worker(tmp_path,"m.operator_logout({})")
state["refresh_release"].set()
outcome(refresh)
assert outcome(logout)["ok"]
assert state["revoked"]
assert read_json(store.user).get("logged_out")
assert not outcome(worker(tmp_path,"m.request_json('GET','/v1/projects')"))["ok"]
def test_refresh_pins_credential_source_when_override_appears(tmp_path, provider):
url,state=provider
store=seed(tmp_path,url)
state["hold_refresh"]=True
refresh=worker(tmp_path,"m.request_json('GET','/v1/projects')")
assert state["refresh_started"].wait(5)
override={"base_url":url,"token":"npk_project_test","account_mode":"api_key"}
store.login(override,project=True)
state["refresh_release"].set()
assert not outcome(refresh)["ok"], "retry observes the explicit override"
assert read_json(store.project)==override
assert read_json(store.user)["token"]=="fresh-access"
def test_legacy_migration_preserves_authority_and_general_selection_is_explicit(tmp_path,provider):
url,_=provider
store=seed(tmp_path,url)
legacy={"base_url":url,"token":"legacy-project","account_mode":"operator_project","project_id":"test-project"}
atomic_json(store.legacy,legacy)
assert store.selected()[1]=="legacy_project"
store.migrate()
assert not store.legacy.exists()
assert store.read()["token"]=="legacy-project"
store.use_user()
assert store.read()["token"]=="old-access"
assert read_json(store.project)==legacy
def test_failed_login_initialization_releases_actual_loopback_socket(tmp_path,monkeypatch):
import socket
spec=importlib.util.spec_from_file_location("cleanup_worker",SCRIPT)
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
reserved=socket.socket();reserved.bind(("127.0.0.1",0));port=reserved.getsockname()[1];reserved.close()
monkeypatch.setattr(module,"request_json",lambda *a,**k: (_ for _ in ()).throw(RuntimeError("synthetic unavailable")))
with pytest.raises(RuntimeError):
module.oauth_loopback_authorize({"scope":"test","flow":"operator_login","port":port,"open_browser":False})
probe=socket.socket()
try: probe.bind(("127.0.0.1",port))
finally: probe.close()
def test_native_login_uses_requested_service_pkce_callback_and_shared_store(tmp_path,monkeypatch):
import base64
import hashlib
import urllib.request
from urllib.parse import urlsplit, urlencode
state={}
class Handler(BaseHTTPRequestHandler):
def log_message(self,*_args): pass
def do_POST(self):
raw=self.rfile.read(int(self.headers['Content-Length']))
if self.path.endswith('/authorizations'):
state['authorization']=json.loads(raw)
payload={"authorization_url":"http://synthetic-browser.invalid/consent"}
else:
form=parse_qs(raw.decode());state['exchanged']=True
expected=base64.urlsafe_b64encode(hashlib.sha256(form['code_verifier'][0].encode()).digest()).decode().rstrip('=')
assert expected==state['authorization']['code_challenge']
assert form['code']==['synthetic-one-time-code']
payload={"access_token":"synthetic-native-access","refresh_token":"synthetic-native-refresh","expires_in":3600}
body=json.dumps(payload).encode();self.send_response(200);self.end_headers();self.wfile.write(body)
server=ThreadingHTTPServer(('127.0.0.1',0),Handler)
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
spec=importlib.util.spec_from_file_location('native_login_worker',SCRIPT)
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
monkeypatch.setattr(module,'AUTH_DIR',tmp_path/'.napseer')
monkeypatch.setattr(module,'AUTH_PATH',tmp_path/'.napseer/auth.json')
monkeypatch.setattr(module,'BASE_URL','http://127.0.0.1:1')
store = CredentialStore(tmp_path/'.napseer')
store.user.parent.mkdir(parents=True,exist_ok=True)
store.user.write_text('{broken synthetic state')
def consent(_url):
grant=state['authorization']
callback=grant['redirect_uri']+'?'+urlencode({'code':'synthetic-one-time-code','state':grant['state']})
with urllib.request.urlopen(callback,timeout=3) as response: assert response.status==200
return True
monkeypatch.setattr(module.webbrowser,'open',consent)
try:
result=module.operator_account_login({'base_url':f'http://127.0.0.1:{server.server_port}','timeout_seconds':5})
assert result['status']=='authenticated';assert state['exchanged']
store=CredentialStore(tmp_path/'.napseer')
assert store.user.exists();assert not store.legacy.exists()
assert read_json(store.user)['base_url']==f'http://127.0.0.1:{server.server_port}'
finally:server.shutdown();server.server_close();thread.join(timeout=2)
def test_corrupt_credentials_allow_help_but_never_authenticated_fallback(tmp_path):
store=CredentialStore(tmp_path/'.napseer')
store.user.parent.mkdir(parents=True,exist_ok=True)
store.user.write_text('{broken synthetic state')
result=outcome(worker(tmp_path,"m.request_json('GET','/v1/projects')"))
assert result['code']=='CredentialError'
assert store.user.read_text()=='{broken synthetic state'
env=dict(os.environ,NAPSEER_PROJECT_ROOT=str(tmp_path))
help_result=subprocess.run([sys.executable,str(SCRIPT),'auth','--help'],cwd=tmp_path,env=env,capture_output=True,text=True,timeout=10)
assert help_result.returncode==0
assert 'nap auth login' in help_result.stdout