Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb0fb030c3 | ||
|
|
eed1d1d3f2 | ||
|
|
f201cb358b | ||
|
|
611a4571f3 | ||
|
|
d86610da8e | ||
|
|
bf6acaacc8 | ||
|
|
0938b3026e | ||
|
|
c6bc28442a | ||
|
|
7eaaffb63f | ||
|
|
c427e2432a | ||
|
|
2a78262754 | ||
|
|
2a4a054549 |
@@ -28,9 +28,15 @@ Current state:
|
|||||||
- `nap auth login` stores the shared account session in the user data folder
|
- `nap auth login` stores the shared account session in the user data folder
|
||||||
(`~/.local/share/napseer/credentials/default.json` on Linux; platform user data directories
|
(`~/.local/share/napseer/credentials/default.json` on Linux; platform user data directories
|
||||||
on macOS/Windows). `NAPSEER_USER_DATA_DIR` overrides that location.
|
on macOS/Windows). `NAPSEER_USER_DATA_DIR` overrides that location.
|
||||||
`nap auth login --project` selects a local override. Legacy repository
|
`nap auth login --project` selects a repository override shared by its Git
|
||||||
credentials remain an explicit override until `nap auth migrate` moves them.
|
worktrees. Separate repositories retain separate overrides.
|
||||||
Gateway identities remain separate in `.napseer/gateway-auth.json`.
|
- Private state lives entirely in user data. `credentials/` holds login grants;
|
||||||
|
`repositories/<id>/` holds the shared signing key; `workspaces/<id>/` holds
|
||||||
|
each worktree's selection, gateway identity, vault, index, locks, and logs.
|
||||||
|
Git common-directory identity groups related worktrees; each worktree's Git
|
||||||
|
directory identifies its runtime state, including after `git worktree move`.
|
||||||
|
Nested project locators keep their own namespace. Ordinary directories use
|
||||||
|
their canonical absolute path. Separate clones do not share project overrides.
|
||||||
- `.napseer/project.json` is the commit-safe project locator. It contains only
|
- `.napseer/project.json` is the commit-safe project locator. It contains only
|
||||||
the schema, API origin, project UUID, and slug; it never contains tokens,
|
the schema, API origin, project UUID, and slug; it never contains tokens,
|
||||||
account or worker identity, encryption state, keys, passphrases, or claim
|
account or worker identity, encryption state, keys, passphrases, or claim
|
||||||
@@ -42,6 +48,18 @@ Current state:
|
|||||||
Backend discovery publishes the release manifest and exact public source
|
Backend discovery publishes the release manifest and exact public source
|
||||||
revision used for each bundle.
|
revision used for each bundle.
|
||||||
|
|
||||||
|
For an existing checkout, stop older MCP clients and local gateway/UI processes,
|
||||||
|
update the runtime, and run `nap auth migrate` once from that checkout. This moves
|
||||||
|
private files out of `.napseer`, preserving the public locator and ignore file.
|
||||||
|
Migration checks running gateway PIDs, copies and verifies every file before
|
||||||
|
removing originals, and refuses conflicting destinations. An interrupted copy
|
||||||
|
can be retried; it never selects a different login to hide a conflict. Restart
|
||||||
|
clients afterward. Migrate existing worktrees individually; conflicting older
|
||||||
|
credentials or signing keys must be reconciled explicitly, never overwritten.
|
||||||
|
|
||||||
|
`NAPSEER_USER_DATA_DIR` and explicit credential files must point outside the
|
||||||
|
checkout. There is no credential or private-state symlink inside the repository.
|
||||||
|
|
||||||
Configure Codex and other long-lived stdio clients to launch:
|
Configure Codex and other long-lived stdio clients to launch:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ 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.3.0"
|
CLI_RELEASE_VERSION = "0.5.0"
|
||||||
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-10"
|
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-29"
|
||||||
# Older installers have a fixed asset list and would omit the credentials module.
|
# Older installers have a fixed asset list and would omit the credentials module.
|
||||||
# Reject them before activation; the published bootstrap performs the upgrade.
|
# Reject them before activation; the published bootstrap performs the upgrade.
|
||||||
CLI_MINIMUM_CONTRACT_VERSION = "2026-09-10"
|
CLI_MINIMUM_CONTRACT_VERSION = "2026-09-10"
|
||||||
@@ -82,7 +82,7 @@ INSTALL_PATHS = {
|
|||||||
"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",
|
||||||
}
|
}
|
||||||
CLI_GENERATED_SOURCE_REPO = os.environ.get("NAPSEER_CLI_SOURCE_REPO", "https://github.com/napseer/nap-cli")
|
CLI_GENERATED_SOURCE_REPO = os.environ.get("NAPSEER_CLI_SOURCE_REPO", "https://gitea.powc-r.com/napseer/nap-cli")
|
||||||
CLI_GENERATED_SOURCE_REVISION = os.environ.get("NAPSEER_CLI_SOURCE_REVISION", "unresolved")
|
CLI_GENERATED_SOURCE_REVISION = os.environ.get("NAPSEER_CLI_SOURCE_REVISION", "unresolved")
|
||||||
CLI_GENERATED_SOURCE_REVISION_STATUS = os.environ.get("NAPSEER_CLI_SOURCE_REVISION_STATUS", "unresolved")
|
CLI_GENERATED_SOURCE_REVISION_STATUS = os.environ.get("NAPSEER_CLI_SOURCE_REVISION_STATUS", "unresolved")
|
||||||
HELP_TOKENS = {"help", "-h", "--help"}
|
HELP_TOKENS = {"help", "-h", "--help"}
|
||||||
@@ -630,32 +630,34 @@ def run_wrapper(command_name, args):
|
|||||||
|
|
||||||
|
|
||||||
def preferred_state_dir():
|
def preferred_state_dir():
|
||||||
return pathlib.Path.cwd() / ".napseer"
|
from napseer_credentials import WorkspacePaths
|
||||||
|
return WorkspacePaths().state
|
||||||
|
|
||||||
|
|
||||||
def cwd_state_dir():
|
def cwd_state_dir():
|
||||||
preferred = preferred_state_dir()
|
preferred = preferred_state_dir()
|
||||||
preferred.mkdir(exist_ok=True)
|
preferred.mkdir(parents=True, exist_ok=True)
|
||||||
chmod_best_effort(preferred, 0o700)
|
chmod_best_effort(preferred, 0o700)
|
||||||
return preferred
|
return preferred
|
||||||
|
|
||||||
|
|
||||||
def state_dir_status():
|
def state_dir_status():
|
||||||
preferred = preferred_state_dir()
|
preferred = preferred_state_dir()
|
||||||
from napseer_credentials import CredentialStore
|
from napseer_credentials import CredentialStore, WorkspacePaths
|
||||||
try: auth_path, credential_source = CredentialStore(preferred).selected()
|
try: auth_path, credential_source = CredentialStore(WorkspacePaths().legacy).selected()
|
||||||
except RuntimeError: auth_path, credential_source = CredentialStore(preferred).user, "invalid_selection"
|
except RuntimeError: auth_path, credential_source = CredentialStore(WorkspacePaths().legacy).user, "invalid_selection"
|
||||||
return {
|
return {
|
||||||
"credential_source": credential_source,
|
"credential_source": credential_source,
|
||||||
|
"migration_required": bool(WorkspacePaths().migration_sources()),
|
||||||
"active_state_dir": str(preferred),
|
"active_state_dir": str(preferred),
|
||||||
"active_auth_path": str(auth_path),
|
"active_auth_path": str(auth_path),
|
||||||
"state_dir": str(preferred),
|
"state_dir": str(preferred),
|
||||||
"auth_path": str(auth_path),
|
"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."
|
"Private Napseer state is stored in user data."
|
||||||
if preferred.exists()
|
if preferred.exists()
|
||||||
else ".napseer directory is not initialized."
|
else "Private Napseer state is not initialized."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,6 +864,8 @@ def validate_cli_options(args, *, flags=(), value_options=(), command_label):
|
|||||||
|
|
||||||
|
|
||||||
def start_service(kind, args):
|
def start_service(kind, args):
|
||||||
|
from napseer_credentials import WorkspacePaths
|
||||||
|
WorkspacePaths().require_migrated()
|
||||||
wrapper = runtime_asset_path("napseer_mcp_server.py")
|
wrapper = runtime_asset_path("napseer_mcp_server.py")
|
||||||
require_runtime_assets()
|
require_runtime_assets()
|
||||||
existing = read_service_state(kind)
|
existing = read_service_state(kind)
|
||||||
@@ -1038,8 +1042,12 @@ def print_gateway_help(topic=None):
|
|||||||
nap gateway schedule delete [ID | --schedule ID]""")
|
nap gateway schedule delete [ID | --schedule ID]""")
|
||||||
return 0
|
return 0
|
||||||
if topic == "vault":
|
if topic == "vault":
|
||||||
print("Usage: nap gateway vault [status|process|rotate-secret] [--kind memory] [--all] [--project-id ID] [--vault-passphrase TEXT]")
|
print("Usage: nap gateway vault [status|setup|forget|process|rotate-secret] [--kind memory] [--all] [--project-id ID] [--vault-passphrase TEXT]")
|
||||||
print(" status Show local gateway state and pending setup requests.")
|
print(" status Show local gateway state, memory secret provisioning, and pending setup requests.")
|
||||||
|
print(" setup Store the project memory secret in local encrypted storage for this worktree.")
|
||||||
|
print(" Prompts once for the project vault passphrase; the passphrase is never stored.")
|
||||||
|
print(" The CLI and MCP workers then load the secret automatically.")
|
||||||
|
print(" forget Remove the locally stored memory secret for this worktree.")
|
||||||
print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.")
|
print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.")
|
||||||
print(" rotate-secret Rotate the memory project secret from the gateway worker path.")
|
print(" rotate-secret Rotate the memory project secret from the gateway worker path.")
|
||||||
return 0
|
return 0
|
||||||
@@ -1050,6 +1058,7 @@ def print_gateway_help(topic=None):
|
|||||||
print(" nap gateway repair Create dedicated gateway worker credentials.")
|
print(" nap gateway repair Create dedicated gateway worker credentials.")
|
||||||
print(" nap gateway terminal help Show terminal lifecycle commands.")
|
print(" nap gateway terminal help Show terminal lifecycle commands.")
|
||||||
print(" nap gateway schedule help Show schedule lifecycle commands.")
|
print(" nap gateway schedule help Show schedule lifecycle commands.")
|
||||||
|
print(" nap gateway vault setup Store the E2E memory secret locally (prompts once).")
|
||||||
print(" nap gateway vault help Show vault commands.")
|
print(" nap gateway vault help Show vault commands.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -1089,12 +1098,12 @@ def print_command_help(command):
|
|||||||
),
|
),
|
||||||
"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 and attach the project named by .napseer/project.json.\n"
|
||||||
" status Show credential state without credential values.\n"
|
" status Show credential state without credential values.\n"
|
||||||
" repair Recover failed automatic credential renewal.\n"
|
" repair Recover failed automatic credential renewal.\n"
|
||||||
" logout [--project] Revoke the selected login.\n"
|
" logout [--project] Revoke the selected login.\n"
|
||||||
" login --project Store a separate project login in user data.\n"
|
" login --project Store a separate project login in user data.\n"
|
||||||
" migrate Move legacy project credentials into user data.\n"
|
" migrate Move legacy private state into user data after stopping older clients/gateways.\n"
|
||||||
" use-user Explicitly select the general login for this folder.\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"
|
" api-key --env NAME Configure project access from a named environment variable.\n"
|
||||||
" keys list|create|revoke Manage named, expiring delegated access."
|
" keys list|create|revoke Manage named, expiring delegated access."
|
||||||
@@ -1304,6 +1313,10 @@ def mcp_runtime_probe(timeout_seconds=6):
|
|||||||
"verified": False,
|
"verified": False,
|
||||||
},
|
},
|
||||||
"remote_project_access": "not_tested",
|
"remote_project_access": "not_tested",
|
||||||
|
"memory_secret": (local_payload or {}).get("memory_secret"),
|
||||||
|
"deprecations": [
|
||||||
|
item.get("code") for item in (local_payload or {}).get("deprecations") or [] if isinstance(item, dict)
|
||||||
|
],
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {
|
return {
|
||||||
@@ -1366,17 +1379,19 @@ def doctor_status():
|
|||||||
auth = {}
|
auth = {}
|
||||||
auth_error = None
|
auth_error = None
|
||||||
try:
|
try:
|
||||||
from napseer_credentials import CredentialStore, read_json
|
from napseer_credentials import CredentialStore, WorkspacePaths, read_json
|
||||||
store=CredentialStore(preferred_state_dir())
|
store=CredentialStore(WorkspacePaths().legacy)
|
||||||
auth=store.read()
|
auth=store.read()
|
||||||
locator=read_json(preferred_state_dir()/"project.json")
|
locator=read_json(WorkspacePaths().locator)
|
||||||
if not auth.get("project_id"): auth["project_id"]=locator.get("project_id")
|
if not auth.get("project_id"): auth["project_id"]=locator.get("project_id")
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
auth_error="credential state is unreadable; run nap auth login or inspect the selected override"
|
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 = []
|
||||||
if auth_error:
|
if state.get("migration_required"):
|
||||||
|
issues.append({"code": "state_migration_required", "message": "Stop older MCP clients and local gateways, then migrate private state into user data.", "next": "nap auth migrate"})
|
||||||
|
elif auth_error:
|
||||||
issues.append({"code": "auth_file_invalid", "message": auth_error, "next": "nap auth repair"})
|
issues.append({"code": "auth_file_invalid", "message": auth_error, "next": "nap auth repair"})
|
||||||
elif not auth_path.exists():
|
elif not auth_path.exists():
|
||||||
setup.append({
|
setup.append({
|
||||||
@@ -1411,6 +1426,18 @@ def doctor_status():
|
|||||||
else "inspect the failed_stage field and local project binding"
|
else "inspect the failed_stage field and local project binding"
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
if probe.get("memory_secret") == "rotated":
|
||||||
|
setup.append({
|
||||||
|
"code": "memory_secret_rotated",
|
||||||
|
"message": "the project memory secret was rotated after it was stored locally",
|
||||||
|
"next": "nap gateway vault setup",
|
||||||
|
})
|
||||||
|
if "vault_passphrase_env_deprecated" in (probe.get("deprecations") or []):
|
||||||
|
setup.append({
|
||||||
|
"code": "vault_passphrase_env_deprecated",
|
||||||
|
"message": "NAPSEER_VAULT_PASSPHRASE / NAPSEER_MASTER_PASSPHRASE are deprecated for agents; store the memory secret locally instead",
|
||||||
|
"next": "nap gateway vault setup",
|
||||||
|
})
|
||||||
status = "repair_required" if issues else ("setup_available" if setup else "ok")
|
status = "repair_required" if issues else ("setup_available" if setup else "ok")
|
||||||
return {
|
return {
|
||||||
"status": status,
|
"status": status,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import stat
|
import stat
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
@@ -34,12 +35,201 @@ _held = threading.local()
|
|||||||
|
|
||||||
def user_data_dir():
|
def user_data_dir():
|
||||||
if os.environ.get("NAPSEER_USER_DATA_DIR"):
|
if os.environ.get("NAPSEER_USER_DATA_DIR"):
|
||||||
return Path(os.environ["NAPSEER_USER_DATA_DIR"]).expanduser().absolute()
|
path = Path(os.environ["NAPSEER_USER_DATA_DIR"]).expanduser()
|
||||||
|
if not path.is_absolute():
|
||||||
|
raise CredentialError("NAPSEER_USER_DATA_DIR must be an absolute path outside the checkout.")
|
||||||
|
return path.resolve()
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
return Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "napseer"
|
return Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "napseer"
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
return Path.home() / "Library" / "Application Support" / "napseer"
|
return Path.home() / "Library" / "Application Support" / "napseer"
|
||||||
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "napseer"
|
xdg = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
||||||
|
return (xdg if xdg.is_absolute() else Path.home() / ".local" / "share") / "napseer"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspacePaths:
|
||||||
|
"""Pure path resolution: Git identity survives worktree moves, not copies.
|
||||||
|
|
||||||
|
The public locator stays in Git. Repository credentials/signing keys are
|
||||||
|
shared by related worktrees; daemon/vault/cache state belongs to one worktree.
|
||||||
|
No credentials are read and no directory is created during path discovery.
|
||||||
|
"""
|
||||||
|
def __init__(self, root=None):
|
||||||
|
start = Path(root or os.environ.get("NAPSEER_PROJECT_ROOT") or Path.cwd()).expanduser().resolve()
|
||||||
|
self.root = start
|
||||||
|
git_dir = common = None
|
||||||
|
environment = {k:v for k,v in os.environ.items() if k not in
|
||||||
|
{"GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE"}}
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["git", "-C", str(start), "rev-parse", "--path-format=absolute",
|
||||||
|
"--show-toplevel", "--git-dir", "--git-common-dir"],
|
||||||
|
capture_output=True, text=True, timeout=3, env=environment)
|
||||||
|
if result.returncode == 0:
|
||||||
|
top, git_dir, common = result.stdout.strip().splitlines()
|
||||||
|
self.root = Path(top).resolve()
|
||||||
|
except (OSError, ValueError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
# A nested project's explicit locator is a separate namespace within Git.
|
||||||
|
for candidate in (start, *start.parents):
|
||||||
|
if (candidate / ".napseer/project.json").is_file():
|
||||||
|
self.root = candidate
|
||||||
|
break
|
||||||
|
if git_dir and candidate == self.root:
|
||||||
|
break
|
||||||
|
suffix = str(self.root.relative_to(Path(top).resolve())) if git_dir else ""
|
||||||
|
repo_identity = str(Path(common).resolve()) + ":" + suffix if common else str(self.root)
|
||||||
|
worktree_identity = str(Path(git_dir).resolve()) + ":" + suffix if git_dir else str(self.root)
|
||||||
|
self.repository_id = hashlib.sha256(repo_identity.encode()).hexdigest()
|
||||||
|
self.workspace_id = hashlib.sha256(worktree_identity.encode()).hexdigest()
|
||||||
|
self.data = user_data_dir().expanduser().resolve()
|
||||||
|
if self.data.is_relative_to(self.root) and self.root != Path.home().resolve():
|
||||||
|
raise CredentialError("Napseer user data must be outside the project checkout.")
|
||||||
|
self.legacy = self.root / ".napseer"
|
||||||
|
self.locator = self.legacy / "project.json"
|
||||||
|
self.repository = self.data / "repositories" / self.repository_id
|
||||||
|
self.state = self.data / "workspaces" / self.workspace_id
|
||||||
|
self.signing_key = self.repository / "id_ed25519"
|
||||||
|
self.project_credentials = self.data / "credentials/projects" / (self.repository_id + ".json")
|
||||||
|
old_id = hashlib.sha256(str(self.root).encode()).hexdigest()
|
||||||
|
self.old_credentials = self.data / "credentials/projects" / (old_id + ".json")
|
||||||
|
|
||||||
|
def migration_sources(self):
|
||||||
|
if self.legacy.is_symlink():
|
||||||
|
raise CredentialError("Legacy Napseer state must not be a symbolic link.")
|
||||||
|
sources = []
|
||||||
|
if self.legacy.exists():
|
||||||
|
sources = [p for p in self.legacy.iterdir() if p.name not in {"project.json", ".gitignore"}]
|
||||||
|
if self.old_credentials != self.project_credentials and self.old_credentials.exists():
|
||||||
|
sources.append(self.old_credentials)
|
||||||
|
return sources
|
||||||
|
|
||||||
|
def require_migrated(self):
|
||||||
|
if self.migration_sources():
|
||||||
|
raise CredentialError("Repository-local Napseer state needs migration. Stop older MCP clients and local gateways, then run `nap auth migrate`.")
|
||||||
|
|
||||||
|
def migrate(self, *, gateway=False):
|
||||||
|
"""Copy/verify/publish before removing originals; never overwrite conflicts.
|
||||||
|
|
||||||
|
Older processes must be stopped before this explicit operation. Existing
|
||||||
|
daemon PIDs are checked. A partial copy can be retried safely; all source
|
||||||
|
bytes remain until every destination has been verified.
|
||||||
|
"""
|
||||||
|
with credential_lock(self.data / "migration"):
|
||||||
|
sources = self.migration_sources()
|
||||||
|
if not sources:
|
||||||
|
return {"status":"already_migrated", "state_dir":str(self.state)}
|
||||||
|
for name in ("gateway.json", "ui.json"):
|
||||||
|
state = read_json(self.legacy / name)
|
||||||
|
pid = state.get("pid")
|
||||||
|
if isinstance(pid, int) and pid > 0:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise CredentialError("Stop the running local gateway/UI before `nap auth migrate`.")
|
||||||
|
entries = []
|
||||||
|
for source in sources:
|
||||||
|
if source.is_symlink() or not source.is_file():
|
||||||
|
raise CredentialError("Migration requires regular files; original state was preserved.")
|
||||||
|
destination = self.state / source.name
|
||||||
|
if source.name in {"id_ed25519", "id_ed25519.pub"}:
|
||||||
|
destination = self.repository / source.name
|
||||||
|
if source.name == "auth.json" or source == self.old_credentials:
|
||||||
|
destination = (self.state / "auth.json") if gateway and source.name == "auth.json" else self.project_credentials
|
||||||
|
original = file_fingerprint(source)
|
||||||
|
copied = None
|
||||||
|
if source.name in {"auth.json", "gateway-auth.json", "state.json"} or source == self.old_credentials:
|
||||||
|
data = read_json(source)
|
||||||
|
for field, old, new in (
|
||||||
|
("ssh_key_path", self.legacy / "id_ed25519", self.signing_key),
|
||||||
|
("vault_path", self.legacy / "vault.json", self.state / "vault.json"),
|
||||||
|
):
|
||||||
|
value = data.get(field)
|
||||||
|
if isinstance(value, str):
|
||||||
|
candidate = Path(value).expanduser()
|
||||||
|
candidate = candidate if candidate.is_absolute() else self.root / candidate
|
||||||
|
if candidate.resolve() == old.resolve(): data[field] = str(new)
|
||||||
|
copied = (json.dumps(data, indent=2) + "\n").encode()
|
||||||
|
if destination.is_symlink():
|
||||||
|
raise CredentialError("Migration destination must not be a symbolic link.")
|
||||||
|
expected = (len(copied), hashlib.sha256(copied).hexdigest()) if copied is not None else original
|
||||||
|
if destination.exists() and file_fingerprint(destination) != expected:
|
||||||
|
raise CredentialError("Migration conflicts with existing private state; nothing was overwritten. Reconcile the two states before retrying.")
|
||||||
|
if any(dst == destination and digest != expected for _,dst,_,_,digest in entries):
|
||||||
|
raise CredentialError("Legacy credentials conflict; original state was preserved.")
|
||||||
|
entries.append((source, destination, original, copied, expected))
|
||||||
|
# Publish credentials last, after their keys and other state exist.
|
||||||
|
entries.sort(key=lambda e: e[1] in {self.project_credentials, self.state / "auth.json"})
|
||||||
|
for source, destination, original, copied, expected in entries:
|
||||||
|
private_directory(destination.parent, self.data)
|
||||||
|
if not destination.exists():
|
||||||
|
fd, temporary = tempfile.mkstemp(prefix=".migrate-", dir=destination.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "wb") as stream:
|
||||||
|
os.fchmod(stream.fileno(), 0o600)
|
||||||
|
if copied is not None:
|
||||||
|
stream.write(copied)
|
||||||
|
else:
|
||||||
|
with source.open("rb") as original_stream:
|
||||||
|
while chunk := original_stream.read(1024 * 1024):
|
||||||
|
stream.write(chunk)
|
||||||
|
stream.flush(); os.fsync(stream.fileno())
|
||||||
|
# Publish without replacing another login or migration's file.
|
||||||
|
try:
|
||||||
|
os.link(temporary, destination)
|
||||||
|
except FileExistsError:
|
||||||
|
if file_fingerprint(destination) != expected:
|
||||||
|
raise CredentialError("Migration destination changed; original state was preserved.") from None
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temporary): os.unlink(temporary)
|
||||||
|
if file_fingerprint(destination) != expected or file_fingerprint(source) != original:
|
||||||
|
raise CredentialError("State changed during migration; original files were preserved. Stop older clients and retry.")
|
||||||
|
for source, destination, original, copied, expected in entries:
|
||||||
|
if file_fingerprint(source) != original or file_fingerprint(destination) != expected:
|
||||||
|
raise CredentialError("Migration verification failed; remaining originals were preserved.")
|
||||||
|
# Make published directory entries durable before removing originals.
|
||||||
|
# Windows does not support opening directories for fsync this way.
|
||||||
|
if os.name != "nt":
|
||||||
|
parents = set()
|
||||||
|
for _, destination, _, _, _ in entries:
|
||||||
|
for parent in (destination.parent, *destination.parent.parents):
|
||||||
|
parents.add(parent)
|
||||||
|
if parent == self.data: break
|
||||||
|
for parent in sorted(parents, key=lambda p: len(p.parts), reverse=True):
|
||||||
|
fd = os.open(parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||||
|
try: os.fsync(fd)
|
||||||
|
finally: os.close(fd)
|
||||||
|
for source, _, _, _, _ in entries:
|
||||||
|
source.unlink()
|
||||||
|
return {"status":"migrated", "state_dir":str(self.state), "files":len(entries)}
|
||||||
|
|
||||||
|
|
||||||
|
def private_directory(path, boundary):
|
||||||
|
path, boundary = Path(path), Path(boundary)
|
||||||
|
if not path.is_relative_to(boundary):
|
||||||
|
raise CredentialError("Private state directory is outside its user data boundary.")
|
||||||
|
for parent in (path, *path.parents):
|
||||||
|
if parent.is_symlink():
|
||||||
|
raise CredentialError("Private Napseer state must not use symlink directories.")
|
||||||
|
if parent == boundary: break
|
||||||
|
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
for parent in (path, *path.parents):
|
||||||
|
parent.chmod(0o700)
|
||||||
|
if parent == boundary: break
|
||||||
|
|
||||||
|
|
||||||
|
def file_fingerprint(path):
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||||
|
with os.fdopen(fd, "rb") as stream:
|
||||||
|
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
||||||
|
raise CredentialError("Migration requires regular files.")
|
||||||
|
size = 0
|
||||||
|
while chunk := stream.read(1024 * 1024):
|
||||||
|
digest.update(chunk); size += len(chunk)
|
||||||
|
return size, digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def read_json(path):
|
def read_json(path):
|
||||||
@@ -137,11 +327,11 @@ def credential_lock(path, timeout=30):
|
|||||||
|
|
||||||
class CredentialStore:
|
class CredentialStore:
|
||||||
def __init__(self, state_dir, legacy_path=None):
|
def __init__(self, state_dir, legacy_path=None):
|
||||||
self.state_dir = Path(state_dir)
|
self.workspace = WorkspacePaths(Path(state_dir).parent)
|
||||||
self.legacy = Path(legacy_path or self.state_dir / "auth.json")
|
self.state_dir = self.workspace.state
|
||||||
|
self.legacy = Path(legacy_path or self.workspace.legacy / "auth.json").expanduser().absolute()
|
||||||
self.user = user_data_dir() / "credentials" / "default.json"
|
self.user = user_data_dir() / "credentials" / "default.json"
|
||||||
project_key = hashlib.sha256(str(self.state_dir.parent.resolve()).encode()).hexdigest()
|
self.project = self.workspace.project_credentials
|
||||||
self.project = user_data_dir() / "credentials" / "projects" / f"{project_key}.json"
|
|
||||||
self.selection = self.state_dir / "state.json"
|
self.selection = self.state_dir / "state.json"
|
||||||
|
|
||||||
def selected(self):
|
def selected(self):
|
||||||
@@ -149,15 +339,21 @@ class CredentialStore:
|
|||||||
if bound: return bound
|
if bound: return bound
|
||||||
explicit = os.environ.get("NAPSEER_AUTH_FILE")
|
explicit = os.environ.get("NAPSEER_AUTH_FILE")
|
||||||
if explicit:
|
if explicit:
|
||||||
return Path(explicit).expanduser().absolute(), "explicit"
|
path = Path(explicit).expanduser().absolute()
|
||||||
if self.legacy != self.state_dir / "auth.json":
|
if path.is_symlink():
|
||||||
|
raise CredentialError("Explicit credentials must not be a symbolic link.")
|
||||||
|
if path.resolve().is_relative_to(self.workspace.root) and self.workspace.root != Path.home().resolve():
|
||||||
|
raise CredentialError("NAPSEER_AUTH_FILE must be outside the checkout; migrate private state first.")
|
||||||
|
return path, "explicit"
|
||||||
|
if self.legacy != self.workspace.legacy / "auth.json":
|
||||||
|
if self.legacy.resolve().is_relative_to(self.workspace.root) and self.workspace.root != Path.home().resolve():
|
||||||
|
raise CredentialError("Explicit credentials must be outside the checkout; migrate private state first.")
|
||||||
return self.legacy, "explicit"
|
return self.legacy, "explicit"
|
||||||
|
self.workspace.require_migrated()
|
||||||
if read_json(self.selection).get("credential_profile") == "user":
|
if read_json(self.selection).get("credential_profile") == "user":
|
||||||
return self.user, "user"
|
return self.user, "user"
|
||||||
if self.project.exists() or self.project.is_symlink():
|
if self.project.exists() or self.project.is_symlink():
|
||||||
return self.project, "project"
|
return self.project, "project"
|
||||||
if self.legacy.exists() or self.legacy.is_symlink():
|
|
||||||
return self.legacy, "legacy_project"
|
|
||||||
return self.user, "user"
|
return self.user, "user"
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -185,14 +381,8 @@ class CredentialStore:
|
|||||||
return self.user
|
return self.user
|
||||||
|
|
||||||
def migrate(self):
|
def migrate(self):
|
||||||
with credential_lock(self.legacy), credential_lock(self.project):
|
self.workspace.migrate()
|
||||||
legacy = read_json(self.legacy)
|
return self.project
|
||||||
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):
|
def read(self):
|
||||||
path, source = self.selected()
|
path, source = self.selected()
|
||||||
@@ -205,12 +395,15 @@ class CredentialStore:
|
|||||||
def write(self, data):
|
def write(self, data):
|
||||||
path, source = self.selected()
|
path, source = self.selected()
|
||||||
with credential_lock(path):
|
with credential_lock(path):
|
||||||
if source in {"legacy_project", "explicit"}:
|
if source == "explicit":
|
||||||
atomic_json(path, data)
|
atomic_json(path, data)
|
||||||
return
|
return
|
||||||
credentials = {k: v for k, v in data.items() if k in CREDENTIAL_KEYS}
|
credentials = {k: v for k, v in data.items() if k in CREDENTIAL_KEYS}
|
||||||
if source == "project":
|
if source == "project":
|
||||||
credentials.update({k: v for k, v in data.items() if k in SELECTION_KEYS})
|
# Preserve project-bound worker/recovery/key metadata during
|
||||||
|
# refresh after legacy migration. Profile selection stays local
|
||||||
|
# to the worktree and must not change sibling worktrees.
|
||||||
|
credentials = {k: v for k, v in data.items() if k != "credential_profile"}
|
||||||
current = read_json(path)
|
current = read_json(path)
|
||||||
# Reading project metadata must not churn or overwrite shared
|
# Reading project metadata must not churn or overwrite shared
|
||||||
# credentials, including after another process logged out.
|
# credentials, including after another process logged out.
|
||||||
|
|||||||
+4397
-405
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -3,8 +3,8 @@ import pytest
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def isolated_user_credentials(monkeypatch, tmp_path):
|
def isolated_user_credentials(monkeypatch, tmp_path, tmp_path_factory):
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
monkeypatch.setenv("NAPSEER_USER_DATA_DIR", str(tmp_path / "user-data"))
|
monkeypatch.setenv("NAPSEER_USER_DATA_DIR", str(tmp_path_factory.mktemp("napseer-user")))
|
||||||
for name in ("NAPSEER_API_KEY", "NAPSEER_TOKEN", "NAPSEER_REFRESH_TOKEN", "NAPSEER_AUTH_FILE", "NAPSEER_PROJECT_ROOT", "NAPSEER_PROJECT_ID", "NAPSEER_BASE_URL"):
|
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)
|
monkeypatch.delenv(name, raising=False)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def test_stdin_bootstrap_installs_verified_runtime_and_preserves_credentials(tmp
|
|||||||
content = (source / installer.INSTALL_PATHS[name]).read_text()
|
content = (source / installer.INSTALL_PATHS[name]).read_text()
|
||||||
item = {"name": name, "version": installer.CLI_RELEASE_VERSION,
|
item = {"name": name, "version": installer.CLI_RELEASE_VERSION,
|
||||||
"bytes": len(content.encode()), "sha256": hashlib.sha256(content.encode()).hexdigest(),
|
"bytes": len(content.encode()), "sha256": hashlib.sha256(content.encode()).hexdigest(),
|
||||||
"source_repo": "https://github.com/napseer/nap-cli", "source_revision": revision,
|
"source_repo": "https://gitea.powc-r.com/napseer/nap-cli", "source_revision": revision,
|
||||||
"source_revision_status": "resolved", "contract_version": installer.CLI_DISTRIBUTION_CONTRACT_VERSION,
|
"source_revision_status": "resolved", "contract_version": installer.CLI_DISTRIBUTION_CONTRACT_VERSION,
|
||||||
"minimum_contract_version": installer.CLI_MINIMUM_CONTRACT_VERSION,
|
"minimum_contract_version": installer.CLI_MINIMUM_CONTRACT_VERSION,
|
||||||
"install_path": installer.INSTALL_PATHS[name], "mode": "0755"}
|
"install_path": installer.INSTALL_PATHS[name], "mode": "0755"}
|
||||||
@@ -30,7 +30,7 @@ def test_stdin_bootstrap_installs_verified_runtime_and_preserves_credentials(tmp
|
|||||||
payloads["/v1/scripts"] = {"schema_version": installer.CLI_BUNDLE_SCHEMA_VERSION,
|
payloads["/v1/scripts"] = {"schema_version": installer.CLI_BUNDLE_SCHEMA_VERSION,
|
||||||
"bundle_id": "synthetic-bootstrap", "release_version": installer.CLI_RELEASE_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},
|
"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}
|
"source": {"repo": "https://gitea.powc-r.com/napseer/nap-cli", "revision": revision, "revision_status": "resolved"}, "items": items}
|
||||||
|
|
||||||
class Provider(BaseHTTPRequestHandler):
|
class Provider(BaseHTTPRequestHandler):
|
||||||
def log_message(self, *_args):
|
def log_message(self, *_args):
|
||||||
@@ -48,13 +48,15 @@ def test_stdin_bootstrap_installs_verified_runtime_and_preserves_credentials(tmp
|
|||||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Provider)
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Provider)
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
user_data = tmp_path / "user-data"
|
user_data = tmp_path / "user-data"
|
||||||
user_data.mkdir(exist_ok=True)
|
user_data.mkdir(exist_ok=True)
|
||||||
auth = user_data / "auth.json"
|
auth = user_data / "auth.json"
|
||||||
auth.write_text('{"refresh_token":"synthetic-preserved"}')
|
auth.write_text('{"refresh_token":"synthetic-preserved"}')
|
||||||
env = {"PATH": os.environ["PATH"], "NAPSEER_HOME": str(tmp_path / "runtime"),
|
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_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}"}
|
"NAPSEER_PROJECT_ROOT": str(workspace), "NAPSEER_BASE_URL": f"http://127.0.0.1:{server.server_port}"}
|
||||||
try:
|
try:
|
||||||
result = subprocess.run([sys.executable, "-"], input=(source / "nap_install.py").read_text(),
|
result = subprocess.run([sys.executable, "-"], input=(source / "nap_install.py").read_text(),
|
||||||
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30)
|
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
|
import datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
@@ -192,6 +193,10 @@ def test_doctor_reports_presence_without_exposing_values(tmp_path, monkeypatch):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from napseer_credentials import CredentialStore
|
||||||
|
store = CredentialStore(state_dir)
|
||||||
|
store.migrate()
|
||||||
|
auth_path = store.project
|
||||||
result = module.doctor_status()
|
result = module.doctor_status()
|
||||||
serialized = json.dumps(result)
|
serialized = json.dumps(result)
|
||||||
|
|
||||||
@@ -395,7 +400,7 @@ def fake_bundle(module, revision="a" * 40, broken_name=None):
|
|||||||
"version": module.CLI_RELEASE_VERSION,
|
"version": module.CLI_RELEASE_VERSION,
|
||||||
"bytes": len(content.encode("utf-8")),
|
"bytes": len(content.encode("utf-8")),
|
||||||
"sha256": digest,
|
"sha256": digest,
|
||||||
"source_repo": "https://github.com/napseer/nap-cli",
|
"source_repo": "https://gitea.powc-r.com/napseer/nap-cli",
|
||||||
"source_revision": revision,
|
"source_revision": revision,
|
||||||
"source_revision_status": "resolved",
|
"source_revision_status": "resolved",
|
||||||
"contract_version": module.CLI_DISTRIBUTION_CONTRACT_VERSION,
|
"contract_version": module.CLI_DISTRIBUTION_CONTRACT_VERSION,
|
||||||
@@ -418,7 +423,7 @@ def fake_bundle(module, revision="a" * 40, broken_name=None):
|
|||||||
"minimum_supported": module.CLI_MINIMUM_CONTRACT_VERSION,
|
"minimum_supported": module.CLI_MINIMUM_CONTRACT_VERSION,
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"repo": "https://github.com/napseer/nap-cli",
|
"repo": "https://gitea.powc-r.com/napseer/nap-cli",
|
||||||
"revision": revision,
|
"revision": revision,
|
||||||
"revision_status": "resolved",
|
"revision_status": "resolved",
|
||||||
},
|
},
|
||||||
@@ -509,11 +514,16 @@ 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-09-11"
|
# One day past this installer's own contract, whatever it currently is.
|
||||||
manifest["contract"]["minimum_supported"] = "2026-09-11"
|
newer = (
|
||||||
|
datetime.date.fromisoformat(module.CLI_DISTRIBUTION_CONTRACT_VERSION)
|
||||||
|
+ datetime.timedelta(days=1)
|
||||||
|
).isoformat()
|
||||||
|
manifest["contract"]["current"] = newer
|
||||||
|
manifest["contract"]["minimum_supported"] = newer
|
||||||
for item in manifest["items"]:
|
for item in manifest["items"]:
|
||||||
item["contract_version"] = "2026-09-11"
|
item["contract_version"] = newer
|
||||||
item["minimum_contract_version"] = "2026-09-11"
|
item["minimum_contract_version"] = newer
|
||||||
|
|
||||||
try:
|
try:
|
||||||
module.validated_manifest_items(manifest)
|
module.validated_manifest_items(manifest)
|
||||||
|
|||||||
@@ -188,6 +188,13 @@ def test_gateway_vault_aliases_rejected():
|
|||||||
expect_unknown(mod, args, "gateway vault action: list")
|
expect_unknown(mod, args, "gateway vault action: list")
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_vault_unlock_verbs_point_to_setup_and_forget():
|
||||||
|
"""The memory secret is provisioned once, not unlocked per session."""
|
||||||
|
mod = load_module()
|
||||||
|
for action in ("unlock", "lock", "provision", "remove", "reset"):
|
||||||
|
expect_unknown(mod, ["napseer_mcp_server.py", "gateway", "vault", action], "nap gateway vault setup")
|
||||||
|
|
||||||
|
|
||||||
def test_gateway_rotate_passphrase_aliases_rejected():
|
def test_gateway_rotate_passphrase_aliases_rejected():
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
for args in [
|
for args in [
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""Login in a checkout with a committed locator rebinds its project without a
|
||||||
|
second, project-scoped consent, and never replaces a still-valid override."""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
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, read_json # noqa: E402
|
||||||
|
|
||||||
|
PROJECT_ID = "33333333-3333-3333-3333-333333333333"
|
||||||
|
PROJECT = {"id": PROJECT_ID, "slug": "committed-project", "name": "Committed Project",
|
||||||
|
"encryption_state": "standard"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def provider():
|
||||||
|
state = {"refresh": [], "project_reads": [], "token_status": 401}
|
||||||
|
|
||||||
|
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):
|
||||||
|
if self.path == f"/v1/projects/{PROJECT_ID}":
|
||||||
|
state["project_reads"].append(self.headers.get("Authorization"))
|
||||||
|
if self.headers.get("Authorization") == "Bearer account-access":
|
||||||
|
return self.reply(200, PROJECT)
|
||||||
|
self.reply(401, {"error": {"code": "unauthorized"}})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
form = parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode())
|
||||||
|
assert self.path == "/v1/oauth/token"
|
||||||
|
state["refresh"].append(form.get("refresh_token"))
|
||||||
|
if state["token_status"] == 503:
|
||||||
|
return self.reply(503, {"error": {"code": "unavailable"}})
|
||||||
|
self.reply(401, {"error": {"code": "invalid_grant"}})
|
||||||
|
|
||||||
|
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 checkout(tmp_path, monkeypatch, url, project_credential):
|
||||||
|
root = tmp_path / "checkout"
|
||||||
|
(root / ".napseer").mkdir(parents=True)
|
||||||
|
(root / ".napseer/project.json").write_text(json.dumps({
|
||||||
|
"schema": "napseer.project.v1", "base_url": url,
|
||||||
|
"project_id": PROJECT_ID, "project_slug": PROJECT["slug"]}) + "\n")
|
||||||
|
monkeypatch.setenv("NAPSEER_PROJECT_ROOT", str(root))
|
||||||
|
monkeypatch.setenv("NAPSEER_BASE_URL", url)
|
||||||
|
monkeypatch.chdir(root)
|
||||||
|
spec = importlib.util.spec_from_file_location(f"committed_login_{id(root)}", SCRIPT)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
store = module.credential_store()
|
||||||
|
if project_credential is not None:
|
||||||
|
store.login({"base_url": url, "account_mode": "operator_project", "oauth_client_id": "nap-cli",
|
||||||
|
"project_id": PROJECT_ID, **project_credential}, project=True)
|
||||||
|
oauth_calls = []
|
||||||
|
|
||||||
|
def consent(args):
|
||||||
|
oauth_calls.append(args["flow"])
|
||||||
|
assert args["flow"] == "operator_login", "a committed project must not need project consent"
|
||||||
|
return {"api_base_url": url, "access_token": "account-access", "refresh_token": "account-refresh",
|
||||||
|
"expires_in": 3600, "scope": args["scope"]}
|
||||||
|
|
||||||
|
module.oauth_loopback_authorize = consent
|
||||||
|
return module, store, oauth_calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_replaces_revoked_project_grant_and_attaches_committed_project(tmp_path, monkeypatch, provider):
|
||||||
|
url, state = provider
|
||||||
|
module, store, oauth_calls = checkout(tmp_path, monkeypatch, url, {
|
||||||
|
"token": "dead-access", "refresh_token": "dead-refresh",
|
||||||
|
"token_expires_at": "2020-01-01T00:00:00Z", "refresh_expires_at": "2099-01-01T00:00:00Z"})
|
||||||
|
|
||||||
|
result = module.operator_account_login({"base_url": url})
|
||||||
|
|
||||||
|
assert oauth_calls == ["operator_login"]
|
||||||
|
assert state["refresh"] == [["dead-refresh"]], "the dead grant is proven dead before it is replaced"
|
||||||
|
assert result["replaced_project_credential"] == "refresh_rejected"
|
||||||
|
assert result["credential_source"] == "user"
|
||||||
|
assert result["project_id"] == PROJECT_ID
|
||||||
|
assert store.selected()[1] == "user"
|
||||||
|
assert read_json(store.selection)["project_id"] == PROJECT_ID
|
||||||
|
assert state["project_reads"][-1] == "Bearer account-access"
|
||||||
|
assert "dead-refresh" not in json.dumps(result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_keeps_valid_project_override(tmp_path, monkeypatch, provider):
|
||||||
|
url, state = provider
|
||||||
|
module, store, oauth_calls = checkout(tmp_path, monkeypatch, url, {
|
||||||
|
"token": "live-access", "refresh_token": "live-refresh",
|
||||||
|
"token_expires_at": "2099-01-01T00:00:00Z", "refresh_expires_at": "2099-01-01T00:00:00Z"})
|
||||||
|
|
||||||
|
result = module.operator_account_login({"base_url": url})
|
||||||
|
|
||||||
|
assert oauth_calls == ["operator_login"]
|
||||||
|
assert result["credential_source"] == "project"
|
||||||
|
assert "use-user" in result["message"]
|
||||||
|
assert store.selected()[1] == "project"
|
||||||
|
assert state["refresh"] == [] and state["project_reads"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_does_not_replace_override_when_refresh_outcome_is_unknown(tmp_path, monkeypatch, provider):
|
||||||
|
url, state = provider
|
||||||
|
state["token_status"] = 503
|
||||||
|
module, store, _calls = checkout(tmp_path, monkeypatch, url, {
|
||||||
|
"token": "dead-access", "refresh_token": "dead-refresh",
|
||||||
|
"token_expires_at": "2020-01-01T00:00:00Z", "refresh_expires_at": "2099-01-01T00:00:00Z"})
|
||||||
|
|
||||||
|
result = module.operator_account_login({"base_url": url})
|
||||||
|
|
||||||
|
assert result["credential_source"] == "project"
|
||||||
|
assert store.selected()[1] == "project"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_without_override_attaches_committed_project(tmp_path, monkeypatch, provider):
|
||||||
|
url, state = provider
|
||||||
|
module, store, oauth_calls = checkout(tmp_path, monkeypatch, url, None)
|
||||||
|
|
||||||
|
result = module.operator_account_login({"base_url": url})
|
||||||
|
|
||||||
|
assert oauth_calls == ["operator_login"]
|
||||||
|
assert result["project_id"] == PROJECT_ID
|
||||||
|
assert read_json(store.selection)["project_id"] == PROJECT_ID
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_uses_account_login_when_project_grant_was_logged_out(tmp_path, monkeypatch, provider):
|
||||||
|
url, state = provider
|
||||||
|
module, store, oauth_calls = checkout(tmp_path, monkeypatch, url, None)
|
||||||
|
store.login({"base_url": url, "token": "account-access", "refresh_token": "account-refresh",
|
||||||
|
"account_mode": "operator_account", "oauth_client_id": "nap-cli",
|
||||||
|
"token_expires_at": "2099-01-01T00:00:00Z"})
|
||||||
|
store.login({"base_url": url, "logged_out": True}, project=True)
|
||||||
|
|
||||||
|
result = module.operator_project_attach({})
|
||||||
|
|
||||||
|
assert oauth_calls == [], "no browser consent is needed for a committed project"
|
||||||
|
assert result["mode"] == "operator_account"
|
||||||
|
assert result["project_id"] == PROJECT_ID
|
||||||
|
assert store.selected()[1] == "user"
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_grant_rejection_explains_reattach(tmp_path, monkeypatch, provider):
|
||||||
|
url, _state = provider
|
||||||
|
module, _store, _calls = checkout(tmp_path, monkeypatch, url, {
|
||||||
|
"token": "dead-access", "refresh_token": "dead-refresh",
|
||||||
|
"token_expires_at": "2020-01-01T00:00:00Z", "refresh_expires_at": "2099-01-01T00:00:00Z"})
|
||||||
|
module.refresh_public_auth_state()
|
||||||
|
with pytest.raises(module.SafeToolError) as error:
|
||||||
|
module.renew_auth()
|
||||||
|
assert "project login" in error.value.safe_message
|
||||||
|
assert ".napseer/project.json" in error.value.safe_message
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""AES-256-GCM backend selection: known answers, interop and tamper detection.
|
||||||
|
|
||||||
|
Both the optional native backend (`cryptography`) and the stdlib-only
|
||||||
|
pure-Python backend run the same vectors through the public
|
||||||
|
aes_gcm_encrypt/aes_gcm_decrypt API used by node and bundle encryption.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py"
|
||||||
|
|
||||||
|
h = bytes.fromhex
|
||||||
|
GCM_KEY = h("feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308")
|
||||||
|
GCM_IV = h("cafebabefacedbaddecaf888")
|
||||||
|
GCM_P = h(
|
||||||
|
"d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a72"
|
||||||
|
"1c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b391aafd255"
|
||||||
|
)
|
||||||
|
GCM_C = h(
|
||||||
|
"522dc1f099567d07f47f37a32a84427d643a8cdcbfe5c0c97598a2bd2555d1aa"
|
||||||
|
"8cb08e48590dbb3da7b08b1056828838c5f61e6393ba7a0abcc9f662898015ad"
|
||||||
|
)
|
||||||
|
# The GCM specification (McGrew & Viega) test cases 13-16, AES-256.
|
||||||
|
KNOWN_ANSWERS = [
|
||||||
|
("tc13", bytes(32), bytes(12), b"", b"", b"", h("530f8afbc74536b9a963b4f1c4cb738b")),
|
||||||
|
("tc14", bytes(32), bytes(12), b"", bytes(16), h("cea7403d4d606b6e074ec5d3baf39d18"),
|
||||||
|
h("d0d1c8a799996bf0265b98b5d48ab919")),
|
||||||
|
("tc15", GCM_KEY, GCM_IV, b"", GCM_P, GCM_C, h("b094dac5d93471bdec1a502270e3cc6c")),
|
||||||
|
("tc16", GCM_KEY, GCM_IV, h("feedfacedeadbeeffeedfacedeadbeefabaddad2"), GCM_P[:60], GCM_C[:60],
|
||||||
|
h("76fc6ece0f4e1768cddf8853bb2d551b")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def mod():
|
||||||
|
spec = importlib.util.spec_from_file_location("crypto_backend_test_worker", SCRIPT)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(SCRIPT.parent))
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
yield module
|
||||||
|
module.select_crypto_backend()
|
||||||
|
|
||||||
|
|
||||||
|
def backends(mod):
|
||||||
|
names = [mod.CRYPTO_BACKEND_PURE]
|
||||||
|
if mod.native_crypto_available():
|
||||||
|
names.append(mod.CRYPTO_BACKEND_NATIVE)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def use(mod, name):
|
||||||
|
assert mod.select_crypto_backend(name) == name
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_backend_is_selected_when_cryptography_is_importable(mod, monkeypatch):
|
||||||
|
pytest.importorskip("cryptography")
|
||||||
|
monkeypatch.delenv("NAPSEER_CRYPTO_BACKEND", raising=False)
|
||||||
|
assert mod.select_crypto_backend() == mod.CRYPTO_BACKEND_NATIVE
|
||||||
|
monkeypatch.setenv("NAPSEER_CRYPTO_BACKEND", "pure_python")
|
||||||
|
assert mod.select_crypto_backend() == mod.CRYPTO_BACKEND_PURE
|
||||||
|
assert mod.crypto_backend_status()["crypto_backend"] == "pure_python"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pure_backend_is_used_when_native_is_unavailable(mod, monkeypatch):
|
||||||
|
monkeypatch.setattr(mod, "_NativeAESGCM", None)
|
||||||
|
monkeypatch.delenv("NAPSEER_CRYPTO_BACKEND", raising=False)
|
||||||
|
try:
|
||||||
|
assert mod.select_crypto_backend() == mod.CRYPTO_BACKEND_PURE
|
||||||
|
status = mod.crypto_backend_status()
|
||||||
|
assert status["native_crypto_available"] is False
|
||||||
|
assert "cryptography" in status["crypto_backend_note"]
|
||||||
|
finally:
|
||||||
|
monkeypatch.undo()
|
||||||
|
mod.select_crypto_backend()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("case", KNOWN_ANSWERS, ids=[case[0] for case in KNOWN_ANSWERS])
|
||||||
|
def test_known_answer_vectors_through_every_backend(mod, case):
|
||||||
|
_name, key, iv, aad, plaintext, ciphertext, tag = case
|
||||||
|
for backend in backends(mod):
|
||||||
|
use(mod, backend)
|
||||||
|
assert mod.aes_gcm_encrypt(key, iv, aad, plaintext) == ciphertext + tag, backend
|
||||||
|
assert mod.aes_gcm_decrypt(key, iv, aad, ciphertext + tag) == plaintext, backend
|
||||||
|
mod.select_crypto_backend()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_backend_interop(mod):
|
||||||
|
pytest.importorskip("cryptography")
|
||||||
|
key = bytes(range(32))
|
||||||
|
nonce = bytes(range(12))
|
||||||
|
aad = b"napseer interop aad"
|
||||||
|
for size in (0, 1, 15, 16, 17, 1000):
|
||||||
|
plaintext = bytes((index * 7) & 0xFF for index in range(size))
|
||||||
|
use(mod, mod.CRYPTO_BACKEND_PURE)
|
||||||
|
pure = mod.aes_gcm_encrypt(key, nonce, aad, plaintext)
|
||||||
|
use(mod, mod.CRYPTO_BACKEND_NATIVE)
|
||||||
|
native = mod.aes_gcm_encrypt(key, nonce, aad, plaintext)
|
||||||
|
assert pure == native
|
||||||
|
assert mod.aes_gcm_decrypt(key, nonce, aad, pure) == plaintext
|
||||||
|
use(mod, mod.CRYPTO_BACKEND_PURE)
|
||||||
|
assert mod.aes_gcm_decrypt(key, nonce, aad, native) == plaintext
|
||||||
|
mod.select_crypto_backend()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tamper_detection_raises_the_same_error_in_every_backend(mod):
|
||||||
|
key = bytes(range(32))
|
||||||
|
nonce = bytes(12)
|
||||||
|
for backend in backends(mod):
|
||||||
|
use(mod, backend)
|
||||||
|
packed = mod.aes_gcm_encrypt(key, nonce, b"aad", b"synthetic payload")
|
||||||
|
tampered_body = bytes([packed[0] ^ 1]) + packed[1:]
|
||||||
|
tampered_tag = packed[:-1] + bytes([packed[-1] ^ 0x80])
|
||||||
|
wrong_key = bytes([key[0] ^ 1]) + key[1:]
|
||||||
|
for label, attempt in (
|
||||||
|
("body", lambda: mod.aes_gcm_decrypt(key, nonce, b"aad", tampered_body)),
|
||||||
|
("tag", lambda: mod.aes_gcm_decrypt(key, nonce, b"aad", tampered_tag)),
|
||||||
|
("aad", lambda: mod.aes_gcm_decrypt(key, nonce, b"other", packed)),
|
||||||
|
("key", lambda: mod.aes_gcm_decrypt(wrong_key, nonce, b"aad", packed)),
|
||||||
|
):
|
||||||
|
with pytest.raises(RuntimeError, match="^AES-GCM authentication failed$"):
|
||||||
|
attempt()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="missing tag"):
|
||||||
|
mod.aes_gcm_decrypt(key, nonce, b"aad", b"short")
|
||||||
|
with pytest.raises(RuntimeError, match="nonce must be 12 bytes"):
|
||||||
|
mod.aes_gcm_encrypt(key, bytes(16), b"", b"x")
|
||||||
|
with pytest.raises(RuntimeError, match="key must be 32 bytes"):
|
||||||
|
mod.aes_gcm_encrypt(bytes(16), nonce, b"", b"x")
|
||||||
|
mod.select_crypto_backend()
|
||||||
|
|
||||||
|
|
||||||
|
def test_hkdf_sha256_matches_rfc5869_vectors(mod):
|
||||||
|
# RFC 5869 Appendix A, test cases 1 and 3.
|
||||||
|
okm = mod.hkdf_sha256(b"\x0b" * 22, bytes(range(13)), bytes(range(0xF0, 0xFA)), 42)
|
||||||
|
assert okm.hex() == (
|
||||||
|
"3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
|
||||||
|
)
|
||||||
|
okm = mod.hkdf_sha256(b"\x0b" * 22, b"", b"", 42)
|
||||||
|
assert okm.hex() == (
|
||||||
|
"8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnostics_report_the_backend_without_key_material(mod):
|
||||||
|
status = mod.crypto_backend_status()
|
||||||
|
assert status["crypto_backend"] in {"native", "pure_python"}
|
||||||
|
assert set(status) <= {"crypto_backend", "native_crypto_available", "crypto_backend_note"}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""Effective node types and release-terminal statuses in the CLI wrapper.
|
||||||
|
|
||||||
|
Decision /decisions/2026-09-29-node-type-status-vocabulary: creates infer a
|
||||||
|
type from the folder when the caller omits one, type filters match effective
|
||||||
|
types (a generic `note` resolves from its folder), and active-only lists also
|
||||||
|
exclude release-style terminal statuses. Older APIs do none of this, so the
|
||||||
|
wrapper must behave the same on its own. Writes go through the real request
|
||||||
|
path to a fake transport; discovery runs against a disposable HTTP provider
|
||||||
|
and the real local index.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
|
||||||
|
|
||||||
|
|
||||||
|
def load_module(name):
|
||||||
|
sys.path.insert(0, str(SCRIPT.parent))
|
||||||
|
spec = importlib.util.spec_from_file_location(name, SCRIPT)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec and spec.loader
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
# -- Writes -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, payload):
|
||||||
|
self._body = json.dumps(payload).encode("utf-8")
|
||||||
|
|
||||||
|
def read(self, *_args):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def writer(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "all")
|
||||||
|
mod = load_module("napseer_effective_types_write_test")
|
||||||
|
mod.DEFAULT_PROJECT_ID = "project-1"
|
||||||
|
mod.TOKEN = "synthetic-access"
|
||||||
|
mod.TOKEN_EXPIRES_AT = "2099-01-01T00:00:00Z"
|
||||||
|
mod.AUTH = {**mod.AUTH, "account_mode": "operator_account", "project_id": "project-1"}
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = {"all"}
|
||||||
|
mod.refresh_public_auth_state = lambda: mod.AUTH
|
||||||
|
mod.project_memory_encryption_active = lambda _project_id: False
|
||||||
|
mod.AUTH_DIR = tmp_path / "state"
|
||||||
|
mod.INDEX_PATH = tmp_path / "state" / "index.sqlite"
|
||||||
|
mod.INDEX_LOCK_PATH = tmp_path / "state" / "index.lock"
|
||||||
|
mod.resolve_project_id = lambda _args: "project-1"
|
||||||
|
mod.acquire_project_lock = lambda args=None, **kwargs: {"id": "lock-1", "lease_token": "lease-1"}
|
||||||
|
mod.cleanup_project_lock = lambda project_id, lock: None
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
def fake_urlopen(request, timeout=None):
|
||||||
|
body = json.loads(request.data.decode("utf-8")) if request.data else None
|
||||||
|
sent.append((request.get_method(), urlsplit(request.full_url).path, body))
|
||||||
|
folder = (body or {}).get("folder_path") or "/"
|
||||||
|
name = (body or {}).get("name") or "node"
|
||||||
|
return FakeResponse({
|
||||||
|
"id": f"node-{len(sent)}", "project_id": "project-1",
|
||||||
|
"full_path": f"{folder.rstrip('/')}/{name}", "folder_path": folder, "name": name,
|
||||||
|
# The fake server applies the historical default; the payload is what is asserted.
|
||||||
|
"type": (body or {}).get("type") or "note",
|
||||||
|
"content_text": (body or {}).get("content_text", ""), "metadata": {},
|
||||||
|
"updated_at": "2026-09-29T12:00:00Z",
|
||||||
|
})
|
||||||
|
|
||||||
|
mod.api_urlopen = fake_urlopen
|
||||||
|
return mod, sent
|
||||||
|
|
||||||
|
|
||||||
|
def created_payload(sent):
|
||||||
|
posts = [body for method, path, body in sent if method == "POST" and path.endswith("/nodes")]
|
||||||
|
assert len(posts) == 1, sent
|
||||||
|
return posts[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("folder_path", "expected"),
|
||||||
|
[
|
||||||
|
("/plans", "plan"),
|
||||||
|
("/plans/2026", "plan"),
|
||||||
|
("/decisions", "decision"),
|
||||||
|
("/product/decisions", "decision"),
|
||||||
|
("/implementation-notes", "implementation_note"),
|
||||||
|
("/deployments", "deployment"),
|
||||||
|
("/runbooks", "runbook"),
|
||||||
|
("/rules", "rule"),
|
||||||
|
("/indexes", "index"),
|
||||||
|
("/analysis", "analysis"),
|
||||||
|
("/product/analysis", "analysis"),
|
||||||
|
("/documentation/security", "documentation"),
|
||||||
|
("/kanban/doing", "kanban_card"),
|
||||||
|
# The server normalizes folder segments before storing; inference agrees.
|
||||||
|
("/Implementation Notes/", "implementation_note"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_create_without_type_sends_the_folder_type(writer, folder_path, expected):
|
||||||
|
mod, sent = writer
|
||||||
|
mod.call_tool_impl("nap_create_node", {"folder_path": folder_path, "name": "record", "content_text": "body"})
|
||||||
|
assert created_payload(sent)["type"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("folder_path", ["/notes", "/plansx", "/product", "/services/plans"])
|
||||||
|
def test_create_without_type_outside_typed_folders_leaves_the_server_default(writer, folder_path):
|
||||||
|
mod, sent = writer
|
||||||
|
mod.call_tool_impl("nap_create_node", {"folder_path": folder_path, "name": "record", "content_text": "body"})
|
||||||
|
assert "type" not in created_payload(sent)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("given", "expected"),
|
||||||
|
[
|
||||||
|
("implementation-note", "implementation_note"),
|
||||||
|
("Kanban Card", "kanban_card"),
|
||||||
|
("product-spec", "product_spec"),
|
||||||
|
("decision", "decision"),
|
||||||
|
# An explicit generic note stays a note even inside a typed folder.
|
||||||
|
("note", "note"),
|
||||||
|
# Unknown custom types are kept exactly as given.
|
||||||
|
("security-documentation", "security-documentation"),
|
||||||
|
("Custom Type", "Custom Type"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_create_with_explicit_type_only_canonicalizes_known_spellings(writer, given, expected):
|
||||||
|
mod, sent = writer
|
||||||
|
mod.call_tool_impl(
|
||||||
|
"nap_create_node",
|
||||||
|
{"folder_path": "/plans", "name": "record", "content_text": "body", "type": given},
|
||||||
|
)
|
||||||
|
assert created_payload(sent)["type"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_node_schema_advertises_optional_type(writer):
|
||||||
|
mod, _sent = writer
|
||||||
|
tool = next(tool for tool in mod.tools() if tool["name"] == "nap_create_node")
|
||||||
|
schema = tool["inputSchema"]
|
||||||
|
assert schema["properties"]["type"]["type"] == "string"
|
||||||
|
assert "type" not in schema.get("required", [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_creates_infer_types_per_item(writer):
|
||||||
|
mod, sent = writer
|
||||||
|
mod.try_get_node_by_path = lambda path, allow_agent=False: None
|
||||||
|
mod.call_tool_impl("nap_bulk", {"nodes": [
|
||||||
|
{"path": "/decisions/choose-store", "content_text": "a"},
|
||||||
|
{"path": "/scratch/idea", "content_text": "b"},
|
||||||
|
{"path": "/runbooks/restore", "content_text": "c", "type": "product-spec"},
|
||||||
|
{"path": "/plans/custom", "content_text": "d", "type": "Custom Type"},
|
||||||
|
]})
|
||||||
|
posts = [body for method, path, body in sent if method == "POST" and path.endswith("/nodes")]
|
||||||
|
assert [body.get("type") for body in posts] == ["decision", None, "product_spec", "Custom Type"]
|
||||||
|
assert "type" not in posts[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_update_without_type_does_not_retype_the_stored_node(writer):
|
||||||
|
mod, sent = writer
|
||||||
|
existing = {"id": "node-9", "project_id": "project-1", "full_path": "/plans/legacy",
|
||||||
|
"folder_path": "/plans", "name": "legacy", "type": "note", "content_text": "old"}
|
||||||
|
mod.get_node_by_id = lambda args: existing
|
||||||
|
mod.call_tool_impl("nap_bulk", {"nodes": [{"node_id": "node-9", "content_text": "new"}]})
|
||||||
|
patches = [body for method, path, body in sent if method == "PATCH"]
|
||||||
|
assert patches and "type" not in patches[0]
|
||||||
|
|
||||||
|
|
||||||
|
# -- Local discovery ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def stored_node(folder, name, node_type, *, minute, status=None, content=None):
|
||||||
|
stamp = f"2026-09-01T12:{minute:02d}:00Z"
|
||||||
|
return {
|
||||||
|
"id": name, "project_id": "project-1", "full_path": f"{folder}/{name}",
|
||||||
|
"folder_path": folder, "name": name, "type": node_type,
|
||||||
|
"metadata": {"status": status} if status else {}, "tags": [], "aliases": [], "links": [],
|
||||||
|
"content_text": content or f"{name} vocabularyterm", "updated_at": stamp, "archived_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def local(tmp_path, monkeypatch):
|
||||||
|
nodes = {}
|
||||||
|
events = []
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *_args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def reply(self, status, payload):
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(payload).encode())
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
url = urlsplit(self.path)
|
||||||
|
query = parse_qs(url.query)
|
||||||
|
if url.path.endswith("/changes"):
|
||||||
|
after = query.get("after", ["head"])[0]
|
||||||
|
start = len(events) if after == "head" else int(after[1:])
|
||||||
|
return self.reply(200, {"items": [], "next_cursor": f"c{start}", "has_more": False})
|
||||||
|
if url.path.endswith("/nodes"):
|
||||||
|
rows = sorted((copy.deepcopy(n) for n in nodes.values()),
|
||||||
|
key=lambda n: (n["updated_at"], n["id"]), reverse=True)
|
||||||
|
return self.reply(200, {"items": rows, "next_cursor": None})
|
||||||
|
node_id = url.path.rsplit("/", 1)[-1]
|
||||||
|
if node_id not in nodes:
|
||||||
|
return self.reply(404, {"error": {"code": "not_found", "message": "missing"}})
|
||||||
|
return self.reply(200, copy.deepcopy(nodes[node_id]))
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "all")
|
||||||
|
mod = load_module("napseer_effective_types_local_test")
|
||||||
|
mod.AUTH_DIR = tmp_path / "project-state"
|
||||||
|
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
||||||
|
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.lock"
|
||||||
|
mod.BASE_URL = f"http://127.0.0.1:{server.server_port}"
|
||||||
|
mod.TOKEN = "disposable-test-token"
|
||||||
|
mod.AUTH = {"account_id": "test-account"}
|
||||||
|
mod.DEFAULT_PROJECT_ID = "project-1"
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = {"all"}
|
||||||
|
mod.refresh_public_auth_state = lambda: None
|
||||||
|
mod.require_unlocked = lambda _name: None
|
||||||
|
mod.resolve_project_id = lambda args: args.get("project_id", "project-1")
|
||||||
|
mod.project_encryption_state = lambda _project_id: "standard"
|
||||||
|
|
||||||
|
def seed(*items):
|
||||||
|
for item in items:
|
||||||
|
nodes[item["id"]] = copy.deepcopy(item)
|
||||||
|
events.append(item["id"])
|
||||||
|
assert mod.reindex_project({})["graph_complete"] is True
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield mod, seed
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
TYPED_FIXTURE = [
|
||||||
|
stored_node("/plans", "legacy-plan", "note", minute=1),
|
||||||
|
stored_node("/plans/2026", "nested-plan", "note", minute=2),
|
||||||
|
stored_node("/plans", "typed-plan", "plan", minute=3),
|
||||||
|
stored_node("/notes", "scratch", "note", minute=4),
|
||||||
|
stored_node("/implementation-notes", "dashed-note", "implementation-note", minute=5),
|
||||||
|
stored_node("/implementation-notes", "generic-note", "note", minute=6),
|
||||||
|
stored_node("/services", "misplaced-plan", "Plan", minute=7),
|
||||||
|
stored_node("/services", "custom", "security-documentation", minute=8),
|
||||||
|
# A differently spelled generic note still resolves from its folder.
|
||||||
|
stored_node("/decisions", "capital-note", "Note", minute=9),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def discover(mod, **args):
|
||||||
|
return mod.call_tool_impl("nap_discover", {"mode": "local", "limit": 50, **args})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("q", [None, "vocabularyterm"])
|
||||||
|
def test_local_type_filter_matches_effective_types(local, q):
|
||||||
|
mod, seed = local
|
||||||
|
seed(*TYPED_FIXTURE)
|
||||||
|
extra = {"q": q} if q else {}
|
||||||
|
|
||||||
|
plans = discover(mod, type="plan", **extra)
|
||||||
|
by_id = {item["node_id"]: item for item in plans["items"]}
|
||||||
|
assert set(by_id) == {"legacy-plan", "nested-plan", "typed-plan", "misplaced-plan"}
|
||||||
|
# The stored type is reported unchanged; effective_type is added when it differs.
|
||||||
|
assert (by_id["legacy-plan"]["type"], by_id["legacy-plan"]["effective_type"]) == ("note", "plan")
|
||||||
|
assert (by_id["nested-plan"]["type"], by_id["nested-plan"]["effective_type"]) == ("note", "plan")
|
||||||
|
assert (by_id["misplaced-plan"]["type"], by_id["misplaced-plan"]["effective_type"]) == ("Plan", "plan")
|
||||||
|
assert by_id["typed-plan"]["type"] == "plan"
|
||||||
|
assert "effective_type" not in by_id["typed-plan"]
|
||||||
|
|
||||||
|
notes = discover(mod, type="implementation_note", **extra)
|
||||||
|
by_id = {item["node_id"]: item for item in notes["items"]}
|
||||||
|
assert set(by_id) == {"dashed-note", "generic-note"}
|
||||||
|
assert by_id["dashed-note"]["type"] == "implementation-note"
|
||||||
|
assert {item["effective_type"] for item in notes["items"]} == {"implementation_note"}
|
||||||
|
|
||||||
|
# A spelling variant in the filter canonicalizes the same way.
|
||||||
|
dashed = discover(mod, type="implementation-note", **extra)
|
||||||
|
assert {item["node_id"] for item in dashed["items"]} == {"dashed-note", "generic-note"}
|
||||||
|
|
||||||
|
decisions = discover(mod, type="decision", **extra)
|
||||||
|
assert [item["node_id"] for item in decisions["items"]] == ["capital-note"]
|
||||||
|
assert (decisions["items"][0]["type"], decisions["items"][0]["effective_type"]) == ("Note", "decision")
|
||||||
|
|
||||||
|
custom = discover(mod, type="security_documentation", **extra)
|
||||||
|
assert [item["node_id"] for item in custom["items"]] == ["custom"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("q", [None, "vocabularyterm"])
|
||||||
|
@pytest.mark.parametrize("requested", ["note", "Note", " NOTE "])
|
||||||
|
def test_local_note_filter_is_effective_type_only(local, q, requested):
|
||||||
|
mod, seed = local
|
||||||
|
seed(*TYPED_FIXTURE)
|
||||||
|
extra = {"q": q} if q else {}
|
||||||
|
# Only the note under /notes is a note; notes under /plans, /decisions and
|
||||||
|
# /implementation-notes take their folder's type and are excluded.
|
||||||
|
notes = discover(mod, type=requested, **extra)
|
||||||
|
assert [item["node_id"] for item in notes["items"]] == ["scratch"]
|
||||||
|
assert "effective_type" not in notes["items"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiltered_local_rows_carry_effective_type_and_facets(local):
|
||||||
|
mod, seed = local
|
||||||
|
seed(*TYPED_FIXTURE)
|
||||||
|
result = discover(mod, view="paths", facets=["type", "effective_type"])
|
||||||
|
by_id = {item["node_id"]: item for item in result["items"]}
|
||||||
|
assert by_id["legacy-plan"]["type"] == "note"
|
||||||
|
assert by_id["legacy-plan"]["effective_type"] == "plan"
|
||||||
|
assert "effective_type" not in by_id["scratch"]
|
||||||
|
assert by_id["custom"]["effective_type"] == "security_documentation"
|
||||||
|
counts = result["facets"]["counts"]
|
||||||
|
# The stored-type facet keeps its historical meaning.
|
||||||
|
assert {"value": "note", "count": 4} in counts["type"]
|
||||||
|
assert {"value": "plan", "count": 4} in counts["effective_type"]
|
||||||
|
assert {"value": "implementation_note", "count": 2} in counts["effective_type"]
|
||||||
|
|
||||||
|
|
||||||
|
RELEASE_TERMINAL = ["deployed", "released", "released_verified_production", "folded"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("q", [None, "vocabularyterm"])
|
||||||
|
def test_local_active_only_excludes_release_terminal_statuses(local, q):
|
||||||
|
mod, seed = local
|
||||||
|
seed(
|
||||||
|
stored_node("/deployments", "live", "note", minute=1, status="active"),
|
||||||
|
*[
|
||||||
|
stored_node("/deployments", f"release-{status}", "note", minute=2 + index, status=status)
|
||||||
|
for index, status in enumerate(RELEASE_TERMINAL)
|
||||||
|
],
|
||||||
|
stored_node("/plans", "finished", "note", minute=9, status="completed"),
|
||||||
|
)
|
||||||
|
extra = {"q": q} if q else {}
|
||||||
|
active = discover(mod, active_only=True, **extra)
|
||||||
|
assert [item["node_id"] for item in active["items"]] == ["live"]
|
||||||
|
# Without active_only the release records remain discoverable.
|
||||||
|
everything = discover(mod, **extra)
|
||||||
|
assert {f"release-{status}" for status in RELEASE_TERMINAL} <= {
|
||||||
|
item["node_id"] for item in everything["items"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_rows_are_post_filtered_on_effective_type(local):
|
||||||
|
mod, _seed = local
|
||||||
|
# An older server matches stored types only, so type=note returns every
|
||||||
|
# stored note; the wrapper keeps just the effective notes.
|
||||||
|
rows = [
|
||||||
|
{"id": "under-plans", "full_path": "/plans/legacy", "folder_path": "/plans", "type": "note"},
|
||||||
|
{"id": "under-decisions", "full_path": "/decisions/choice", "type": "Note"},
|
||||||
|
{"id": "under-notes", "full_path": "/notes/scratch", "folder_path": "/notes", "type": "note"},
|
||||||
|
]
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def fake_request(method, path, payload=None, **kwargs):
|
||||||
|
requests.append(path)
|
||||||
|
return {"items": copy.deepcopy(rows), "next_cursor": None}
|
||||||
|
|
||||||
|
mod.request_json = fake_request
|
||||||
|
notes = mod.list_project_nodes({"type": "note", "view": "paths"})
|
||||||
|
assert "type=note" in requests[-1]
|
||||||
|
assert [item["node_id"] for item in notes["items"]] == ["under-notes"]
|
||||||
|
|
||||||
|
rows[:] = [{"id": "typed", "full_path": "/plans/typed", "type": "plan"},
|
||||||
|
{"id": "legacy", "full_path": "/plans/legacy", "type": "note"}]
|
||||||
|
plans = mod.list_project_nodes({"type": "plan", "view": "paths"})
|
||||||
|
assert [item["node_id"] for item in plans["items"]] == ["typed", "legacy"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_only_filter_on_server_rows_excludes_release_statuses(local):
|
||||||
|
mod, _seed = local
|
||||||
|
rows = [{"id": status, "metadata": {"status": status}} for status in [*RELEASE_TERMINAL, "active", "done"]]
|
||||||
|
filters = mod.status_filter_from_args({"active_only": True})
|
||||||
|
# "done" keeps its existing CLI handling (already excluded).
|
||||||
|
assert [row["id"] for row in mod.apply_status_filters(rows, filters)] == ["active"]
|
||||||
|
explicit = mod.status_filter_from_args({"status": "deployed"})
|
||||||
|
assert explicit["warnings"] == []
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
"""Encrypted local E2E index shard against a disposable HTTP provider.
|
||||||
|
|
||||||
|
E2E nodes are encrypted with a synthetic memory secret using the wrapper's
|
||||||
|
own envelope code. The real reindex, feed sync, search and shard persistence
|
||||||
|
code run; only the key cache is seeded (standing in for an unlocked vault).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py"
|
||||||
|
PROJECT = "project-1"
|
||||||
|
# Synthetic fixture strings; none may appear in any file in the state dir.
|
||||||
|
BODY_TERM = "zephyrquartz"
|
||||||
|
BODY = f"calibrate the {BODY_TERM} manifold before launch"
|
||||||
|
META_VALUE = "obsidianfalcon"
|
||||||
|
PLAINTEXT_MARKERS = (BODY_TERM.encode(), META_VALUE.encode(), b"manifold before launch", b"brandnewsecret")
|
||||||
|
|
||||||
|
|
||||||
|
def memory_secret(version=3, secret_bytes=bytes(range(32))):
|
||||||
|
return {
|
||||||
|
"project_id": PROJECT, "secret_kind": "memory", "version": version,
|
||||||
|
"wrapping_epoch": 1, "bundle_version": version, "data_key_epoch": version,
|
||||||
|
"secret_bytes": secret_bytes, "created_at": "2026-09-01T00:00:00Z", "last_rotated_at": "2026-09-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cache_secret(mod, secret, active=True):
|
||||||
|
slot = (PROJECT, "memory", secret["version"], secret["wrapping_epoch"], secret["bundle_version"], secret["data_key_epoch"])
|
||||||
|
mod.MEMORY_SECRET_CACHE[slot] = secret
|
||||||
|
if active:
|
||||||
|
mod.ACTIVE_MEMORY_SECRET_VERSIONS[PROJECT] = slot
|
||||||
|
|
||||||
|
|
||||||
|
def lock(mod):
|
||||||
|
mod.e2e_shards_close(flush=True)
|
||||||
|
mod.MEMORY_SECRET_CACHE.clear()
|
||||||
|
mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear()
|
||||||
|
mod.PROJECT_VAULT_PASSPHRASE = None
|
||||||
|
|
||||||
|
|
||||||
|
def unlock(mod, secret=None):
|
||||||
|
cache_secret(mod, secret or memory_secret())
|
||||||
|
|
||||||
|
|
||||||
|
def plain_node(name, minute, content=None, folder="/notes"):
|
||||||
|
stamp = f"2026-09-01T12:{minute:02d}:00Z"
|
||||||
|
return {
|
||||||
|
"id": name, "project_id": PROJECT, "full_path": f"{folder}/{name}", "folder_path": folder,
|
||||||
|
"name": name, "type": "note", "metadata": {}, "tags": [], "aliases": [], "links": [],
|
||||||
|
"content_text": content if content is not None else name, "updated_at": stamp, "archived_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def e2e_node(mod, name, minute, content, metadata=None, secret=None, archived=False, folder="/notes"):
|
||||||
|
secret = secret or memory_secret()
|
||||||
|
node = plain_node(name, minute, folder=folder)
|
||||||
|
context = {"full_path": node["full_path"]}
|
||||||
|
node.update({
|
||||||
|
"content_text": "", "metadata": {}, "encryption_state": "encrypted",
|
||||||
|
"project_secret_version": secret["version"],
|
||||||
|
"encrypted_content_envelope": mod.encrypt_memory_bytes(
|
||||||
|
PROJECT, secret, "node_content", content.encode(), context, "text/plain; charset=utf-8"),
|
||||||
|
"encrypted_metadata_envelope": mod.encrypt_memory_bytes(
|
||||||
|
PROJECT, secret, "node_metadata", mod.stable_json_bytes(metadata or {}), context,
|
||||||
|
"application/json; charset=utf-8") if metadata else None,
|
||||||
|
})
|
||||||
|
if archived:
|
||||||
|
node["archived_at"] = node["updated_at"]
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
class Provider:
|
||||||
|
def __init__(self):
|
||||||
|
self.nodes = {}
|
||||||
|
self.events = []
|
||||||
|
self.requests = []
|
||||||
|
|
||||||
|
def put(self, node, kind):
|
||||||
|
self.nodes[node["id"]] = copy.deepcopy(node)
|
||||||
|
self.events.append({"type": kind, "node_id": node["id"], "full_path": node["full_path"],
|
||||||
|
"new_revision": node["updated_at"]})
|
||||||
|
|
||||||
|
def delete(self, node_id):
|
||||||
|
node = self.nodes.pop(node_id)
|
||||||
|
self.events.append({"type": "node_deleted", "node_id": node_id, "full_path": node["full_path"],
|
||||||
|
"new_revision": None})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env(tmp_path, monkeypatch):
|
||||||
|
provider = Provider()
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *_args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def reply(self, status, payload):
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(payload).encode())
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
url = urlsplit(self.path)
|
||||||
|
query = parse_qs(url.query)
|
||||||
|
kind = "changes" if url.path.endswith("/changes") else "list" if url.path.endswith("/nodes") else "get"
|
||||||
|
provider.requests.append((kind, url.path))
|
||||||
|
if "/vault/secrets/" in url.path:
|
||||||
|
return self.reply(503, {"error": {"code": "test_failure", "message": "no vault in test"}})
|
||||||
|
if kind == "changes":
|
||||||
|
after = query.get("after", ["head"])[0]
|
||||||
|
start = len(provider.events) if after == "head" else int(re.fullmatch(r"c(\d+)", after).group(1))
|
||||||
|
limit = int(query.get("limit", ["100"])[0])
|
||||||
|
page = provider.events[start:start + limit]
|
||||||
|
items = [{**event, "cursor": f"c{start + i + 1}", "project_id": PROJECT, "changed_fields": [],
|
||||||
|
"created_at": "2026-09-01T12:00:00Z"} for i, event in enumerate(page)]
|
||||||
|
end = start + len(page)
|
||||||
|
return self.reply(200, {"items": items, "next_cursor": f"c{end}", "has_more": end < len(provider.events)})
|
||||||
|
if kind == "list":
|
||||||
|
include_archived = query.get("include_archived", ["false"])[0] in {"true", "True", "1"}
|
||||||
|
rows = [copy.deepcopy(n) for n in provider.nodes.values() if include_archived or not n["archived_at"]]
|
||||||
|
rows.sort(key=lambda n: (n["updated_at"], n["id"]), reverse=True)
|
||||||
|
return self.reply(200, {"items": rows, "next_cursor": None})
|
||||||
|
node_id = url.path.rsplit("/", 1)[-1]
|
||||||
|
if node_id not in provider.nodes:
|
||||||
|
return self.reply(404, {"error": {"code": "not_found", "message": "missing"}})
|
||||||
|
return self.reply(200, copy.deepcopy(provider.nodes[node_id]))
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
sqlite_tmp = tmp_path / "sqlite-tmp"
|
||||||
|
sqlite_tmp.mkdir()
|
||||||
|
monkeypatch.setenv("SQLITE_TMPDIR", str(sqlite_tmp))
|
||||||
|
monkeypatch.setenv("TMPDIR", str(sqlite_tmp))
|
||||||
|
for name in ("NAPSEER_VAULT_PASSPHRASE", "NAPSEER_MASTER_PASSPHRASE", "NAPSEER_CRYPTO_BACKEND"):
|
||||||
|
monkeypatch.delenv(name, raising=False)
|
||||||
|
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "all")
|
||||||
|
monkeypatch.syspath_prepend(str(SCRIPT.parent))
|
||||||
|
spec = importlib.util.spec_from_file_location("e2e_shard_test_worker", SCRIPT)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
mod.AUTH_DIR = tmp_path / "project-state"
|
||||||
|
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
||||||
|
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.lock"
|
||||||
|
mod.BASE_URL = f"http://127.0.0.1:{server.server_port}"
|
||||||
|
mod.TOKEN = "disposable-test-token"
|
||||||
|
mod.AUTH = {"account_id": "test-account"}
|
||||||
|
mod.DEFAULT_PROJECT_ID = PROJECT
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = {"all"}
|
||||||
|
mod.refresh_public_auth_state = lambda: None
|
||||||
|
mod.require_unlocked = lambda _name: None
|
||||||
|
mod.resolve_project_id = lambda args: args.get("project_id", PROJECT)
|
||||||
|
mod.project_encryption_state = lambda _project_id: "encrypted"
|
||||||
|
mod.MEMORY_SECRET_CACHE.clear()
|
||||||
|
mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear()
|
||||||
|
unlock(mod)
|
||||||
|
try:
|
||||||
|
yield mod, provider, sqlite_tmp
|
||||||
|
finally:
|
||||||
|
mod.e2e_shards_close(flush=False)
|
||||||
|
mod.select_crypto_backend()
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
def seed(mod, provider):
|
||||||
|
provider.put(e2e_node(mod, "secretnote", 1, BODY, metadata={"owner": META_VALUE}), "node_created")
|
||||||
|
provider.put(plain_node("plainnote", 2, "ordinary plain body text"), "node_created")
|
||||||
|
return mod.reindex_project({})
|
||||||
|
|
||||||
|
|
||||||
|
def local_search(mod, text):
|
||||||
|
return mod.search_local_index({"q": text, "limit": 10})
|
||||||
|
|
||||||
|
|
||||||
|
def hit_ids(result):
|
||||||
|
return [item["id"] for item in result["items"]]
|
||||||
|
|
||||||
|
|
||||||
|
def codes(result):
|
||||||
|
return [warning["code"] for warning in result.get("warnings") or []]
|
||||||
|
|
||||||
|
|
||||||
|
def shard_file(mod):
|
||||||
|
return mod.e2e_shard_path(PROJECT)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_no_plaintext_on_disk(mod, *extra_dirs):
|
||||||
|
for directory in (mod.AUTH_DIR, *extra_dirs):
|
||||||
|
for path in directory.rglob("*"):
|
||||||
|
if path.is_file():
|
||||||
|
raw = path.read_bytes()
|
||||||
|
for marker in PLAINTEXT_MARKERS:
|
||||||
|
assert marker not in raw, f"{marker!r} leaked into {path.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_body_term_is_findable_with_snippet_while_unlocked(env):
|
||||||
|
mod, provider, _ = env
|
||||||
|
result = seed(mod, provider)
|
||||||
|
assert result["e2e_index"]["status"] == "applied"
|
||||||
|
|
||||||
|
local = local_search(mod, BODY_TERM)
|
||||||
|
assert hit_ids(local) == ["secretnote"]
|
||||||
|
item = local["items"][0]
|
||||||
|
assert item["search_source"] == "local_e2e"
|
||||||
|
assert BODY_TERM in item["snippet"]
|
||||||
|
assert BODY_TERM in item["matched_terms"]
|
||||||
|
assert hit_ids(local_search(mod, META_VALUE)) == ["secretnote"]
|
||||||
|
# Non-E2E results are unchanged and not marked as shard hits.
|
||||||
|
plain = local_search(mod, "ordinary")
|
||||||
|
assert hit_ids(plain) == ["plainnote"] and "search_source" not in plain["items"][0]
|
||||||
|
|
||||||
|
discovered = mod.discover_memory({"q": BODY_TERM, "mode": "local"})
|
||||||
|
assert [item["node_id"] for item in discovered["items"]] == ["secretnote"]
|
||||||
|
assert discovered["items"][0]["search_source"] == "local_e2e"
|
||||||
|
assert "local_e2e" in discovered["sources"]
|
||||||
|
assert not discovered.get("warnings")
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_shard_hits_filter_on_effective_type(env):
|
||||||
|
mod, provider, _ = env
|
||||||
|
provider.put(e2e_node(mod, "legacyplan", 1, BODY, folder="/plans"), "node_created")
|
||||||
|
provider.put(e2e_node(mod, "scratchnote", 2, BODY), "node_created")
|
||||||
|
assert mod.reindex_project({})["e2e_index"]["status"] == "applied"
|
||||||
|
|
||||||
|
# Bodies are only in the shard, so these hits come from the E2E path.
|
||||||
|
plans = mod.search_local_index({"q": BODY_TERM, "limit": 10, "type": "plan"})
|
||||||
|
assert hit_ids(plans) == ["legacyplan"]
|
||||||
|
assert plans["items"][0]["search_source"] == "local_e2e"
|
||||||
|
assert (plans["items"][0]["type"], plans["items"][0]["effective_type"]) == ("note", "plan")
|
||||||
|
notes = mod.search_local_index({"q": BODY_TERM, "limit": 10, "type": "note"})
|
||||||
|
assert hit_ids(notes) == ["scratchnote"]
|
||||||
|
assert notes["items"][0]["search_source"] == "local_e2e"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unprovisioned_secret_yields_no_e2e_hits_and_an_explicit_warning(env):
|
||||||
|
mod, provider, _ = env
|
||||||
|
seed(mod, provider)
|
||||||
|
lock(mod)
|
||||||
|
|
||||||
|
local = local_search(mod, BODY_TERM)
|
||||||
|
assert hit_ids(local) == []
|
||||||
|
assert codes(local) == ["e2e_index_not_provisioned"]
|
||||||
|
warning = local["warnings"][0]
|
||||||
|
assert warning["memory_secret"] == "not_provisioned"
|
||||||
|
assert warning["next_action"] == "nap gateway vault setup"
|
||||||
|
response = mod.search_memory({"q": BODY_TERM, "mode": "local", "view": "summary"})
|
||||||
|
assert response["items"] == []
|
||||||
|
assert "e2e_index_not_provisioned" in codes(response)
|
||||||
|
# Identity fields of the E2E node remain searchable from the main index.
|
||||||
|
assert hit_ids(local_search(mod, "secretnote")) == ["secretnote"]
|
||||||
|
# An unprovisioned reindex never prompts and writes no shard.
|
||||||
|
shard_file(mod).unlink()
|
||||||
|
assert "e2e_index" in mod.reindex_project({})
|
||||||
|
assert not shard_file(mod).exists()
|
||||||
|
|
||||||
|
unlock(mod)
|
||||||
|
assert hit_ids(local_search(mod, BODY_TERM)) == [] # shard not rebuilt yet: reported, not guessed
|
||||||
|
assert "e2e_index_incomplete" in codes(local_search(mod, BODY_TERM))
|
||||||
|
synced = mod.sync_local_index({})
|
||||||
|
assert synced["e2e_index"]["status"] == "complete"
|
||||||
|
assert hit_ids(local_search(mod, BODY_TERM)) == ["secretnote"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_shard_file_holds_no_plaintext_and_no_side_files_exist(env):
|
||||||
|
mod, provider, sqlite_tmp = env
|
||||||
|
seed(mod, provider)
|
||||||
|
assert hit_ids(local_search(mod, BODY_TERM)) == ["secretnote"]
|
||||||
|
shard = mod.e2e_shard_loaded(PROJECT)
|
||||||
|
with shard.lock:
|
||||||
|
assert shard.conn.execute("PRAGMA journal_mode").fetchone()[0] == "memory"
|
||||||
|
assert shard.conn.execute("PRAGMA temp_store").fetchone()[0] == 2
|
||||||
|
databases = shard.conn.execute("PRAGMA database_list").fetchall()
|
||||||
|
assert [(row[1], row[2]) for row in databases] == [("main", "")]
|
||||||
|
mod.e2e_shards_close(flush=True)
|
||||||
|
|
||||||
|
path = shard_file(mod)
|
||||||
|
assert path.exists() and stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||||
|
assert path.read_bytes().startswith(mod.E2E_SHARD_MAGIC)
|
||||||
|
names = {entry.name for entry in mod.AUTH_DIR.iterdir()}
|
||||||
|
assert names <= {"index.sqlite", "index.sqlite-wal", "index.sqlite-shm", "index.lock", path.name}
|
||||||
|
assert not any(name.endswith(".tmp") or "journal" in name for name in names)
|
||||||
|
assert list(sqlite_tmp.iterdir()) == []
|
||||||
|
assert_no_plaintext_on_disk(mod, sqlite_tmp)
|
||||||
|
# The shard lives in the private state dir, never in the checkout.
|
||||||
|
assert path.parent == mod.AUTH_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def test_tampered_shard_fails_closed_rebuilds_and_reports(env):
|
||||||
|
mod, provider, _ = env
|
||||||
|
seed(mod, provider)
|
||||||
|
mod.e2e_shards_close(flush=True)
|
||||||
|
path = shard_file(mod)
|
||||||
|
blob = bytearray(path.read_bytes())
|
||||||
|
blob[-20] ^= 0x01
|
||||||
|
path.write_bytes(bytes(blob))
|
||||||
|
|
||||||
|
response = mod.search_memory({"q": BODY_TERM, "mode": "local", "view": "summary"})
|
||||||
|
|
||||||
|
discarded = [w for w in response["warnings"] if w["code"] == "e2e_index_discarded"]
|
||||||
|
assert discarded and discarded[0]["reason"] == "tampered"
|
||||||
|
assert [item["node_id"] for item in response["items"]] == ["secretnote"] # rebuilt from source
|
||||||
|
mod.e2e_shards_close(flush=True)
|
||||||
|
rebuilt = path.read_bytes()
|
||||||
|
assert rebuilt != bytes(blob)
|
||||||
|
# The rebuilt file loads cleanly in a fresh process state.
|
||||||
|
local = local_search(mod, BODY_TERM)
|
||||||
|
assert hit_ids(local) == ["secretnote"] and not local.get("warnings")
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncated_or_foreign_shard_file_is_discarded(env):
|
||||||
|
mod, provider, _ = env
|
||||||
|
seed(mod, provider)
|
||||||
|
mod.e2e_shards_close(flush=True)
|
||||||
|
shard_file(mod).write_bytes(b"not a shard")
|
||||||
|
result = mod.sync_local_index({})
|
||||||
|
assert [w["reason"] for w in result["e2e_index"]["warnings"]] == ["format"]
|
||||||
|
assert result["e2e_index"]["status"] == "complete"
|
||||||
|
assert hit_ids(local_search(mod, BODY_TERM)) == ["secretnote"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_key_fails_closed_and_reports_undecryptable_nodes(env):
|
||||||
|
mod, provider, _ = env
|
||||||
|
seed(mod, provider)
|
||||||
|
lock(mod)
|
||||||
|
unlock(mod, memory_secret(secret_bytes=bytes(range(1, 33))))
|
||||||
|
|
||||||
|
response = mod.search_memory({"q": BODY_TERM, "mode": "local", "view": "summary"})
|
||||||
|
|
||||||
|
assert response["items"] == []
|
||||||
|
by_code = {w["code"]: w for w in response["warnings"]}
|
||||||
|
assert by_code["e2e_index_discarded"]["reason"] == "wrong_key"
|
||||||
|
assert by_code["e2e_index_undecryptable"]["nodes"] == 1
|
||||||
|
assert_no_plaintext_on_disk(mod)
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_version_change_discards_and_rebuilds_under_the_new_key(env):
|
||||||
|
mod, provider, _ = env
|
||||||
|
seed(mod, provider)
|
||||||
|
assert hit_ids(local_search(mod, BODY_TERM)) == ["secretnote"]
|
||||||
|
|
||||||
|
# Rotation while loaded: the in-memory shard is re-keyed by discarding it.
|
||||||
|
cache_secret(mod, memory_secret(version=4, secret_bytes=bytes(range(100, 132))))
|
||||||
|
response = mod.search_memory({"q": BODY_TERM, "mode": "local", "view": "summary"})
|
||||||
|
reasons = [w.get("reason") for w in response["warnings"] if w["code"] == "e2e_index_discarded"]
|
||||||
|
assert reasons == ["key_rotated"]
|
||||||
|
assert [item["node_id"] for item in response["items"]] == ["secretnote"] # v3 node, v4 shard key
|
||||||
|
mod.e2e_shards_close(flush=True)
|
||||||
|
header = shard_file(mod).read_bytes()
|
||||||
|
assert b'"secret_version":4' in header
|
||||||
|
|
||||||
|
# Rotation between processes: a v4 file opened with a v5 active secret.
|
||||||
|
cache_secret(mod, memory_secret(version=5, secret_bytes=bytes(range(50, 82))))
|
||||||
|
result = mod.sync_local_index({})
|
||||||
|
assert [w["reason"] for w in result["e2e_index"]["warnings"]] == ["key_rotated"]
|
||||||
|
assert hit_ids(local_search(mod, BODY_TERM)) == ["secretnote"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_update_archive_and_delete_update_the_shard(env):
|
||||||
|
mod, provider, _ = env
|
||||||
|
seed(mod, provider)
|
||||||
|
provider.put(e2e_node(mod, "othernote", 3, "second brandnewsecret body"), "node_created")
|
||||||
|
mod.sync_local_index_from_feed(PROJECT)
|
||||||
|
assert hit_ids(local_search(mod, "brandnewsecret")) == ["othernote"]
|
||||||
|
|
||||||
|
provider.put(e2e_node(mod, "secretnote", 10, "rewritten body mentions heliotrope"), "node_updated")
|
||||||
|
result = mod.sync_local_index_from_feed(PROJECT)
|
||||||
|
assert result["e2e_index"]["indexed"] == 1
|
||||||
|
assert hit_ids(local_search(mod, "heliotrope")) == ["secretnote"]
|
||||||
|
assert hit_ids(local_search(mod, BODY_TERM)) == []
|
||||||
|
|
||||||
|
provider.delete("othernote")
|
||||||
|
provider.put(e2e_node(mod, "secretnote", 11, "archived text", archived=True), "node_archived")
|
||||||
|
mod.sync_local_index_from_feed(PROJECT)
|
||||||
|
assert hit_ids(local_search(mod, "brandnewsecret")) == []
|
||||||
|
assert hit_ids(local_search(mod, "heliotrope")) == []
|
||||||
|
shard = mod.e2e_shard_loaded(PROJECT)
|
||||||
|
with shard.lock:
|
||||||
|
assert shard.conn.execute("SELECT COUNT(*) FROM e2e_nodes").fetchone()[0] == 0
|
||||||
|
# A reloaded shard reflects the removals as well.
|
||||||
|
mod.e2e_shards_close(flush=True)
|
||||||
|
assert hit_ids(local_search(mod, "brandnewsecret")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_write_path_indexes_and_removes_shard_text(env):
|
||||||
|
mod, provider, _ = env
|
||||||
|
seed(mod, provider)
|
||||||
|
written = e2e_node(mod, "writtennote", 20, "locally written umbraquill text")
|
||||||
|
mod.index_node(written)
|
||||||
|
assert hit_ids(local_search(mod, "umbraquill")) == ["writtennote"]
|
||||||
|
mod.remove_indexed_node("writtennote")
|
||||||
|
assert hit_ids(local_search(mod, "umbraquill")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_shard_row_is_not_served_and_is_refetched(env):
|
||||||
|
mod, provider, _ = env
|
||||||
|
seed(mod, provider)
|
||||||
|
mod.e2e_shards_close(flush=True)
|
||||||
|
# Another process advanced the main index without this shard file.
|
||||||
|
updated = e2e_node(mod, "secretnote", 30, "fresh body with vermilionkite")
|
||||||
|
provider.put(updated, "node_updated")
|
||||||
|
with mod.index_connect() as conn:
|
||||||
|
mod.index_node(updated, connection=conn)
|
||||||
|
local = local_search(mod, BODY_TERM)
|
||||||
|
assert hit_ids(local) == [] and "e2e_index_incomplete" in codes(local)
|
||||||
|
mod.sync_local_index({})
|
||||||
|
assert hit_ids(local_search(mod, "vermilionkite")) == ["secretnote"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("writer,reader", [("native", "pure_python"), ("pure_python", "native")])
|
||||||
|
def test_crypto_backends_read_each_others_shard(env, writer, reader):
|
||||||
|
mod, provider, _ = env
|
||||||
|
if not mod.native_crypto_available():
|
||||||
|
pytest.skip("cryptography is not installed")
|
||||||
|
mod.select_crypto_backend(writer)
|
||||||
|
seed(mod, provider)
|
||||||
|
mod.e2e_shards_close(flush=True)
|
||||||
|
mod.select_crypto_backend(reader)
|
||||||
|
local = local_search(mod, BODY_TERM)
|
||||||
|
assert hit_ids(local) == ["secretnote"] and not local.get("warnings")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pure_backend_large_shard_loads_in_background_and_warns(env, monkeypatch):
|
||||||
|
mod, provider, _ = env
|
||||||
|
seed(mod, provider)
|
||||||
|
mod.e2e_shards_close(flush=True)
|
||||||
|
mod.select_crypto_backend("pure_python")
|
||||||
|
monkeypatch.setattr(mod, "E2E_SHARD_PURE_SYNC_LOAD_MAX_BYTES", 1)
|
||||||
|
|
||||||
|
first = local_search(mod, BODY_TERM)
|
||||||
|
assert hit_ids(first) == []
|
||||||
|
slow = [w for w in first["warnings"] if w["code"] == "e2e_index_slow_crypto"]
|
||||||
|
assert slow and slow[0]["state"] == "loading"
|
||||||
|
loader = mod.e2e_shard_get(PROJECT).loading
|
||||||
|
if loader is not None:
|
||||||
|
loader.join(timeout=30)
|
||||||
|
assert hit_ids(local_search(mod, BODY_TERM)) == ["secretnote"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pure_backend_does_not_persist_oversized_shard(env, monkeypatch):
|
||||||
|
mod, provider, _ = env
|
||||||
|
mod.select_crypto_backend("pure_python")
|
||||||
|
monkeypatch.setattr(mod, "E2E_SHARD_PURE_MAX_PERSIST_BYTES", 1)
|
||||||
|
result = seed(mod, provider)
|
||||||
|
assert result["e2e_index"]["persist"] == "skipped_slow_crypto"
|
||||||
|
assert not shard_file(mod).exists()
|
||||||
|
local = local_search(mod, BODY_TERM)
|
||||||
|
assert hit_ids(local) == ["secretnote"]
|
||||||
|
assert [w.get("state") for w in local["warnings"]] == ["not_persisted"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_sqlite_disables_e2e_search_with_reason(env, monkeypatch):
|
||||||
|
mod, provider, _ = env
|
||||||
|
monkeypatch.setattr(mod, "e2e_shard_supported", lambda: (False, "sqlite_serialize_unavailable"))
|
||||||
|
seed(mod, provider)
|
||||||
|
local = local_search(mod, BODY_TERM)
|
||||||
|
assert hit_ids(local) == []
|
||||||
|
assert codes(local) == ["e2e_index_unsupported"]
|
||||||
|
assert not shard_file(mod).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_without_e2e_nodes_creates_no_shard_or_warnings(env):
|
||||||
|
mod, provider, _ = env
|
||||||
|
provider.put(plain_node("plainnote", 2, "ordinary plain body text"), "node_created")
|
||||||
|
result = mod.reindex_project({})
|
||||||
|
assert "e2e_index" not in result
|
||||||
|
local = local_search(mod, "ordinary")
|
||||||
|
assert hit_ids(local) == ["plainnote"] and "warnings" not in local
|
||||||
|
assert not shard_file(mod).exists()
|
||||||
|
assert mod.index_status({})["e2e_index"]["loaded"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_shard_key_is_bound_to_project_and_secret_identity(env):
|
||||||
|
mod, _provider, _ = env
|
||||||
|
secret = memory_secret()
|
||||||
|
identity, key, key_check = mod.e2e_shard_key_material(PROJECT, secret)
|
||||||
|
other_project = mod.e2e_shard_key_material("project-2", secret)
|
||||||
|
other_version = mod.e2e_shard_key_material(PROJECT, {**secret, "data_key_epoch": 4})
|
||||||
|
assert len(key) == 32 and key not in {other_project[1], other_version[1]}
|
||||||
|
blob = mod.e2e_shard_encode(PROJECT, identity, key, key_check, b"synthetic")
|
||||||
|
assert mod.e2e_shard_decode(blob, PROJECT, identity, key, key_check) == b"synthetic"
|
||||||
|
assert blob != mod.e2e_shard_encode(PROJECT, identity, key, key_check, b"synthetic") # fresh nonce
|
||||||
|
with pytest.raises(mod.E2EShardFileInvalid) as info:
|
||||||
|
mod.e2e_shard_decode(blob, "project-2", identity, key, key_check)
|
||||||
|
assert info.value.reason == "project_mismatch"
|
||||||
|
# Rewriting the header (AAD) without the key is detected.
|
||||||
|
forged = blob.replace(b'"index_schema":2', b'"index_schema":2 ')
|
||||||
|
assert forged != blob
|
||||||
|
with pytest.raises(mod.E2EShardFileInvalid):
|
||||||
|
mod.e2e_shard_decode(forged, PROJECT, identity, key, key_check)
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
"""Change-feed driven local index freshness against a disposable HTTP provider.
|
||||||
|
|
||||||
|
The provider implements the node list, single-node read and change-feed
|
||||||
|
routes with synthetic data. The real request, index and search code run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py"
|
||||||
|
|
||||||
|
|
||||||
|
def make_node(name, minute, *, content=None, archived=False):
|
||||||
|
stamp = f"2026-09-01T12:{minute:02d}:00Z"
|
||||||
|
return {
|
||||||
|
"id": name, "project_id": "project-1", "full_path": f"/notes/{name}",
|
||||||
|
"folder_path": "/notes", "name": name, "type": "note",
|
||||||
|
"metadata": {}, "tags": [], "aliases": [], "links": [],
|
||||||
|
"content_text": content if content is not None else name,
|
||||||
|
"updated_at": stamp, "archived_at": stamp if archived else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Provider:
|
||||||
|
def __init__(self):
|
||||||
|
self.nodes = {}
|
||||||
|
self.events = []
|
||||||
|
self.requests = []
|
||||||
|
self.fail = {} # route kind -> HTTP status
|
||||||
|
self.on_list = None
|
||||||
|
|
||||||
|
def put(self, node, kind):
|
||||||
|
self.nodes[node["id"]] = copy.deepcopy(node)
|
||||||
|
self.events.append({"type": kind, "node_id": node["id"], "full_path": node["full_path"],
|
||||||
|
"new_revision": node["updated_at"]})
|
||||||
|
|
||||||
|
def delete(self, node_id):
|
||||||
|
node = self.nodes.pop(node_id)
|
||||||
|
self.events.append({"type": "node_deleted", "node_id": node_id, "full_path": node["full_path"],
|
||||||
|
"new_revision": None})
|
||||||
|
|
||||||
|
def count(self, kind):
|
||||||
|
return sum(1 for item in self.requests if item[0] == kind)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def feed(tmp_path, monkeypatch):
|
||||||
|
provider = Provider()
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *_args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def reply(self, status, payload):
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(payload).encode())
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
url = urlsplit(self.path)
|
||||||
|
query = parse_qs(url.query)
|
||||||
|
if url.path.endswith("/changes"):
|
||||||
|
kind = "changes"
|
||||||
|
elif url.path.endswith("/nodes"):
|
||||||
|
kind = "list"
|
||||||
|
else:
|
||||||
|
kind = "get"
|
||||||
|
provider.requests.append((kind, url.path, query))
|
||||||
|
if kind in provider.fail:
|
||||||
|
status = provider.fail[kind]
|
||||||
|
return self.reply(status, {"error": {"code": "test_failure", "message": "injected"}})
|
||||||
|
if kind == "changes":
|
||||||
|
after = query.get("after", ["head"])[0]
|
||||||
|
if after == "head":
|
||||||
|
start = len(provider.events)
|
||||||
|
elif re.fullmatch(r"c\d+", after):
|
||||||
|
start = int(after[1:])
|
||||||
|
else:
|
||||||
|
return self.reply(400, {"error": {"code": "invalid_cursor", "message": "bad cursor"}})
|
||||||
|
limit = int(query.get("limit", ["100"])[0])
|
||||||
|
page = provider.events[start:start + limit]
|
||||||
|
items = [{**event, "cursor": f"c{start + index + 1}", "project_id": "project-1",
|
||||||
|
"changed_fields": [], "created_at": "2026-09-01T12:00:00Z"}
|
||||||
|
for index, event in enumerate(page)]
|
||||||
|
end = start + len(page)
|
||||||
|
return self.reply(200, {"items": items, "next_cursor": f"c{end}",
|
||||||
|
"has_more": end < len(provider.events)})
|
||||||
|
if kind == "list":
|
||||||
|
if provider.on_list is not None:
|
||||||
|
hook, provider.on_list = provider.on_list, None
|
||||||
|
hook()
|
||||||
|
rows = [copy.deepcopy(n) for n in provider.nodes.values() if not n["archived_at"]]
|
||||||
|
rows.sort(key=lambda n: (n["updated_at"], n["id"]), reverse=True)
|
||||||
|
after = query.get("updated_after", [""])[0]
|
||||||
|
if after:
|
||||||
|
rows = [n for n in provider.nodes.values() if n["updated_at"] > after]
|
||||||
|
return self.reply(200, {"items": rows, "next_cursor": None})
|
||||||
|
node_id = url.path.rsplit("/", 1)[-1]
|
||||||
|
if node_id not in provider.nodes:
|
||||||
|
return self.reply(404, {"error": {"code": "not_found", "message": "missing"}})
|
||||||
|
return self.reply(200, copy.deepcopy(provider.nodes[node_id]))
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "all")
|
||||||
|
monkeypatch.syspath_prepend(str(SCRIPT.parent))
|
||||||
|
spec = importlib.util.spec_from_file_location("feed_sync_test_worker", SCRIPT)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
mod.AUTH_DIR = tmp_path / "project-state"
|
||||||
|
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
||||||
|
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.lock"
|
||||||
|
mod.BASE_URL = f"http://127.0.0.1:{server.server_port}"
|
||||||
|
mod.TOKEN = "disposable-test-token"
|
||||||
|
mod.AUTH = {"account_id": "test-account"}
|
||||||
|
mod.DEFAULT_PROJECT_ID = "project-1"
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = {"all"}
|
||||||
|
mod.refresh_public_auth_state = lambda: None
|
||||||
|
mod.require_unlocked = lambda _name: None
|
||||||
|
mod.resolve_project_id = lambda args: args.get("project_id", "project-1")
|
||||||
|
mod.project_encryption_state = lambda _project_id: "standard"
|
||||||
|
try:
|
||||||
|
yield mod, provider
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
def local_ids(mod, text):
|
||||||
|
return [item["id"] for item in mod.search_local_index({"q": text, "limit": 10})["items"]]
|
||||||
|
|
||||||
|
|
||||||
|
def seed(mod, provider, *names):
|
||||||
|
for index, name in enumerate(names):
|
||||||
|
provider.put(make_node(name, index), "node_created")
|
||||||
|
result = mod.reindex_project({})
|
||||||
|
assert result["graph_complete"] is True
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def test_reindex_takes_head_cursor_before_listing_and_replays_idempotently(feed):
|
||||||
|
mod, provider = feed
|
||||||
|
provider.put(make_node("alpha", 1), "node_created")
|
||||||
|
|
||||||
|
def concurrent_write():
|
||||||
|
provider.put(make_node("alpha", 2, content="alpha concurrent revision"), "node_updated")
|
||||||
|
|
||||||
|
provider.on_list = concurrent_write
|
||||||
|
mod.reindex_project({})
|
||||||
|
|
||||||
|
kinds = [item[0] for item in provider.requests]
|
||||||
|
assert kinds[:2] == ["changes", "list"]
|
||||||
|
assert provider.requests[0][2]["after"] == ["head"]
|
||||||
|
assert mod.local_index_feed_state("project-1")["feed_cursor"] == "c1"
|
||||||
|
assert local_ids(mod, "concurrent") == ["alpha"]
|
||||||
|
|
||||||
|
result = mod.sync_local_index_from_feed("project-1")
|
||||||
|
|
||||||
|
assert (result["events"], result["skipped_current"], result["indexed"]) == (1, 1, 0)
|
||||||
|
assert provider.count("get") == 0 # revision already indexed: no refetch
|
||||||
|
assert mod.local_index_feed_state("project-1")["feed_cursor"] == "c2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_applies_create_update_archive_restore_and_delete(feed):
|
||||||
|
mod, provider = feed
|
||||||
|
seed(mod, provider, "keepme", "archiveme", "deleteme", "restoreme")
|
||||||
|
provider.put(make_node("restoreme", 5, archived=True), "node_archived")
|
||||||
|
provider.put(make_node("freshnode", 10, content="brandnewterm"), "node_created")
|
||||||
|
provider.put(make_node("keepme", 11, content="keepme revisedterm"), "node_updated")
|
||||||
|
provider.put(make_node("archiveme", 12, archived=True), "node_archived")
|
||||||
|
provider.delete("deleteme")
|
||||||
|
provider.put(make_node("restoreme", 13), "node_restored")
|
||||||
|
|
||||||
|
result = mod.sync_local_index_from_feed("project-1")
|
||||||
|
|
||||||
|
assert result["status"] == "synced"
|
||||||
|
assert local_ids(mod, "brandnewterm") == ["freshnode"]
|
||||||
|
assert local_ids(mod, "revisedterm") == ["keepme"]
|
||||||
|
assert local_ids(mod, "archiveme") == []
|
||||||
|
assert local_ids(mod, "deleteme") == []
|
||||||
|
assert local_ids(mod, "restoreme") == ["restoreme"]
|
||||||
|
with mod.index_connect() as conn:
|
||||||
|
ids = {row[0] for row in conn.execute("SELECT node_id FROM local_index_nodes")}
|
||||||
|
# Archived rows match a full rebuild, which lists only active nodes.
|
||||||
|
assert ids == {"keepme", "freshnode", "restoreme"}
|
||||||
|
assert mod.local_index_feed_state("project-1")["feed_cursor"] == f"c{len(provider.events)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_failure_never_advances_cursor_and_retry_recovers(feed):
|
||||||
|
mod, provider = feed
|
||||||
|
seed(mod, provider, "stable")
|
||||||
|
before = mod.local_index_feed_state("project-1")["feed_cursor"]
|
||||||
|
provider.put(make_node("newcomer", 10, content="latecomerterm"), "node_created")
|
||||||
|
provider.fail["get"] = 503
|
||||||
|
|
||||||
|
with pytest.raises(mod.SafeToolError):
|
||||||
|
mod.sync_local_index_from_feed("project-1")
|
||||||
|
|
||||||
|
assert mod.local_index_feed_state("project-1")["feed_cursor"] == before
|
||||||
|
assert local_ids(mod, "latecomerterm") == []
|
||||||
|
del provider.fail["get"]
|
||||||
|
mod.sync_local_index_from_feed("project-1")
|
||||||
|
assert local_ids(mod, "latecomerterm") == ["newcomer"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_feed_cursor_triggers_full_reindex(feed):
|
||||||
|
mod, provider = feed
|
||||||
|
seed(mod, provider, "stable")
|
||||||
|
with mod.index_connect() as conn:
|
||||||
|
conn.execute("UPDATE local_index_state SET feed_cursor = 'expired-cursor'")
|
||||||
|
provider.nodes["hardgone"] = make_node("hardgone", 3) # present only in the full listing
|
||||||
|
|
||||||
|
result = mod.sync_local_index({})
|
||||||
|
|
||||||
|
assert result["mode"] == "full_reindex"
|
||||||
|
assert result["reason"] == "feed_cursor_invalid"
|
||||||
|
assert local_ids(mod, "hardgone") == ["hardgone"]
|
||||||
|
assert mod.local_index_feed_state("project-1")["feed_cursor"] == f"c{len(provider.events)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_server_without_feed_falls_back_to_timestamp_sync(feed):
|
||||||
|
mod, provider = feed
|
||||||
|
seed(mod, provider, "stable")
|
||||||
|
provider.fail["changes"] = 404
|
||||||
|
provider.nodes["later"] = make_node("later", 30, content="timestampterm")
|
||||||
|
|
||||||
|
result = mod.sync_local_index({})
|
||||||
|
|
||||||
|
assert result["mode"] == "incremental"
|
||||||
|
assert result["feed"] == "unsupported"
|
||||||
|
assert local_ids(mod, "timestampterm") == ["later"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reindex_on_old_server_without_feed_still_rebuilds(feed):
|
||||||
|
mod, provider = feed
|
||||||
|
provider.fail["changes"] = 404
|
||||||
|
provider.put(make_node("legacy", 1), "node_created")
|
||||||
|
|
||||||
|
result = mod.reindex_project({})
|
||||||
|
|
||||||
|
assert result["graph_complete"] is True
|
||||||
|
assert local_ids(mod, "legacy") == ["legacy"]
|
||||||
|
assert mod.local_index_feed_state("project-1")["feed_cursor"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_auto_syncs_from_feed_and_throttles(feed):
|
||||||
|
mod, provider = feed
|
||||||
|
seed(mod, provider, "stable")
|
||||||
|
provider.put(make_node("autosync", 10, content="autosyncterm"), "node_created")
|
||||||
|
|
||||||
|
first = mod.call_tool_impl("nap_discover", {"q": "autosyncterm", "mode": "local"})
|
||||||
|
provider.put(make_node("throttled", 11, content="throttledterm"), "node_created")
|
||||||
|
second = mod.call_tool_impl("nap_discover", {"q": "throttledterm", "mode": "local"})
|
||||||
|
|
||||||
|
assert [item["node_id"] for item in first["items"]] == ["autosync"]
|
||||||
|
assert first["freshness"]["stale"] is False
|
||||||
|
assert first["freshness"]["feed_synced_at"]
|
||||||
|
assert second["items"] == [] # within the throttle window no second feed read
|
||||||
|
assert provider.count("changes") == 2 # reindex head + one automatic sync
|
||||||
|
assert second["freshness"]["feed_sync"] == "throttled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_answers_locally_and_marks_stale_when_feed_unavailable(feed):
|
||||||
|
mod, provider = feed
|
||||||
|
seed(mod, provider, "stable")
|
||||||
|
provider.fail["changes"] = 503
|
||||||
|
|
||||||
|
result = mod.call_tool_impl("nap_discover", {"q": "stable", "mode": "local"})
|
||||||
|
|
||||||
|
assert [item["node_id"] for item in result["items"]] == ["stable"]
|
||||||
|
assert result["freshness"]["stale"] is True
|
||||||
|
assert result["freshness"]["reason"] == "server_unavailable"
|
||||||
|
assert result["warnings"][0]["code"] == "server_unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_local_read_auto_syncs_from_feed(feed):
|
||||||
|
mod, provider = feed
|
||||||
|
seed(mod, provider, "stable")
|
||||||
|
provider.put(make_node("contextnew", 10), "node_created")
|
||||||
|
|
||||||
|
result = mod.call_tool_impl("nap_context", {"paths": ["/notes/contextnew"], "mode": "hybrid"})
|
||||||
|
|
||||||
|
assert provider.count("changes") == 2
|
||||||
|
assert "contextnew" in json.dumps(result)
|
||||||
|
assert result["freshness"]["feed_sync"] == "synced"
|
||||||
|
|
||||||
|
|
||||||
|
def cursorless_complete_index(mod, provider, *names):
|
||||||
|
for index, name in enumerate(names):
|
||||||
|
node = make_node(name, index)
|
||||||
|
provider.put(node, "node_created")
|
||||||
|
mod.index_node(node)
|
||||||
|
mod.set_local_graph_index_complete("project-1", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursorless_index_bootstraps_on_first_search_then_refreshes(feed):
|
||||||
|
mod, provider = feed
|
||||||
|
cursorless_complete_index(mod, provider, "manual")
|
||||||
|
provider.nodes["gapnode"] = make_node("gapnode", 20, content="gapterm") # changed before tracking
|
||||||
|
|
||||||
|
first = mod.call_tool_impl("nap_discover", {"q": "gapterm", "mode": "local"})
|
||||||
|
|
||||||
|
assert [kind for kind, *_ in provider.requests] == ["changes", "list"]
|
||||||
|
assert provider.requests[0][2]["after"] == ["head"]
|
||||||
|
assert [item["node_id"] for item in first["items"]] == ["gapnode"]
|
||||||
|
assert first["freshness"]["feed_sync"] == "tracking_initialized"
|
||||||
|
assert not first.get("warnings")
|
||||||
|
assert mod.local_index_feed_state("project-1")["feed_cursor"] == "c1"
|
||||||
|
|
||||||
|
provider.put(make_node("manual", 30, content="manual remoteupdateterm"), "node_updated")
|
||||||
|
throttled = mod.call_tool_impl("nap_discover", {"q": "remoteupdateterm", "mode": "local"})
|
||||||
|
assert throttled["items"] == [] and len(provider.requests) == 2 # no HTTP inside the window
|
||||||
|
|
||||||
|
mod.FEED_AUTO_SYNC_STATE.clear() # the throttle window elapses
|
||||||
|
later = mod.call_tool_impl("nap_discover", {"q": "remoteupdateterm", "mode": "local"})
|
||||||
|
assert [item["node_id"] for item in later["items"]] == ["manual"]
|
||||||
|
assert later["freshness"]["feed_sync"] == "synced"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_failure_answers_locally_warns_and_does_not_retry(feed):
|
||||||
|
mod, provider = feed
|
||||||
|
cursorless_complete_index(mod, provider, "manual")
|
||||||
|
provider.fail["changes"] = 503
|
||||||
|
|
||||||
|
first = mod.call_tool_impl("nap_discover", {"q": "manual", "mode": "local"})
|
||||||
|
second = mod.call_tool_impl("nap_discover", {"q": "manual", "mode": "local"})
|
||||||
|
|
||||||
|
for result in (first, second):
|
||||||
|
assert [item["node_id"] for item in result["items"]] == ["manual"]
|
||||||
|
assert [w["code"] for w in result["warnings"]] == ["index_not_tracking"]
|
||||||
|
assert result["warnings"][0]["next_action"] == "nap_index_sync"
|
||||||
|
assert result["freshness"]["stale"] is True
|
||||||
|
assert provider.count("changes") == 1 # one attempt per retry window
|
||||||
|
assert provider.count("list") == 0
|
||||||
|
assert mod.local_index_feed_state("project-1")["feed_cursor"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_budget_exhaustion_stays_untracked_and_applies_nothing(feed, monkeypatch):
|
||||||
|
mod, provider = feed
|
||||||
|
cursorless_complete_index(mod, provider, "manual")
|
||||||
|
provider.nodes["gapnode"] = make_node("gapnode", 20, content="gapterm")
|
||||||
|
monkeypatch.setattr(mod, "FEED_AUTO_SYNC_MAX_PAGES", 0)
|
||||||
|
|
||||||
|
result = mod.call_tool_impl("nap_discover", {"q": "gapterm", "mode": "local"})
|
||||||
|
|
||||||
|
assert result["items"] == []
|
||||||
|
assert [w["code"] for w in result["warnings"]] == ["index_not_tracking"]
|
||||||
|
assert mod.local_index_feed_state("project-1")["feed_cursor"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_on_old_server_marks_unsupported_without_retry(feed):
|
||||||
|
mod, provider = feed
|
||||||
|
cursorless_complete_index(mod, provider, "manual")
|
||||||
|
provider.fail["changes"] = 404
|
||||||
|
|
||||||
|
first = mod.call_tool_impl("nap_discover", {"q": "manual", "mode": "local"})
|
||||||
|
mod.call_tool_impl("nap_discover", {"q": "manual", "mode": "local"})
|
||||||
|
|
||||||
|
assert first["freshness"]["feed_sync"] == "unsupported"
|
||||||
|
assert not first.get("warnings")
|
||||||
|
assert provider.count("changes") == 1 and provider.count("list") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_sync_is_bounded_by_pages_and_resumes(feed):
|
||||||
|
mod, provider = feed
|
||||||
|
seed(mod, provider, "stable")
|
||||||
|
start = len(provider.events)
|
||||||
|
for index in range(3):
|
||||||
|
provider.put(make_node(f"bounded{index}", 10 + index), "node_created")
|
||||||
|
|
||||||
|
first = mod.sync_local_index_from_feed("project-1", max_pages=2, page_limit=1)
|
||||||
|
|
||||||
|
assert (first["status"], first["pages"], first["has_more"]) == ("partial", 2, True)
|
||||||
|
assert mod.local_index_feed_state("project-1")["feed_cursor"] == f"c{start + 2}"
|
||||||
|
second = mod.sync_local_index_from_feed("project-1", max_pages=5, page_limit=1)
|
||||||
|
assert second["status"] == "synced"
|
||||||
|
assert local_ids(mod, "bounded2") == ["bounded2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_sync_initializes_feed_tracking_for_cursorless_index(feed):
|
||||||
|
mod, provider = feed
|
||||||
|
provider.put(make_node("manual", 1), "node_created")
|
||||||
|
mod.index_node(make_node("manual", 1))
|
||||||
|
mod.set_local_graph_index_complete("project-1", True)
|
||||||
|
|
||||||
|
result = mod.sync_local_index({})
|
||||||
|
|
||||||
|
assert result["mode"] == "incremental"
|
||||||
|
assert result["feed"] == "initialized"
|
||||||
|
assert mod.local_index_feed_state("project-1")["feed_cursor"] == "c1"
|
||||||
|
provider.delete("manual")
|
||||||
|
mod.sync_local_index({})
|
||||||
|
assert local_ids(mod, "manual") == [] # hard delete observed through the feed
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"""Public memory-tool regressions with disposable SQLite and an HTTP provider."""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def node(name, minute, *, archived=False):
|
||||||
|
stamp = f"2026-09-01T12:{minute:02d}:00Z"
|
||||||
|
return {
|
||||||
|
"id": name, "project_id": "project-1", "full_path": f"/notes/{name}",
|
||||||
|
"folder_path": "/notes", "name": name, "type": "custom-existing-type",
|
||||||
|
"metadata": {"custom": "retained"}, "tags": [], "aliases": [], "links": [],
|
||||||
|
"content_text": name, "updated_at": stamp,
|
||||||
|
"archived_at": stamp if archived else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def memory(tmp_path, monkeypatch):
|
||||||
|
provider = {"nodes": [], "requests": [], "fail_page": None, "override": None}
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *_args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if urlsplit(self.path).path.endswith("/changes"):
|
||||||
|
# Model a server without the change feed; the rebuild must not depend on it.
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"error":{"code":"not_found","message":"no change feed"}}')
|
||||||
|
return
|
||||||
|
query = parse_qs(urlsplit(self.path).query)
|
||||||
|
provider["requests"].append(query)
|
||||||
|
offset = int(query.get("cursor", ["0"])[0])
|
||||||
|
if offset == provider["fail_page"] or len(provider["requests"]) > 20:
|
||||||
|
self.send_response(503)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"error":{"code":"unavailable","message":"test outage"}}')
|
||||||
|
return
|
||||||
|
rows = copy.deepcopy(provider["nodes"])
|
||||||
|
after = query.get("updated_after", [""])[0]
|
||||||
|
before = query.get("updated_before", [""])[0]
|
||||||
|
rows = [n for n in rows if n["updated_at"] > after
|
||||||
|
and (not before or n["updated_at"] < before)
|
||||||
|
and (query.get("include_archived") == ["true"] or not n["archived_at"])]
|
||||||
|
rows.sort(key=lambda n: (n["updated_at"], n["id"]), reverse=True)
|
||||||
|
limit = int(query.get("limit", ["200"])[0])
|
||||||
|
end = offset + limit
|
||||||
|
page = {"items": rows[offset:end], "next_cursor": str(end) if end < len(rows) else None}
|
||||||
|
if provider["override"] is not None:
|
||||||
|
page = provider["override"](page, query)
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(page).encode())
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "all")
|
||||||
|
script = pathlib.Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py"
|
||||||
|
monkeypatch.syspath_prepend(str(script.parent))
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
spec = importlib.util.spec_from_file_location("rebuild_recovery_test_worker", script)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
mod.AUTH_DIR = tmp_path / "project-state"
|
||||||
|
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
||||||
|
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.lock"
|
||||||
|
mod.BASE_URL = f"http://127.0.0.1:{server.server_port}"
|
||||||
|
mod.TOKEN = "disposable-test-token"
|
||||||
|
mod.AUTH = {"account_id": "test-account"}
|
||||||
|
mod.DEFAULT_PROJECT_ID = "project-1"
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = {"all"}
|
||||||
|
# Identity is synthetic; the real request, dispatch and index code run.
|
||||||
|
mod.refresh_public_auth_state = lambda: None
|
||||||
|
mod.require_unlocked = lambda _name: None
|
||||||
|
mod.resolve_project_id = lambda args: args.get("project_id", "project-1")
|
||||||
|
return mod
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield worker(), provider, worker
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild(mod, capsys, *, limit=200):
|
||||||
|
mod.cli_main(["napseer_mcp_server.py", "reindex", "--limit", str(limit)])
|
||||||
|
return json.loads(capsys.readouterr().out)
|
||||||
|
|
||||||
|
|
||||||
|
def found(mod, text):
|
||||||
|
return [n["id"] for n in mod.call_tool_impl(
|
||||||
|
"nap_discover", {"q": text, "mode": "local"}
|
||||||
|
)["items"]]
|
||||||
|
|
||||||
|
|
||||||
|
def seed(mod, provider, capsys):
|
||||||
|
provider["nodes"] = [node("initial", 0)]
|
||||||
|
rebuild(mod, capsys)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rebuild_finds_remote_changes_despite_newer_cached_write(memory, capsys):
|
||||||
|
mod, provider, _ = memory
|
||||||
|
seed(mod, provider, capsys)
|
||||||
|
remote, own = node("remoteunique", 5), node("ownwrite", 10)
|
||||||
|
provider["nodes"].extend([remote, own])
|
||||||
|
mod.index_node(own)
|
||||||
|
|
||||||
|
result = rebuild(mod, capsys)
|
||||||
|
|
||||||
|
assert result["graph_complete"] is True
|
||||||
|
assert found(mod, "remoteunique") == ["remoteunique"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_page_preserves_snapshot_and_restart_recovers(memory, capsys):
|
||||||
|
mod, provider, worker = memory
|
||||||
|
seed(mod, provider, capsys)
|
||||||
|
provider["nodes"].extend([node("remoteunique", 5), node("newest", 20)])
|
||||||
|
provider["fail_page"] = 1
|
||||||
|
with pytest.raises(mod.SafeToolError):
|
||||||
|
rebuild(mod, capsys, limit=1)
|
||||||
|
assert found(mod, "newest") == []
|
||||||
|
own = node("ownwrite", 30)
|
||||||
|
mod.index_node(own)
|
||||||
|
provider["nodes"].append(own)
|
||||||
|
provider["fail_page"] = None
|
||||||
|
|
||||||
|
restarted = worker()
|
||||||
|
rebuild(restarted, capsys, limit=1)
|
||||||
|
|
||||||
|
assert found(restarted, "remoteunique") == ["remoteunique"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_records_need_no_new_fields_to_rebuild(memory, capsys):
|
||||||
|
mod, provider, _ = memory
|
||||||
|
mod.index_node(node("ownwrite", 30))
|
||||||
|
mod.set_local_graph_index_complete("project-1", True)
|
||||||
|
provider["nodes"] = [node("remoteunique", 5), node("ownwrite", 30)]
|
||||||
|
|
||||||
|
rebuild(mod, capsys)
|
||||||
|
|
||||||
|
assert "updated_after" not in provider["requests"][-1]
|
||||||
|
assert found(mod, "remoteunique") == ["remoteunique"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_failure_rolls_back_batch_and_retry_recovers(memory, capsys):
|
||||||
|
mod, provider, _ = memory
|
||||||
|
seed(mod, provider, capsys)
|
||||||
|
provider["nodes"].extend([node("newest", 20), node("failsave", 5)])
|
||||||
|
with mod.index_connect() as conn:
|
||||||
|
conn.execute("CREATE TRIGGER fail_sync BEFORE INSERT ON local_index_nodes "
|
||||||
|
"WHEN NEW.node_id = 'failsave' BEGIN SELECT RAISE(ABORT, 'injected'); END")
|
||||||
|
with pytest.raises(sqlite3.IntegrityError):
|
||||||
|
rebuild(mod, capsys)
|
||||||
|
assert found(mod, "newest") == []
|
||||||
|
assert found(mod, "initial") == ["initial"]
|
||||||
|
assert mod.local_index_diagnostics("project-1")["graph_complete"] is True
|
||||||
|
with mod.index_connect() as conn:
|
||||||
|
conn.execute("DROP TRIGGER fail_sync")
|
||||||
|
|
||||||
|
rebuild(mod, capsys)
|
||||||
|
assert found(mod, "failsave") == ["failsave"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("page", [{}, {"items": {}}, {"items": [], "next_cursor": 3},
|
||||||
|
{"items": [None]}, {"items": [], "has_more": True},
|
||||||
|
{"items": [{"id": "broken"}]},
|
||||||
|
{"items": [{**node("other", 5), "project_id": "project-2"}]}])
|
||||||
|
def test_malformed_rebuild_page_preserves_index_and_returns_safe_failure(memory, capsys, page):
|
||||||
|
mod, provider, _ = memory
|
||||||
|
seed(mod, provider, capsys)
|
||||||
|
provider["override"] = lambda *_: page
|
||||||
|
|
||||||
|
with pytest.raises(mod.SafeToolError) as error:
|
||||||
|
rebuild(mod, capsys)
|
||||||
|
|
||||||
|
assert error.value.code == "invalid_index_response"
|
||||||
|
assert found(mod, "initial") == ["initial"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_cursor_is_rejected_before_mutating_the_index(memory, capsys):
|
||||||
|
mod, provider, _ = memory
|
||||||
|
seed(mod, provider, capsys)
|
||||||
|
provider["override"] = lambda *_: {"items": [], "next_cursor": "1"}
|
||||||
|
|
||||||
|
with pytest.raises(mod.SafeToolError) as error:
|
||||||
|
rebuild(mod, capsys)
|
||||||
|
|
||||||
|
assert error.value.code == "invalid_index_response"
|
||||||
|
assert len(provider["requests"]) == 3 # seed + two pages, no unbounded loop
|
||||||
|
assert found(mod, "initial") == ["initial"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_readers_see_previous_snapshot_until_replacement_commits(memory, capsys, monkeypatch):
|
||||||
|
mod, provider, _ = memory
|
||||||
|
seed(mod, provider, capsys)
|
||||||
|
provider["nodes"] = [node("replacement", 20), node("second", 10)]
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
errors = []
|
||||||
|
original = mod.index_node
|
||||||
|
|
||||||
|
def paused_write(item, **kwargs):
|
||||||
|
original(item, **kwargs)
|
||||||
|
if item["id"] == "replacement":
|
||||||
|
entered.set()
|
||||||
|
assert release.wait(timeout=3)
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
mod.cli_main(["napseer_mcp_server.py", "reindex"])
|
||||||
|
except Exception as error:
|
||||||
|
errors.append(error)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "index_node", paused_write)
|
||||||
|
thread = threading.Thread(target=run)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
assert entered.wait(timeout=3)
|
||||||
|
assert found(mod, "initial") == ["initial"]
|
||||||
|
assert found(mod, "replacement") == []
|
||||||
|
assert mod.local_index_diagnostics("project-1")["graph_complete"] is True
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
thread.join(timeout=3)
|
||||||
|
|
||||||
|
assert not thread.is_alive()
|
||||||
|
assert not errors
|
||||||
|
assert json.loads(capsys.readouterr().out)["graph_complete"] is True
|
||||||
|
assert found(mod, "initial") == []
|
||||||
|
assert found(mod, "replacement") == ["replacement"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancelled_replacement_rolls_back_and_releases_writer_lock(memory, capsys, monkeypatch):
|
||||||
|
mod, provider, _ = memory
|
||||||
|
seed(mod, provider, capsys)
|
||||||
|
provider["nodes"] = [node("replacement", 20), node("second", 10)]
|
||||||
|
cancelled = threading.Event()
|
||||||
|
mod.MCP_REQUEST_LOCAL.cancelled = cancelled
|
||||||
|
original = mod.index_node
|
||||||
|
|
||||||
|
def cancel_after_write(item, **kwargs):
|
||||||
|
original(item, **kwargs)
|
||||||
|
cancelled.set()
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "index_node", cancel_after_write)
|
||||||
|
with pytest.raises(mod.SafeToolError) as error:
|
||||||
|
rebuild(mod, capsys)
|
||||||
|
assert error.value.code == "request_cancelled"
|
||||||
|
cancelled.clear()
|
||||||
|
assert found(mod, "initial") == ["initial"]
|
||||||
|
assert found(mod, "replacement") == []
|
||||||
|
assert mod.local_index_diagnostics("project-1")["graph_complete"] is True
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "index_node", original)
|
||||||
|
rebuild(mod, capsys)
|
||||||
|
assert found(mod, "replacement") == ["replacement"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rebuild_preserves_other_projects_and_existing_node_facets(memory, capsys):
|
||||||
|
mod, provider, _ = memory
|
||||||
|
seed(mod, provider, capsys)
|
||||||
|
mod.index_node({**node("otherproject", 30), "project_id": "project-2"})
|
||||||
|
kept = node("kept", 5)
|
||||||
|
kept.update(tags=["existing-tag"], aliases=["secondaryhandle"],
|
||||||
|
links=[{"path": "/notes/target", "relation": "custom-relation"}])
|
||||||
|
mod.index_node(kept)
|
||||||
|
assert found(mod, "secondaryhandle") == ["kept"], "baseline before rebuild"
|
||||||
|
provider["nodes"] = [kept, node("target", 6), node("retired", 8, archived=True)]
|
||||||
|
|
||||||
|
result = rebuild(mod, capsys, limit=1)
|
||||||
|
|
||||||
|
assert result["indexed"] == 2
|
||||||
|
assert found(mod, "initial") == []
|
||||||
|
assert found(mod, "retired") == []
|
||||||
|
assert found(mod, "secondaryhandle") == ["kept"]
|
||||||
|
with mod.index_connect() as conn:
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM local_index_nodes WHERE project_id='project-2'").fetchone()[0] == 1
|
||||||
|
assert conn.execute("SELECT node_type FROM local_index_nodes WHERE node_id='kept'").fetchone()[0] == "custom-existing-type"
|
||||||
|
assert json.loads(conn.execute("SELECT aliases_json FROM local_index_nodes WHERE node_id='kept'").fetchone()[0]) == ["secondaryhandle"]
|
||||||
|
assert conn.execute("SELECT relation FROM local_index_edges WHERE source_node_id='kept'").fetchone()[0] == "custom-relation"
|
||||||
|
assert conn.execute("SELECT tag FROM local_index_node_tags WHERE node_id='kept'").fetchone()[0] == "existing-tag"
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_empty_inventory_commits_an_empty_complete_project(memory, capsys):
|
||||||
|
mod, provider, _ = memory
|
||||||
|
seed(mod, provider, capsys)
|
||||||
|
provider["nodes"] = []
|
||||||
|
|
||||||
|
result = rebuild(mod, capsys)
|
||||||
|
|
||||||
|
assert result["indexed"] == 0
|
||||||
|
assert result["graph_complete"] is True
|
||||||
|
assert mod.local_index_diagnostics("project-1")["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_exit_during_rebuild_preserves_snapshot_after_restart(memory, capsys):
|
||||||
|
mod, provider, worker = memory
|
||||||
|
seed(mod, provider, capsys)
|
||||||
|
provider["nodes"] = [node("replacement", 20), node("second", 10)]
|
||||||
|
child = r'''
|
||||||
|
import importlib.util, os, pathlib, sys
|
||||||
|
script, state, base = sys.argv[1:]
|
||||||
|
sys.path.insert(0, str(pathlib.Path(script).parent))
|
||||||
|
spec = importlib.util.spec_from_file_location("rebuild_crash_worker", script)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
mod.AUTH_DIR = pathlib.Path(state)
|
||||||
|
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
||||||
|
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.lock"
|
||||||
|
mod.BASE_URL = base
|
||||||
|
mod.TOKEN = "disposable-test-token"
|
||||||
|
mod.DEFAULT_PROJECT_ID = "project-1"
|
||||||
|
mod.refresh_public_auth_state = lambda: None
|
||||||
|
mod.resolve_project_id = lambda _: "project-1"
|
||||||
|
original = mod.index_node
|
||||||
|
def crash_after_write(item, **kwargs):
|
||||||
|
original(item, **kwargs)
|
||||||
|
os._exit(71)
|
||||||
|
mod.index_node = crash_after_write
|
||||||
|
mod.cli_main([script, "reindex"])
|
||||||
|
'''
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-c", child, mod.__file__, str(mod.AUTH_DIR), mod.BASE_URL],
|
||||||
|
capture_output=True, timeout=8, check=False,
|
||||||
|
)
|
||||||
|
assert result.returncode == 71
|
||||||
|
restarted = worker()
|
||||||
|
assert found(restarted, "initial") == ["initial"]
|
||||||
|
assert found(restarted, "replacement") == []
|
||||||
|
assert restarted.local_index_diagnostics("project-1")["graph_complete"] is True
|
||||||
|
rebuild(restarted, capsys)
|
||||||
|
assert found(restarted, "replacement") == ["replacement"]
|
||||||
@@ -283,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) == 58
|
assert len(tool_by_name) == 60
|
||||||
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")
|
||||||
@@ -886,6 +886,9 @@ def test_index_sync_fetches_incremental_changes_and_removes_archived_nodes(mod):
|
|||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def fake_request(method, path, payload=None, **kwargs):
|
def fake_request(method, path, payload=None, **kwargs):
|
||||||
|
if "/changes?" in path:
|
||||||
|
# Server without the change feed: sync uses the timestamp path.
|
||||||
|
raise mod.SafeToolError("not_found", "no change feed", status=404)
|
||||||
calls.append(path)
|
calls.append(path)
|
||||||
assert method == "GET"
|
assert method == "GET"
|
||||||
assert "view=full" in path
|
assert "view=full" in path
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ def run():
|
|||||||
mod = load_module()
|
mod = load_module()
|
||||||
test_state = tempfile.TemporaryDirectory()
|
test_state = tempfile.TemporaryDirectory()
|
||||||
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
|
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
|
||||||
|
mod.MEMORY_SECRET_VAULT_PATH = pathlib.Path(test_state.name) / "memory-secrets.vault.json"
|
||||||
|
mod.LOCAL_VAULT_KEY_PATH = pathlib.Path(test_state.name) / "vault.local-key"
|
||||||
mod.AUTH = {"account_id": ACCOUNT_ID, "token": "token", "token_expires_at": "later"}
|
mod.AUTH = {"account_id": ACCOUNT_ID, "token": "token", "token_expires_at": "later"}
|
||||||
mod.PROJECT_VAULT_PASSPHRASE = PASSPHRASE
|
mod.PROJECT_VAULT_PASSPHRASE = PASSPHRASE
|
||||||
mod.DEFAULT_PROJECT_ID = "project-1"
|
mod.DEFAULT_PROJECT_ID = "project-1"
|
||||||
@@ -305,6 +307,8 @@ def run():
|
|||||||
"node_id": "done",
|
"node_id": "done",
|
||||||
"full_path": "/plans/done",
|
"full_path": "/plans/done",
|
||||||
"type": "note",
|
"type": "note",
|
||||||
|
# A generic note under /plans reports its effective type as well.
|
||||||
|
"effective_type": "plan",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
}]
|
}]
|
||||||
|
|
||||||
@@ -472,8 +476,24 @@ def run():
|
|||||||
indexed["metadata"] = {"private": "secret metadata"}
|
indexed["metadata"] = {"private": "secret metadata"}
|
||||||
mod.index_node(indexed)
|
mod.index_node(indexed)
|
||||||
assert mod.search_local_index({"q": "alpha", "limit": 5})["items"]
|
assert mod.search_local_index({"q": "alpha", "limit": 5})["items"]
|
||||||
assert mod.search_local_index({"q": "needle", "limit": 5})["items"] == []
|
# E2E body/metadata text never reaches the on-disk index; with the
|
||||||
assert mod.search_local_index({"q": "metadata", "limit": 5})["items"] == []
|
# memory secret unlocked it is found only via the encrypted shard.
|
||||||
|
for term in ("needle", "metadata"):
|
||||||
|
hits = mod.search_local_index({"q": term, "limit": 5})["items"]
|
||||||
|
assert hits and all(item.get("search_source") == "local_e2e" for item in hits)
|
||||||
|
mod.e2e_shards_close(flush=True)
|
||||||
|
for path in state_dir.iterdir():
|
||||||
|
if path.is_file():
|
||||||
|
raw = path.read_bytes()
|
||||||
|
assert b"secret needle" not in raw and b"secret metadata" not in raw, path.name
|
||||||
|
mod.MEMORY_SECRET_CACHE.clear()
|
||||||
|
mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear()
|
||||||
|
mod.PROJECT_VAULT_PASSPHRASE = None
|
||||||
|
# Earlier passphrase unwraps persisted the secret locally; remove it.
|
||||||
|
mod.memory_secret_vault_forget("project-1")
|
||||||
|
locked = mod.search_local_index({"q": "needle", "limit": 5})
|
||||||
|
assert locked["items"] == []
|
||||||
|
assert [warning["code"] for warning in locked["warnings"]] == ["e2e_index_not_provisioned"]
|
||||||
|
|
||||||
print("ok: local MCP memory node encryption smoke passed")
|
print("ok: local MCP memory node encryption smoke passed")
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ 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_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_changes_since", "nap_discover", "nap_context", "nap_node_by_path", "nap_node_get",
|
||||||
"nap_create_node", "nap_node_patch", "nap_bulk", "nap_batch", "nap_ln",
|
"nap_create_node", "nap_node_patch", "nap_bulk", "nap_batch", "nap_ln",
|
||||||
"nap_mv", "nap_rm",
|
"nap_mv", "nap_rm",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,721 @@
|
|||||||
|
"""Regressions for write idempotency, stale local reads, atomic claim-next,
|
||||||
|
indexed link counts, and committed .napseer-tools.json tool profiles."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
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_reliability_test", script_path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec and spec.loader
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mod(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.delenv("NAPSEER_TOOL_PROFILES", raising=False)
|
||||||
|
module = load_module()
|
||||||
|
module.DEFAULT_PROJECT_ID = "project-1"
|
||||||
|
module.TOKEN = "synthetic-access"
|
||||||
|
module.REFRESH_TOKEN = "synthetic-refresh"
|
||||||
|
module.TOKEN_EXPIRES_AT = "2099-01-01T00:00:00Z"
|
||||||
|
module.REFRESH_EXPIRES_AT = None
|
||||||
|
module.AUTH = {**module.AUTH, "account_mode": "operator_account", "project_id": "project-1"}
|
||||||
|
module.refresh_public_auth_state = lambda: module.AUTH
|
||||||
|
module.project_memory_encryption_active = lambda _project_id: False
|
||||||
|
module.AUTH_DIR = tmp_path / "state"
|
||||||
|
module.INDEX_PATH = tmp_path / "state" / "index.sqlite"
|
||||||
|
module.INDEX_LOCK_PATH = tmp_path / "state" / "index.lock"
|
||||||
|
module.resolve_project_id = lambda _args: "project-1"
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, payload):
|
||||||
|
self._body = json.dumps(payload).encode("utf-8")
|
||||||
|
|
||||||
|
def read(self, *_args):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def http_error(status, body):
|
||||||
|
return urllib.error.HTTPError(
|
||||||
|
"https://api.example.test/x", status, "error", {}, io.BytesIO(json.dumps(body).encode("utf-8"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stub_project_lock(mod):
|
||||||
|
mod.acquire_project_lock = lambda args=None, **kwargs: {"id": "lock-1", "lease_token": "lease-1"}
|
||||||
|
mod.cleanup_project_lock = lambda project_id, lock: None
|
||||||
|
|
||||||
|
|
||||||
|
# -- 1. Idempotency keys ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_node_idempotency_key_survives_auth_retry_and_is_returned(mod):
|
||||||
|
stub_project_lock(mod)
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def fake_urlopen(request, timeout=None):
|
||||||
|
seen.append((request.get_method(), request.full_url, request.get_header("Idempotency-key"),
|
||||||
|
request.get_header("Authorization")))
|
||||||
|
if len(seen) == 1:
|
||||||
|
raise http_error(401, {"error": {"code": "unauthorized"}})
|
||||||
|
return FakeResponse({"id": "node-new", "project_id": "project-1", "full_path": "/notes/a",
|
||||||
|
"folder_path": "/notes", "name": "a", "type": "note", "content_text": "x"})
|
||||||
|
|
||||||
|
def fake_renew(stale_token=None):
|
||||||
|
mod.TOKEN = "renewed-access"
|
||||||
|
return {"status": "renewed"}
|
||||||
|
|
||||||
|
mod.api_urlopen = fake_urlopen
|
||||||
|
mod.renew_auth = fake_renew
|
||||||
|
|
||||||
|
result = mod.call_tool_impl("nap_create_node", {"folder_path": "/notes", "name": "a", "content_text": "x"})
|
||||||
|
|
||||||
|
assert len(seen) == 2
|
||||||
|
generated = seen[0][2]
|
||||||
|
assert generated and str(uuid.UUID(generated)) == generated
|
||||||
|
# One logical request: the internal auth replay reuses the same key.
|
||||||
|
assert seen[1][2] == generated
|
||||||
|
assert result["idempotency_key"] == generated
|
||||||
|
|
||||||
|
seen.clear()
|
||||||
|
mod.api_urlopen = lambda request, timeout=None: seen.append(request.get_header("Idempotency-key")) or FakeResponse(
|
||||||
|
{"id": "node-new", "project_id": "project-1", "full_path": "/notes/a"}
|
||||||
|
)
|
||||||
|
result = mod.call_tool_impl(
|
||||||
|
"nap_create_node", {"folder_path": "/notes", "name": "a", "content_text": "x", "idempotency_key": "caller-key-1"}
|
||||||
|
)
|
||||||
|
assert seen == ["caller-key-1"]
|
||||||
|
assert result["idempotency_key"] == "caller-key-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_patch_sends_header_not_body_and_bulk_items_get_keys(mod):
|
||||||
|
stub_project_lock(mod)
|
||||||
|
existing = {"id": "node-123", "project_id": "project-1", "full_path": "/notes/current",
|
||||||
|
"folder_path": "/notes", "name": "current", "type": "note", "content_text": "old"}
|
||||||
|
mod.get_node_by_id = lambda args: existing
|
||||||
|
mod.try_get_node_by_path = lambda path, allow_agent=False: None
|
||||||
|
mod.index_node = lambda node: None
|
||||||
|
writes = []
|
||||||
|
|
||||||
|
def fake_request(method, path, payload=None, **kwargs):
|
||||||
|
writes.append((method, path, payload, dict(kwargs.get("extra_headers") or {})))
|
||||||
|
if path.endswith("/patch"):
|
||||||
|
return {"ok": True, "data": {"node": existing, "revision": "r2", "read_fingerprint": "f2"}}
|
||||||
|
return {**existing, "id": "node-new" if method == "POST" else existing["id"]}
|
||||||
|
|
||||||
|
mod.request_json = fake_request
|
||||||
|
|
||||||
|
patched = mod.guarded_node_patch({
|
||||||
|
"node_id": "node-123",
|
||||||
|
"precondition": {"revision": "r1", "read_fingerprint": "f1"},
|
||||||
|
"merge_metadata": {"status": "active"},
|
||||||
|
"idempotency_key": "patch-key-1",
|
||||||
|
})
|
||||||
|
method, path, payload, headers = writes[-1]
|
||||||
|
assert (method, path) == ("PATCH", "/v1/projects/project-1/nodes/node-123/patch")
|
||||||
|
assert "idempotency_key" not in payload
|
||||||
|
assert headers["Idempotency-Key"] == "patch-key-1"
|
||||||
|
assert headers["X-Napseer-Lock-Id"] == "lock-1"
|
||||||
|
assert patched["idempotency_key"] == "patch-key-1"
|
||||||
|
|
||||||
|
generated = mod.guarded_node_patch({
|
||||||
|
"node_id": "node-123",
|
||||||
|
"precondition": {"revision": "r1", "read_fingerprint": "f1"},
|
||||||
|
"merge_metadata": {"status": "active"},
|
||||||
|
})
|
||||||
|
assert generated["idempotency_key"] == writes[-1][3]["Idempotency-Key"]
|
||||||
|
uuid.UUID(generated["idempotency_key"])
|
||||||
|
|
||||||
|
writes.clear()
|
||||||
|
bulk = mod.bulk_upsert_nodes({
|
||||||
|
"idempotency_key": "bulk-base",
|
||||||
|
"nodes": [
|
||||||
|
{"path": "/notes/new", "content_text": "new"},
|
||||||
|
{"node_id": "node-123", "content_text": "updated", "idempotency_key": "item-own"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert [(w[0], w[3]["Idempotency-Key"]) for w in writes] == [("POST", "bulk-base:0"), ("PATCH", "item-own")]
|
||||||
|
assert [item["idempotency_key"] for item in bulk["items"]] == ["bulk-base:0", "item-own"]
|
||||||
|
|
||||||
|
for name in ("nap_create_node", "nap_node_patch", "nap_bulk"):
|
||||||
|
assert mod.tool_contract_metadata(name)["idempotency"] == "same-idempotency-key"
|
||||||
|
tools = {tool["name"]: tool for tool in mod.raw_tools()}
|
||||||
|
assert "idempotency_key" in tools["nap_create_node"]["inputSchema"]["properties"]
|
||||||
|
assert "idempotency_key" in tools["nap_bulk"]["inputSchema"]["properties"]
|
||||||
|
assert "idempotency_key" in tools["nap_bulk"]["inputSchema"]["properties"]["nodes"]["items"]["properties"]
|
||||||
|
|
||||||
|
|
||||||
|
# -- 2. Stale local reads and whoami auth precedence -----------------------
|
||||||
|
|
||||||
|
|
||||||
|
def indexed(path, links=None):
|
||||||
|
folder, name = path.rsplit("/", 1)
|
||||||
|
return {
|
||||||
|
"id": f"id-{name}",
|
||||||
|
"project_id": "project-1",
|
||||||
|
"full_path": path,
|
||||||
|
"folder_path": folder,
|
||||||
|
"name": name,
|
||||||
|
"type": "note",
|
||||||
|
"tags": [],
|
||||||
|
"aliases": [],
|
||||||
|
"links": links or [],
|
||||||
|
"metadata": {"status": "active"},
|
||||||
|
"content_text": f"body of {name} gateway reliability",
|
||||||
|
"encryption_state": "legacy_plaintext",
|
||||||
|
"updated_at": "2026-08-22T00:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_complete_index(mod):
|
||||||
|
mod.index_node(indexed("/plans/alpha", links=[{"path": "/plans/beta", "relation": "references"}]))
|
||||||
|
mod.index_node(indexed("/plans/beta"))
|
||||||
|
mod.set_local_graph_index_complete("project-1", True)
|
||||||
|
|
||||||
|
|
||||||
|
def record_revoked_grant(mod):
|
||||||
|
def rejected(*_args, **_kwargs):
|
||||||
|
raise mod.SafeToolError("http_401", "rejected", status=401)
|
||||||
|
|
||||||
|
mod.request_form_json = rejected
|
||||||
|
with pytest.raises(mod.SafeToolError) as caught:
|
||||||
|
mod.renew_auth()
|
||||||
|
assert caught.value.code == "auth_required"
|
||||||
|
assert "expired or revoked" in caught.value.safe_message
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_answers_warn_when_login_grant_is_revoked(mod):
|
||||||
|
build_complete_index(mod)
|
||||||
|
|
||||||
|
def empty_feed(_method, path, *_args, **_kwargs):
|
||||||
|
if "/changes?" in path:
|
||||||
|
return {"items": [], "next_cursor": "c0", "has_more": False}
|
||||||
|
return {"items": [], "next_cursor": None}
|
||||||
|
|
||||||
|
# A healthy read starts change-feed tracking for the cursorless index.
|
||||||
|
mod.request_json = empty_feed
|
||||||
|
healthy = mod.discover_memory({"folder_path": "/plans", "view": "paths"})
|
||||||
|
assert not healthy.get("warnings")
|
||||||
|
assert not (healthy.get("freshness") or {}).get("stale")
|
||||||
|
assert healthy["freshness"]["feed_sync"] == "tracking_initialized"
|
||||||
|
mod.request_json = lambda *a, **k: (_ for _ in ()).throw(AssertionError("no HTTP expected"))
|
||||||
|
|
||||||
|
record_revoked_grant(mod)
|
||||||
|
state_text = (mod.AUTH_DIR / "auth-failure.json").read_text()
|
||||||
|
assert "synthetic-refresh" not in state_text and "synthetic-access" not in state_text
|
||||||
|
|
||||||
|
browsed = mod.discover_memory({"folder_path": "/plans", "view": "paths"})
|
||||||
|
assert [w["code"] for w in browsed["warnings"]] == ["auth_required"]
|
||||||
|
assert browsed["warnings"][0]["indexed_at"]
|
||||||
|
assert browsed["freshness"]["stale"] is True
|
||||||
|
assert browsed["freshness"]["graph_complete"] is True
|
||||||
|
|
||||||
|
context = mod.get_memory_context({"node_id": "id-alpha", "depth": 1})
|
||||||
|
codes = [w["code"] for w in context["warnings"] if isinstance(w, dict)]
|
||||||
|
assert codes == ["auth_required"]
|
||||||
|
assert context["freshness"]["stale"] is True
|
||||||
|
assert context["edge_count"] >= 1
|
||||||
|
|
||||||
|
status = mod.gateway_status()
|
||||||
|
assert status["status"] == "auth_required"
|
||||||
|
assert status["auth_problem"]["reason"] == "auth_required"
|
||||||
|
|
||||||
|
# A new login invalidates the recorded failure without clearing files.
|
||||||
|
mod.TOKEN = "fresh-access"
|
||||||
|
mod.REFRESH_TOKEN = "fresh-refresh"
|
||||||
|
assert mod.current_auth_problem() is None
|
||||||
|
assert mod.gateway_status()["status"] != "auth_required"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hybrid_search_falls_back_to_local_with_server_unavailable_warning(mod):
|
||||||
|
build_complete_index(mod)
|
||||||
|
|
||||||
|
def server_down(*_args, **_kwargs):
|
||||||
|
raise mod.SafeToolError("http_503", "Napseer GET request failed with HTTP 503.", status=503)
|
||||||
|
|
||||||
|
mod.request_json = server_down
|
||||||
|
result = mod.discover_memory({"q": "gateway reliability", "limit": 25})
|
||||||
|
assert {item["full_path"] for item in result["items"]} == {"/plans/alpha", "/plans/beta"}
|
||||||
|
assert [w["code"] for w in result["warnings"]] == ["server_unavailable"]
|
||||||
|
assert result["freshness"]["stale"] is True
|
||||||
|
assert result["freshness"]["reason"] == "server_unavailable"
|
||||||
|
|
||||||
|
# A non-recoverable server error is not masked by the local answer.
|
||||||
|
mod.request_json = lambda *a, **k: (_ for _ in ()).throw(mod.SafeToolError("http_403", "forbidden", status=403))
|
||||||
|
with pytest.raises(mod.SafeToolError):
|
||||||
|
mod.discover_memory({"q": "gateway reliability", "limit": 25})
|
||||||
|
|
||||||
|
|
||||||
|
def test_whoami_reports_expired_access_without_refresh_ahead_of_gateway_state(mod):
|
||||||
|
mod.REFRESH_TOKEN = None
|
||||||
|
mod.TOKEN_EXPIRES_AT = "2020-01-01T00:00:00Z"
|
||||||
|
status = mod.gateway_status()
|
||||||
|
assert status["status"] == "auth_required"
|
||||||
|
assert status["auth_problem"]["reason"] == "access_token_expired_without_refresh"
|
||||||
|
|
||||||
|
|
||||||
|
# -- 3. Atomic claim-next ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def kanban_card(name, priority, rank):
|
||||||
|
return {
|
||||||
|
"id": f"id-{name}",
|
||||||
|
"project_id": "project-1",
|
||||||
|
"full_path": f"/kanban/todo/{name}",
|
||||||
|
"folder_path": "/kanban/todo",
|
||||||
|
"name": name,
|
||||||
|
"type": "kanban_card",
|
||||||
|
"tags": ["kanban"],
|
||||||
|
"links": [],
|
||||||
|
"metadata": {"status": "todo", "column": "todo", "priority": priority, "rank": rank, "blocked": False},
|
||||||
|
"content_text": "card",
|
||||||
|
"updated_at": "2026-05-06T12:00:00Z",
|
||||||
|
"archived_at": None,
|
||||||
|
"encryption_state": "plaintext",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def kanban_backend(mod, claim_outcomes):
|
||||||
|
cards = [kanban_card("first", "urgent", "00001024"), kanban_card("second", "high", "00001024"),
|
||||||
|
kanban_card("third", "normal", "00001024")]
|
||||||
|
claims = []
|
||||||
|
|
||||||
|
def fake_request(method, path, payload=None, **kwargs):
|
||||||
|
if method == "GET":
|
||||||
|
return {"items": cards, "next_cursor": None}
|
||||||
|
assert (method, path) == ("POST", "/v1/projects/project-1/operations")
|
||||||
|
claims.append(payload)
|
||||||
|
outcome = claim_outcomes[len(claims) - 1]
|
||||||
|
if isinstance(outcome, Exception):
|
||||||
|
raise outcome
|
||||||
|
return {**outcome, "id": payload["id"], "node_id": payload["node_id"]}
|
||||||
|
|
||||||
|
mod.request_json = fake_request
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
def held_error(mod, legacy=False):
|
||||||
|
mod_error = mod.safe_http_error(
|
||||||
|
http_error(409, {}),
|
||||||
|
operation="POST request",
|
||||||
|
body_text=json.dumps(
|
||||||
|
{"error": {"code": "conflict", "message": "Another execution holds this work until it expires."}}
|
||||||
|
if legacy else
|
||||||
|
{"error": {"code": "operation_held", "message": "held", "details": {"active_operation_id": "op-active"}}}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return mod_error
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_next_skips_held_cards_and_claims_in_pick_order(mod):
|
||||||
|
claims = kanban_backend(mod, [held_error(mod), held_error(mod, legacy=True), {"state": "running"}])
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = {"workflow"}
|
||||||
|
|
||||||
|
result = mod.call_tool_impl("nap_kanban_claim_next", {"holder": "agent-a", "ttl_seconds": 120})
|
||||||
|
|
||||||
|
assert [c["node_id"] for c in claims] == ["id-first", "id-second", "id-third"]
|
||||||
|
assert len({c["id"] for c in claims}) == 3
|
||||||
|
assert all(c["holder"] == "agent-a" and c["ttl_seconds"] == 120 for c in claims)
|
||||||
|
assert result["claimed"]["full_path"] == "/kanban/todo/third"
|
||||||
|
assert result["operation"]["state"] == "running"
|
||||||
|
assert result["operation_id"] == claims[-1]["id"]
|
||||||
|
assert [s["reason"] for s in result["skipped"]] == ["operation_held", "operation_held"]
|
||||||
|
assert result["skipped"][0]["active_operation_id"] == "op-active"
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_next_returns_null_when_all_held_and_stops_on_ambiguous_error(mod):
|
||||||
|
kanban_backend(mod, [held_error(mod)] * 3)
|
||||||
|
none = mod.claim_next_kanban_card({"holder": "agent-a"})
|
||||||
|
assert none["ok"] is True and none["claimed"] is None
|
||||||
|
assert none["reason"] == "all_candidates_held"
|
||||||
|
|
||||||
|
claims = kanban_backend(mod, [held_error(mod), mod.SafeToolError("request_timeout", "timed out")])
|
||||||
|
with pytest.raises(mod.SafeToolError) as caught:
|
||||||
|
mod.claim_next_kanban_card({"holder": "agent-a"})
|
||||||
|
# Stops at the ambiguous claim; never retries or moves on.
|
||||||
|
assert len(claims) == 2
|
||||||
|
payload = mod.safe_exception_payload(caught.value)
|
||||||
|
assert payload["code"] == "kanban_claim_unresolved"
|
||||||
|
assert payload["details"]["attempted_operation_id"] == claims[1]["id"]
|
||||||
|
assert payload["details"]["cause"]["code"] == "request_timeout"
|
||||||
|
assert claims[1]["id"] in payload["message"]
|
||||||
|
|
||||||
|
claims = kanban_backend(mod, [held_error(mod), held_error(mod)])
|
||||||
|
capped = mod.claim_next_kanban_card({"holder": "agent-a", "max_candidates": 2})
|
||||||
|
assert len(claims) == 2 and capped["reason"] == "max_candidates_held"
|
||||||
|
|
||||||
|
with pytest.raises(mod.SafeToolError):
|
||||||
|
mod.claim_next_kanban_card({})
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_next_is_registered_as_workflow_mutation(mod):
|
||||||
|
assert "nap_kanban_claim_next" in mod.CANONICAL_TOOL_NAMES
|
||||||
|
assert mod.tool_profile("nap_kanban_claim_next") == "workflow"
|
||||||
|
assert mod.tool_category("nap_kanban_claim_next") == "kanban"
|
||||||
|
metadata = mod.tool_contract_metadata("nap_kanban_claim_next")
|
||||||
|
assert metadata["side_effect"] == "remote-write"
|
||||||
|
assert metadata["lock_policy"] == "atomic-execution-lease"
|
||||||
|
assert "nap_kanban_claim_next" not in mod.READ_ONLY_TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
# -- 4. links_count from indexed edges -------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_discovery_rows_report_indexed_link_counts(mod):
|
||||||
|
build_complete_index(mod)
|
||||||
|
mod.request_json = lambda *a, **k: (_ for _ in ()).throw(AssertionError("no HTTP expected"))
|
||||||
|
|
||||||
|
searched = mod.search_local_index({"q": "gateway reliability", "limit": 10})
|
||||||
|
by_path = {item["full_path"]: item for item in searched["items"]}
|
||||||
|
assert by_path["/plans/alpha"]["links_count"] == 1
|
||||||
|
assert by_path["/plans/beta"]["backlinks_count"] == 1
|
||||||
|
|
||||||
|
rows = mod.discover_memory({"q": "gateway reliability", "view": "summary", "mode": "local"})["items"]
|
||||||
|
rows = {item["full_path"]: item for item in rows}
|
||||||
|
assert rows["/plans/alpha"]["links_count"] == 1
|
||||||
|
assert rows["/plans/beta"]["links_count"] == 0
|
||||||
|
assert rows["/plans/beta"]["backlinks_count"] == 1
|
||||||
|
|
||||||
|
# Unknown counts are omitted rather than reported as zero.
|
||||||
|
summary = mod.summarize_node({"id": "x", "full_path": "/a/x", "name": "x"}, view="summary")
|
||||||
|
assert "links_count" not in summary
|
||||||
|
|
||||||
|
|
||||||
|
# -- 5. Tool profiles from the committed root .napseer-tools.json ----------
|
||||||
|
|
||||||
|
|
||||||
|
def write_locator(mod, **extra):
|
||||||
|
path = mod.project_locator_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps({
|
||||||
|
"schema": "napseer.project.v1",
|
||||||
|
"base_url": "https://api.example.test",
|
||||||
|
"project_id": "11111111-1111-4111-8111-111111111111",
|
||||||
|
"project_slug": "example",
|
||||||
|
**extra,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
def write_tools_file(mod, profiles=None, *, raw=None):
|
||||||
|
path = mod.project_tools_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if raw is None:
|
||||||
|
raw = json.dumps({"schema": "napseer.tools.v1", "tool_profiles": profiles}) + "\n"
|
||||||
|
path.write_bytes(raw if isinstance(raw, bytes) else raw.encode("utf-8"))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def doctor_warnings(mod):
|
||||||
|
mod.BASE_URL = "https://api.example.test"
|
||||||
|
mod.local_anchor_integrity = lambda: {}
|
||||||
|
mod.index_status = lambda args: {}
|
||||||
|
return mod.doctor({}).get("warnings", [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_tools_file_profiles_apply_when_env_unset(mod):
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = None
|
||||||
|
write_locator(mod)
|
||||||
|
assert mod.tool_profile_configuration() == (set(), "default", [])
|
||||||
|
assert mod.project_tools_path() == mod.project_locator_path().parent.parent / ".napseer-tools.json"
|
||||||
|
# The retired in-.napseer location is never read.
|
||||||
|
(mod.project_locator_path().parent / "tools.json").write_text(
|
||||||
|
json.dumps({"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}))
|
||||||
|
assert mod.tool_profile_configuration() == (set(), "default", [])
|
||||||
|
|
||||||
|
write_tools_file(mod, ["workflow", " Operations "])
|
||||||
|
assert mod.tool_profile_configuration() == ({"workflow", "operations"}, "tools_file", [])
|
||||||
|
assert "nap_kanban_claim_next" in mod.enabled_tool_names()
|
||||||
|
apropos = mod.explore_tools({})
|
||||||
|
assert apropos["profile_source"] == "tools_file"
|
||||||
|
assert apropos["active_profiles"] == ["operations", "workflow"]
|
||||||
|
assert "warnings" not in apropos
|
||||||
|
contract = mod.contract_profile({"include_tools": False})["tool_profiles"]
|
||||||
|
assert contract["source"] == "tools_file"
|
||||||
|
assert contract["tools_file"] == str(mod.project_tools_path())
|
||||||
|
assert doctor_warnings(mod) == []
|
||||||
|
# The committed locator still carries identity only.
|
||||||
|
assert set(mod.load_project_locator()) == {"schema", "base_url", "project_id", "project_slug"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_overrides_tools_file(mod, monkeypatch):
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = None
|
||||||
|
write_tools_file(mod, ["workflow"])
|
||||||
|
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "maintenance")
|
||||||
|
assert mod.tool_profile_configuration() == ({"maintenance"}, "environment", [])
|
||||||
|
assert "nap_kanban_claim_next" not in mod.enabled_tool_names()
|
||||||
|
assert mod.explore_tools({})["profile_source"] == "environment"
|
||||||
|
|
||||||
|
# A non-empty environment value wins without consulting a broken file.
|
||||||
|
write_tools_file(mod, raw="{not json")
|
||||||
|
assert mod.tool_profile_configuration() == ({"maintenance"}, "environment", [])
|
||||||
|
|
||||||
|
# An empty or blank value is unset: the committed file applies again.
|
||||||
|
write_tools_file(mod, ["workflow"])
|
||||||
|
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", " ")
|
||||||
|
assert mod.tool_profile_configuration() == ({"workflow"}, "tools_file", [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_tools_file_unknown_profile_names_are_ignored_with_warning(mod):
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = None
|
||||||
|
write_tools_file(mod, ["workflow", "bogus", 7])
|
||||||
|
profiles, source, warnings = mod.tool_profile_configuration()
|
||||||
|
assert (profiles, source) == ({"workflow"}, "tools_file")
|
||||||
|
assert [w["code"] for w in warnings] == ["unknown_tool_profile"]
|
||||||
|
assert warnings[0]["ignored"] == ["bogus", "7"]
|
||||||
|
apropos = mod.explore_tools({})
|
||||||
|
assert apropos["warnings"][0]["code"] == "unknown_tool_profile"
|
||||||
|
assert [w["code"] for w in doctor_warnings(mod)] == ["unknown_tool_profile"]
|
||||||
|
|
||||||
|
write_tools_file(mod, ["workflow"] * 20)
|
||||||
|
assert [w["code"] for w in mod.tool_profile_configuration()[2]] == ["tool_profiles_truncated"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"case",
|
||||||
|
["invalid_json", "not_object", "wrong_schema", "missing_schema", "profiles_not_list",
|
||||||
|
"oversized", "not_utf8", "symlink", "directory"],
|
||||||
|
)
|
||||||
|
def test_unusable_tools_file_warns_and_falls_back_to_core(mod, tmp_path_factory, case):
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = None
|
||||||
|
marker = "synthetic-marker-not-echoed"
|
||||||
|
raw = {
|
||||||
|
"invalid_json": '{"schema": "napseer.tools.v1", "tool_profiles": ["workflow"], ' + marker,
|
||||||
|
"not_object": json.dumps(["workflow", marker]),
|
||||||
|
"wrong_schema": json.dumps({"schema": "napseer.tools.v9", "tool_profiles": ["workflow"]}),
|
||||||
|
"missing_schema": json.dumps({"tool_profiles": ["workflow"]}),
|
||||||
|
"profiles_not_list": json.dumps({"schema": "napseer.tools.v1", "tool_profiles": "workflow"}),
|
||||||
|
"oversized": json.dumps({"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}) + " " * 4096,
|
||||||
|
"not_utf8": b'{"schema": "napseer.tools.v1", "tool_profiles": ["\xff"]}',
|
||||||
|
}.get(case)
|
||||||
|
path = mod.project_tools_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if case == "symlink":
|
||||||
|
outside = tmp_path_factory.mktemp("outside-project") / ".napseer-tools.json"
|
||||||
|
outside.write_text(json.dumps({"schema": "napseer.tools.v1", "tool_profiles": ["all"]}))
|
||||||
|
path.symlink_to(outside)
|
||||||
|
elif case == "directory":
|
||||||
|
path.mkdir()
|
||||||
|
else:
|
||||||
|
write_tools_file(mod, raw=raw)
|
||||||
|
|
||||||
|
profiles, source, warnings = mod.tool_profile_configuration()
|
||||||
|
assert (profiles, source) == (set(), "default")
|
||||||
|
assert [w["code"] for w in warnings] == ["tools_file_invalid"]
|
||||||
|
assert marker not in warnings[0]["message"]
|
||||||
|
assert mod.enabled_tool_names() == set(mod.CORE_TOOL_NAMES)
|
||||||
|
assert mod.explore_tools({})["warnings"][0]["code"] == "tools_file_invalid"
|
||||||
|
assert [w["code"] for w in doctor_warnings(mod)] == ["tools_file_invalid"]
|
||||||
|
with pytest.raises(mod.SafeToolError) as excinfo:
|
||||||
|
mod._call_tool_impl_dispatch("nap_kanban_claim_next", {})
|
||||||
|
assert excinfo.value.code == "tool_profile_required"
|
||||||
|
assert ".napseer-tools.json" in excinfo.value.safe_message
|
||||||
|
|
||||||
|
|
||||||
|
def test_tools_file_parser_recursion_is_contained(mod, monkeypatch):
|
||||||
|
# Older Python JSON decoders raise RecursionError on deep nesting that
|
||||||
|
# still fits the size bound; it must degrade to core, not fail every call.
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = None
|
||||||
|
write_tools_file(mod, ["workflow"])
|
||||||
|
|
||||||
|
def too_deep(*_args, **_kwargs):
|
||||||
|
raise RecursionError("synthetic")
|
||||||
|
|
||||||
|
with monkeypatch.context() as patched:
|
||||||
|
patched.setattr(mod.json, "loads", too_deep)
|
||||||
|
profiles, source, warnings = mod.tool_profile_configuration()
|
||||||
|
assert (profiles, source) == (set(), "default")
|
||||||
|
assert [w["code"] for w in warnings] == ["tools_file_invalid"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_locator_tool_profiles_field_is_still_rejected(mod):
|
||||||
|
# tool_profiles was never a released locator field: the locator keeps its
|
||||||
|
# exact-field schema and the field is neither accepted nor applied.
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = None
|
||||||
|
write_locator(mod, tool_profiles=["workflow"])
|
||||||
|
with pytest.raises(mod.SafeToolError) as excinfo:
|
||||||
|
mod.load_project_locator()
|
||||||
|
assert excinfo.value.code == "project_locator_invalid"
|
||||||
|
assert "unsupported or missing fields" in excinfo.value.safe_message
|
||||||
|
with pytest.raises(mod.SafeToolError) as excinfo:
|
||||||
|
mod.write_project_locator({"id": "11111111-1111-4111-8111-111111111111", "slug": "example"},
|
||||||
|
base_url="https://api.example.test")
|
||||||
|
assert excinfo.value.code == "project_locator_invalid"
|
||||||
|
assert "tool_profiles" in json.loads(mod.project_locator_path().read_text())
|
||||||
|
assert mod.tool_profile_configuration() == (set(), "default", [])
|
||||||
|
assert "nap_kanban_claim_next" not in mod.enabled_tool_names()
|
||||||
|
|
||||||
|
|
||||||
|
# -- 6. Change feed cursor --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def changes_backend(mod, responses):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_request(method, path, payload=None, **kwargs):
|
||||||
|
calls.append((method, path))
|
||||||
|
outcome = responses[len(calls) - 1]
|
||||||
|
if isinstance(outcome, Exception):
|
||||||
|
raise outcome
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
mod.request_json = fake_request
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_changes_since_starts_at_head_then_advances_only_on_success(mod, tmp_path):
|
||||||
|
change = {"type": "node_updated", "cursor": "c1.5.9", "event_id": "e1", "project_id": "project-1",
|
||||||
|
"node_id": "n1", "full_path": "/plans/a", "changed_fields": ["content_text"]}
|
||||||
|
calls = changes_backend(mod, [
|
||||||
|
{"items": [], "next_cursor": "c1.5.8", "has_more": False},
|
||||||
|
{"items": [change], "next_cursor": "c1.5.9", "has_more": True},
|
||||||
|
mod.SafeToolError("http_503", "unavailable", status=503),
|
||||||
|
{"items": [], "next_cursor": "c1.9.9", "has_more": False},
|
||||||
|
])
|
||||||
|
|
||||||
|
first = mod.call_tool_impl("nap_changes_since", {})
|
||||||
|
assert calls[0] == ("GET", "/v1/projects/project-1/changes?after=head&limit=100")
|
||||||
|
assert first["started_at_head"] is True and first["items"] == []
|
||||||
|
stored = json.loads((mod.AUTH_DIR / "changes-cursor.json").read_text())
|
||||||
|
assert stored["project_id"] == "project-1" and stored["cursor"] == "c1.5.8"
|
||||||
|
assert not (tmp_path / ".napseer" / "changes-cursor.json").exists()
|
||||||
|
|
||||||
|
second = mod.changes_since({"limit": 50})
|
||||||
|
assert calls[1] == ("GET", "/v1/projects/project-1/changes?after=c1.5.8&limit=50")
|
||||||
|
assert second["items"] == [change] and second["has_more"] is True and second["cursor_advanced"] is True
|
||||||
|
|
||||||
|
with pytest.raises(mod.SafeToolError) as caught:
|
||||||
|
mod.changes_since({})
|
||||||
|
assert caught.value.details["cursor_advanced"] is False
|
||||||
|
assert mod.read_changes_cursor("project-1") == "c1.5.9"
|
||||||
|
|
||||||
|
peek = mod.changes_since({"advance": False})
|
||||||
|
assert peek["cursor_advanced"] is False
|
||||||
|
assert mod.read_changes_cursor("project-1") == "c1.5.9"
|
||||||
|
|
||||||
|
|
||||||
|
def test_changes_since_errors_project_binding_and_reset(mod):
|
||||||
|
mod.write_changes_cursor("other-project", "c1.1.1")
|
||||||
|
calls = changes_backend(mod, [
|
||||||
|
mod.safe_http_error(http_error(404, {}), body_text="{}"),
|
||||||
|
mod.safe_http_error(http_error(400, {}), body_text=json.dumps({"error": {"code": "validation_error"}})),
|
||||||
|
{"items": [], "next_cursor": "c1.2.2", "has_more": False},
|
||||||
|
])
|
||||||
|
with pytest.raises(mod.SafeToolError) as unsupported:
|
||||||
|
mod.changes_since({})
|
||||||
|
# A cursor stored for another project is never reused.
|
||||||
|
assert "after=head" in calls[0][1]
|
||||||
|
assert unsupported.value.code == "unsupported_server"
|
||||||
|
|
||||||
|
with pytest.raises(mod.SafeToolError) as invalid:
|
||||||
|
mod.changes_since({"after": "bogus"})
|
||||||
|
assert invalid.value.code == "invalid_changes_cursor"
|
||||||
|
|
||||||
|
reset = mod.changes_since({"reset": True})
|
||||||
|
assert "after=head" in calls[2][1] and reset["started_at_head"] is True
|
||||||
|
assert mod.read_changes_cursor("project-1") == "c1.2.2"
|
||||||
|
|
||||||
|
mod.REFRESH_TOKEN = None
|
||||||
|
mod.TOKEN_EXPIRES_AT = "2020-01-01T00:00:00Z"
|
||||||
|
with pytest.raises(mod.SafeToolError) as auth:
|
||||||
|
mod.changes_since({})
|
||||||
|
assert auth.value.code == "auth_required"
|
||||||
|
assert auth.value.details["auth_problem"]["reason"] == "access_token_expired_without_refresh"
|
||||||
|
assert len(calls) == 3
|
||||||
|
assert mod.read_changes_cursor("project-1") == "c1.2.2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_changes_since_is_a_core_read_tool(mod):
|
||||||
|
assert "nap_changes_since" in mod.CORE_TOOL_NAMES
|
||||||
|
assert mod.tool_category("nap_changes_since") == "memory"
|
||||||
|
assert mod.tool_contract_metadata("nap_changes_since")["side_effect"] == "none"
|
||||||
|
|
||||||
|
|
||||||
|
# -- 7. Idempotency keys on remaining node writes --------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def node_write_backend(mod, nodes):
|
||||||
|
stub_project_lock(mod)
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = {"all"}
|
||||||
|
mod.get_node_by_id = lambda args: nodes[args.get("node_id") or args.get("id")]
|
||||||
|
mod.index_node = lambda node: None
|
||||||
|
mod.remove_indexed_node = lambda node_id: None
|
||||||
|
writes = []
|
||||||
|
|
||||||
|
def fake_request(method, path, payload=None, **kwargs):
|
||||||
|
if method == "GET":
|
||||||
|
return {"items": list(nodes.values()) if "/nodes?" in path else [], "next_cursor": None}
|
||||||
|
writes.append((method, path, dict(kwargs.get("extra_headers") or {})))
|
||||||
|
node_id = path.rsplit("/", 1)[-1]
|
||||||
|
return {**nodes.get(node_id, next(iter(nodes.values()))), **(payload or {})}
|
||||||
|
|
||||||
|
mod.request_json = fake_request
|
||||||
|
return writes
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_and_kanban_writes_send_scoped_idempotency_keys(mod):
|
||||||
|
card = kanban_card("first", "high", "00001024")
|
||||||
|
card["id"] = "card-1"
|
||||||
|
target = {"id": "target-1", "project_id": "project-1", "full_path": "/plans/t", "links": []}
|
||||||
|
writes = node_write_backend(mod, {"card-1": card, "target-1": target})
|
||||||
|
|
||||||
|
linked = mod.call_tool_impl("nap_ln", {"source_node_id": "card-1", "target_node_id": "target-1"})
|
||||||
|
assert writes[-1][0:2] == ("PATCH", "/v1/projects/project-1/nodes/card-1")
|
||||||
|
assert linked["idempotency_key"] == writes[-1][2]["Idempotency-Key"]
|
||||||
|
uuid.UUID(linked["idempotency_key"])
|
||||||
|
|
||||||
|
started = mod.call_tool_impl("nap_kanban_start", {"node_id": "card-1", "idempotency_key": "start-1"})
|
||||||
|
assert writes[-1][2]["Idempotency-Key"] == "start-1"
|
||||||
|
assert started["idempotency_key"] == "start-1"
|
||||||
|
|
||||||
|
tools = {tool["name"]: tool for tool in mod.all_tools()}
|
||||||
|
for name in ("nap_ln", "nap_kanban_start", "nap_kanban_update", "nap_agent_tee", "nap_mv_folder"):
|
||||||
|
assert "idempotency_key" in tools[name]["inputSchema"]["properties"], name
|
||||||
|
assert mod.tool_contract_metadata(name)["idempotency"] == "same-idempotency-key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_write_tools_derive_keys_and_surface_them_on_failure(mod):
|
||||||
|
nodes = {
|
||||||
|
"a": {"id": "a", "project_id": "project-1", "full_path": "/old/a", "folder_path": "/old", "name": "a"},
|
||||||
|
"b": {"id": "b", "project_id": "project-1", "full_path": "/old/b", "folder_path": "/old", "name": "b"},
|
||||||
|
}
|
||||||
|
writes = node_write_backend(mod, nodes)
|
||||||
|
moved = mod.call_tool_impl("nap_mv_folder", {"from_folder": "/old", "to_folder": "/new", "idempotency_key": "mvf"})
|
||||||
|
assert [w[2]["Idempotency-Key"] for w in writes] == ["mvf", "mvf:1"]
|
||||||
|
assert moved["idempotency_key"] == "mvf" and moved["idempotency_keys"] == ["mvf", "mvf:1"]
|
||||||
|
|
||||||
|
def ambiguous(method, path, payload=None, **kwargs):
|
||||||
|
raise mod.SafeToolError("request_timeout", "timed out")
|
||||||
|
|
||||||
|
mod.request_json = ambiguous
|
||||||
|
with pytest.raises(mod.SafeToolError) as caught:
|
||||||
|
mod.call_tool_impl("nap_create_node", {"folder_path": "/notes", "name": "x", "content_text": "x"})
|
||||||
|
uuid.UUID(caught.value.details["idempotency_key"])
|
||||||
|
|
||||||
|
assert mod.node_write_idempotency_headers("POST", "/v1/projects/p/nodes/n/restore") == {}
|
||||||
|
assert mod.node_write_idempotency_headers("DELETE", "/v1/projects/p/nodes/n") == {}
|
||||||
|
assert "Idempotency-Key" in mod.node_write_idempotency_headers("PATCH", "/v1/projects/p/nodes/n/patch")
|
||||||
@@ -153,6 +153,18 @@ def indexed_node(path, *, node_type="note", tags=None, status="active", content=
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def feed_provider(calls):
|
||||||
|
"""Synthetic server: empty change feed and no newer nodes."""
|
||||||
|
|
||||||
|
def request(method, path, payload=None, **_kwargs):
|
||||||
|
calls.append((method, path))
|
||||||
|
if "/changes?" in path:
|
||||||
|
return {"items": [], "next_cursor": "c0", "has_more": False}
|
||||||
|
return {"items": [], "next_cursor": None}
|
||||||
|
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
def configure_local_index(mod, tmp_path):
|
def configure_local_index(mod, tmp_path):
|
||||||
mod.AUTH_DIR = tmp_path
|
mod.AUTH_DIR = tmp_path
|
||||||
mod.INDEX_PATH = tmp_path / "index.sqlite"
|
mod.INDEX_PATH = tmp_path / "index.sqlite"
|
||||||
@@ -208,11 +220,14 @@ def test_complete_local_compact_browse_uses_keyset_cursor_without_http(tmp_path)
|
|||||||
item["updated_at"] = f"2026-08-2{index}T00:00:00Z"
|
item["updated_at"] = f"2026-08-2{index}T00:00:00Z"
|
||||||
mod.index_node(item)
|
mod.index_node(item)
|
||||||
mod.set_local_graph_index_complete("project-1", True)
|
mod.set_local_graph_index_complete("project-1", True)
|
||||||
|
calls = []
|
||||||
|
mod.TOKEN = "disposable-test-token" # synthetic login; requests go to feed_provider only
|
||||||
|
mod.request_json = feed_provider(calls)
|
||||||
|
# The first read starts change-feed tracking for this cursorless index.
|
||||||
|
mod.discover_memory({"folder_path": "/plans", "limit": 1, "view": "paths"})
|
||||||
|
assert [path.split("?")[0].rsplit("/", 1)[-1] for _method, path in calls] == ["changes", "nodes"]
|
||||||
|
calls.clear()
|
||||||
|
|
||||||
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(
|
first = mod.discover_memory(
|
||||||
{
|
{
|
||||||
"folder_path": "/plans",
|
"folder_path": "/plans",
|
||||||
@@ -241,6 +256,9 @@ def test_complete_local_compact_browse_uses_keyset_cursor_without_http(tmp_path)
|
|||||||
)
|
)
|
||||||
assert not ({item["node_id"] for item in first["items"]} & {item["node_id"] for item in second["items"]})
|
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"])
|
assert all(item["node_id"] for item in first["items"] + second["items"])
|
||||||
|
# Steady state inside the refresh throttle window: no HTTP at all.
|
||||||
|
assert calls == []
|
||||||
|
assert first["freshness"]["feed_sync"] == "throttled"
|
||||||
|
|
||||||
|
|
||||||
def test_local_compact_browse_cursor_is_bound_to_filters(tmp_path):
|
def test_local_compact_browse_cursor_is_bound_to_filters(tmp_path):
|
||||||
@@ -482,11 +500,13 @@ def test_complete_filtered_local_search_stays_off_network(tmp_path):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
mod.set_local_graph_index_complete("project-1", True)
|
mod.set_local_graph_index_complete("project-1", True)
|
||||||
|
calls = []
|
||||||
|
mod.TOKEN = "disposable-test-token" # synthetic login; requests go to feed_provider only
|
||||||
|
mod.request_json = feed_provider(calls)
|
||||||
|
mod.search_memory({"q": "facturación", "tag": "billing", "limit": 1, "view": "summary"})
|
||||||
|
assert len(calls) == 2 # one-time change-feed bootstrap: head + catch-up
|
||||||
|
calls.clear()
|
||||||
|
|
||||||
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(
|
result = mod.search_memory(
|
||||||
{
|
{
|
||||||
"q": "facturación",
|
"q": "facturación",
|
||||||
@@ -500,6 +520,8 @@ def test_complete_filtered_local_search_stays_off_network(tmp_path):
|
|||||||
assert [item["full_path"] for item in result["items"]] == [
|
assert [item["full_path"] for item in result["items"]] == [
|
||||||
"/documentation/facturacion-electronica"
|
"/documentation/facturacion-electronica"
|
||||||
]
|
]
|
||||||
|
assert calls == [] # a complete, tracked, recently refreshed index stays off the network
|
||||||
|
assert not result.get("warnings")
|
||||||
|
|
||||||
|
|
||||||
def test_discovery_schema_documents_dynamic_sort_and_intent():
|
def test_discovery_schema_documents_dynamic_sort_and_intent():
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""Local search quality: FTS schema v2, weighted ranking and query snippets.
|
||||||
|
|
||||||
|
Every test runs the real index, query planner and ranking code against a
|
||||||
|
disposable SQLite file; no network is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import pathlib
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
|
||||||
|
|
||||||
|
|
||||||
|
def load_module(tmp_path):
|
||||||
|
sys.path.insert(0, str(SCRIPT.parent))
|
||||||
|
spec = importlib.util.spec_from_file_location("napseer_mcp_server_search_quality_test", SCRIPT)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
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"
|
||||||
|
mod.project_encryption_state = lambda _project_id: "standard"
|
||||||
|
|
||||||
|
def unexpected_request(*_args, **_kwargs):
|
||||||
|
raise AssertionError("local search quality tests must not use the network")
|
||||||
|
|
||||||
|
mod.request_json = unexpected_request
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def indexed_node(path, *, content="", status="active", updated_at="2026-08-22T00:00:00Z", **extra):
|
||||||
|
name = path.rsplit("/", 1)[-1]
|
||||||
|
node = {
|
||||||
|
"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": "note",
|
||||||
|
"tags": [],
|
||||||
|
"aliases": [],
|
||||||
|
"links": [],
|
||||||
|
"metadata": {"status": status},
|
||||||
|
"content_text": content,
|
||||||
|
"encryption_state": "legacy_plaintext",
|
||||||
|
"updated_at": updated_at,
|
||||||
|
}
|
||||||
|
node.update(extra)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def paths(result):
|
||||||
|
return [item["full_path"] for item in result["items"]]
|
||||||
|
|
||||||
|
|
||||||
|
# --- FTS schema v2 ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_stemming_recalls_other_inflections(tmp_path):
|
||||||
|
mod = load_module(tmp_path)
|
||||||
|
mod.index_node(indexed_node("/notes/release-log", content="The deployment pipeline promotes images."))
|
||||||
|
|
||||||
|
assert paths(mod.search_local_index({"q": "deploying", "limit": 5})) == ["/notes/release-log"]
|
||||||
|
assert paths(mod.search_local_index({"q": "configured deployments", "limit": 5})) == ["/notes/release-log"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefix_query_uses_prefix_index_and_matches(tmp_path):
|
||||||
|
mod = load_module(tmp_path)
|
||||||
|
mod.index_node(indexed_node("/notes/cluster", content="kubernetes control plane"))
|
||||||
|
|
||||||
|
with mod.index_connect() as conn:
|
||||||
|
sql = conn.execute(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE name = 'local_index_fts'"
|
||||||
|
).fetchone()["sql"]
|
||||||
|
assert "porter unicode61 remove_diacritics 2" in sql
|
||||||
|
assert "prefix = '2 3'" in sql
|
||||||
|
assert paths(mod.search_local_index({"q": "kuber", "limit": 5})) == ["/notes/cluster"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_character_terms_are_searchable(tmp_path):
|
||||||
|
mod = load_module(tmp_path)
|
||||||
|
mod.index_node(indexed_node("/notes/note-a", content="db migration runbook for postgres cutover steps"))
|
||||||
|
mod.index_node(indexed_node("/notes/note-b", content="migration", updated_at="2026-09-01T00:00:00Z"))
|
||||||
|
|
||||||
|
analysis = mod.analyze_search_query("db migration")
|
||||||
|
result = mod.search_local_index({"q": "db migration", "limit": 5})
|
||||||
|
|
||||||
|
assert "db" in analysis["terms"]
|
||||||
|
assert paths(result)[0] == "/notes/note-a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_domain_words_are_not_stop_words():
|
||||||
|
mod = load_module(pathlib.Path("/nonexistent"))
|
||||||
|
concepts = mod.analyze_search_query("plan work decide")["concepts"]
|
||||||
|
|
||||||
|
assert {"plan", "work", "decide"} <= set(concepts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_node_fts_delete_uses_rowid_not_scan(tmp_path, monkeypatch):
|
||||||
|
mod = load_module(tmp_path)
|
||||||
|
node = indexed_node("/notes/rowid-target", content="first version")
|
||||||
|
mod.index_node(node)
|
||||||
|
statements = []
|
||||||
|
original_open = mod.open_index_connection
|
||||||
|
|
||||||
|
def traced_open():
|
||||||
|
conn = original_open()
|
||||||
|
conn.set_trace_callback(statements.append)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "open_index_connection", traced_open)
|
||||||
|
mod.index_node({**node, "content_text": "second version"})
|
||||||
|
mod.remove_indexed_node(node["id"])
|
||||||
|
|
||||||
|
fts_deletes = [s for s in statements if "DELETE FROM local_index_fts" in s]
|
||||||
|
assert fts_deletes
|
||||||
|
assert all("rowid" in s and "node_id" not in s for s in fts_deletes)
|
||||||
|
with mod.index_connect() as conn:
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM local_index_fts").fetchone()[0] == 0
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM local_index_nodes").fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_reindexing_a_node_replaces_its_single_fts_row(tmp_path):
|
||||||
|
mod = load_module(tmp_path)
|
||||||
|
node = indexed_node("/notes/replaced", content="alpha")
|
||||||
|
mod.index_node(node)
|
||||||
|
mod.index_node({**node, "content_text": "omega"})
|
||||||
|
|
||||||
|
with mod.index_connect() as conn:
|
||||||
|
rows = conn.execute("SELECT rowid, content_text FROM local_index_fts").fetchall()
|
||||||
|
mapped = conn.execute("SELECT fts_rowid FROM local_index_nodes").fetchone()[0]
|
||||||
|
assert [row["content_text"] for row in rows] == ["omega"]
|
||||||
|
assert rows[0]["rowid"] == mapped
|
||||||
|
|
||||||
|
|
||||||
|
V1_SCHEMA = [
|
||||||
|
"""CREATE TABLE local_index_nodes (
|
||||||
|
node_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, full_path TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL, folder_path TEXT, node_type TEXT NOT NULL, tags_json TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL, indexed_at TEXT NOT NULL, title TEXT, status TEXT,
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0, aliases_json TEXT NOT NULL DEFAULT '[]')""",
|
||||||
|
"""CREATE TABLE local_index_state (
|
||||||
|
project_id TEXT PRIMARY KEY, graph_complete INTEGER NOT NULL DEFAULT 0, graph_completed_at TEXT)""",
|
||||||
|
"""CREATE VIRTUAL TABLE local_index_fts USING fts5(
|
||||||
|
node_id UNINDEXED, project_id UNINDEXED, full_path, name, folder_path, node_type,
|
||||||
|
tags, aliases, links, metadata_text, content_text)""",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_v1_index_upgrades_in_place_and_keeps_completeness(tmp_path):
|
||||||
|
index_path = tmp_path / "index.sqlite"
|
||||||
|
conn = sqlite3.connect(str(index_path))
|
||||||
|
for statement in V1_SCHEMA:
|
||||||
|
conn.execute(statement)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_index_nodes VALUES ('n1','project-1','/notes/legacy','legacy','/notes','note','[]',"
|
||||||
|
"'2026-08-01T00:00:00Z','2026-08-01T00:00:00Z','Legacy title','active',0,'[]')"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_index_fts VALUES ('n1','project-1','/notes/legacy','legacy','/notes','note','','','',"
|
||||||
|
"'','the deployment runbook body')"
|
||||||
|
)
|
||||||
|
conn.execute("INSERT INTO local_index_state VALUES ('project-1', 1, '2026-08-01T00:00:00Z')")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
mod = load_module(tmp_path)
|
||||||
|
result = mod.search_local_index({"q": "deploying", "limit": 5})
|
||||||
|
|
||||||
|
assert paths(result) == ["/notes/legacy"]
|
||||||
|
with mod.index_connect() as conn:
|
||||||
|
assert conn.execute("PRAGMA user_version").fetchone()[0] == mod.INDEX_SCHEMA_VERSION
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM local_index_fts").fetchone()[0] == 1
|
||||||
|
assert "porter" in conn.execute(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE name = 'local_index_fts'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert conn.execute("SELECT fts_rowid FROM local_index_nodes").fetchone()[0] is not None
|
||||||
|
assert mod.local_index_diagnostics("project-1")["graph_complete"] is True
|
||||||
|
# The upgrade drops no rows and a legacy-migrated index has no feed cursor yet.
|
||||||
|
assert mod.local_index_feed_state("project-1")["feed_cursor"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- ranking --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_body_full_coverage_beats_authority_path_partial_match(tmp_path):
|
||||||
|
mod = load_module(tmp_path)
|
||||||
|
mod.index_node(indexed_node("/rules/quasar", content="unrelated governance text"))
|
||||||
|
mod.index_node(
|
||||||
|
indexed_node(
|
||||||
|
"/work/current/notes-a",
|
||||||
|
content="The quasar rollout checklist is kept here with every gate listed.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = mod.search_local_index({"q": "quasar rollout checklist", "limit": 5})
|
||||||
|
|
||||||
|
assert paths(result) == ["/work/current/notes-a", "/rules/quasar"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_superseded_status_is_demoted_unless_requested(tmp_path):
|
||||||
|
mod = load_module(tmp_path)
|
||||||
|
mod.index_node(
|
||||||
|
indexed_node("/decisions/retry-a", content="gateway retry budget", status="superseded",
|
||||||
|
updated_at="2026-09-10T00:00:00Z")
|
||||||
|
)
|
||||||
|
mod.index_node(
|
||||||
|
indexed_node("/decisions/retry-b", content="gateway retry budget", status="active",
|
||||||
|
updated_at="2026-08-01T00:00:00Z")
|
||||||
|
)
|
||||||
|
|
||||||
|
default = mod.search_local_index({"q": "gateway retry budget", "limit": 5})
|
||||||
|
requested = mod.search_local_index({"q": "superseded gateway retry budget", "limit": 5})
|
||||||
|
|
||||||
|
assert paths(default) == ["/decisions/retry-b", "/decisions/retry-a"]
|
||||||
|
assert paths(requested)[0] == "/decisions/retry-a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_exact_identity_stays_on_top_of_requested_intent(tmp_path):
|
||||||
|
mod = load_module(tmp_path)
|
||||||
|
mod.index_node(indexed_node("/notes/cache-invalidation", content="cache invalidation"))
|
||||||
|
mod.index_node(indexed_node("/decisions/cache-layer", content="cache invalidation " * 5))
|
||||||
|
|
||||||
|
result = mod.search_local_index({"q": "cache invalidation", "intent": "decision", "limit": 5})
|
||||||
|
|
||||||
|
assert paths(result)[0] == "/notes/cache-invalidation"
|
||||||
|
assert "exact_identity" in result["items"][0]["match_reason"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- snippets -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_search_returns_query_centred_snippet(tmp_path):
|
||||||
|
mod = load_module(tmp_path)
|
||||||
|
filler = "Unrelated operational background sentence. " * 20
|
||||||
|
mod.index_node(
|
||||||
|
indexed_node("/notes/long-note", content=f"{filler}The zanzibar failover drill runs weekly. {filler}")
|
||||||
|
)
|
||||||
|
mod.set_local_graph_index_complete("project-1", True)
|
||||||
|
|
||||||
|
local = mod.search_local_index({"q": "zanzibar", "limit": 5})
|
||||||
|
envelope = mod.search_memory({"q": "zanzibar", "limit": 5, "view": "summary", "mode": "local"})
|
||||||
|
|
||||||
|
assert "zanzibar" in local["items"][0]["snippet"].lower()
|
||||||
|
assert "zanzibar" in envelope["items"][0]["snippet"].lower()
|
||||||
|
assert "zanzibar" in envelope["items"][0]["preview"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_nodes_have_no_snippet_or_body_index(tmp_path):
|
||||||
|
mod = load_module(tmp_path)
|
||||||
|
mod.index_node(
|
||||||
|
indexed_node(
|
||||||
|
"/notes/zanzibar-sealed",
|
||||||
|
content="plaintext-secret-marker zanzibar",
|
||||||
|
encryption_state="encrypted",
|
||||||
|
encrypted_content_envelope={"ciphertext": "opaque"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mod.search_local_index({"q": "plaintext-secret-marker", "limit": 5})["items"] == []
|
||||||
|
result = mod.search_local_index({"q": "zanzibar", "limit": 5})
|
||||||
|
assert paths(result) == ["/notes/zanzibar-sealed"]
|
||||||
|
assert not result["items"][0].get("snippet")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("query", ["memory-source-policy", "memory source policy"])
|
||||||
|
def test_hyphenated_identity_query_matches_tokenized_path(tmp_path, query):
|
||||||
|
mod = load_module(tmp_path)
|
||||||
|
mod.index_node(indexed_node("/rules/memory-source-policy", content="Canonical memory rule."))
|
||||||
|
mod.index_node(indexed_node("/notes/other", content="policy about memory elsewhere"))
|
||||||
|
|
||||||
|
assert paths(mod.search_local_index({"q": query, "limit": 5}))[0] == "/rules/memory-source-policy"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query, expected",
|
||||||
|
[
|
||||||
|
("existing-alias", "node-notes-first"),
|
||||||
|
("secondaryhandle", "node-notes-second"),
|
||||||
|
("/notes/existing-work", "node-notes-existing-work"),
|
||||||
|
("existing-work", "node-notes-existing-work"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_discover_local_literal_alias_or_path_is_top_exact_identity(tmp_path, query, expected):
|
||||||
|
mod = load_module(tmp_path)
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = {"all"}
|
||||||
|
mod.require_unlocked = lambda _name: None
|
||||||
|
mod.index_node(indexed_node("/notes/first", content="body", aliases=["existing-alias"]))
|
||||||
|
mod.index_node(indexed_node("/notes/second", content="body", aliases=["secondaryhandle"]))
|
||||||
|
mod.index_node(indexed_node("/notes/existing-work", content="body"))
|
||||||
|
# Authoritative distractor whose body repeats the words of every literal.
|
||||||
|
mod.index_node(
|
||||||
|
indexed_node(
|
||||||
|
"/rules/distractor",
|
||||||
|
content="existing alias existing work secondaryhandle notes " * 4,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mod.set_local_graph_index_complete("project-1", True)
|
||||||
|
|
||||||
|
result = mod.call_tool_impl("nap_discover", {"q": query, "mode": "local", "view": "summary"})
|
||||||
|
|
||||||
|
assert result["items"][0]["node_id"] == expected
|
||||||
|
assert "exact_identity" in result["items"][0]["match_reason"]
|
||||||
@@ -167,7 +167,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.TOKEN = "synthetic-configured-access"
|
||||||
mod.AUTH_PATH = tmp_path / "missing-auth.json"
|
mod.AUTH_PATH = mod.AUTH_DIR / "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)
|
||||||
calls = []
|
calls = []
|
||||||
|
|||||||
@@ -0,0 +1,674 @@
|
|||||||
|
"""Project memory secret provisioned once into the local vault, loaded automatically.
|
||||||
|
|
||||||
|
Runs the real wrapper (in-process and as fresh CLI/MCP processes) against a
|
||||||
|
disposable HTTP provider that serves a memory secret wrapped with a fixture
|
||||||
|
passphrase. The fixture secret and passphrase are random per test and every
|
||||||
|
tool output, error, log line and state file is scanned for them (raw, base64,
|
||||||
|
hex) without ever printing them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import pty
|
||||||
|
import queue
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import termios
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py"
|
||||||
|
PROJECT = "project-vault-1"
|
||||||
|
ACCOUNT = "22222222-2222-2222-2222-222222222222"
|
||||||
|
BODY_TERM = "zephyrquartz"
|
||||||
|
BODY = f"calibrate the {BODY_TERM} manifold before launch"
|
||||||
|
SETUP_COMMAND = "nap gateway vault setup"
|
||||||
|
|
||||||
|
|
||||||
|
# -- fixture material ---------------------------------------------------------
|
||||||
|
|
||||||
|
class Material:
|
||||||
|
def __init__(self):
|
||||||
|
self.passphrase = "fixture-" + os.urandom(12).hex()
|
||||||
|
self.secrets = {}
|
||||||
|
|
||||||
|
def secret(self, version):
|
||||||
|
return self.secrets.setdefault(version, os.urandom(32))
|
||||||
|
|
||||||
|
def forms(self):
|
||||||
|
forms = [self.passphrase.encode("utf-8")]
|
||||||
|
for value in self.secrets.values():
|
||||||
|
encoded = base64.b64encode(value)
|
||||||
|
forms += [value, encoded, encoded.rstrip(b"="), base64.urlsafe_b64encode(value).rstrip(b"="),
|
||||||
|
value.hex().encode("ascii")]
|
||||||
|
return forms
|
||||||
|
|
||||||
|
|
||||||
|
def assert_no_leak(material, label, blob):
|
||||||
|
data = blob if isinstance(blob, bytes) else json.dumps(blob, default=str).encode("utf-8") \
|
||||||
|
if not isinstance(blob, str) else blob.encode("utf-8")
|
||||||
|
for form in material.forms():
|
||||||
|
if form in data:
|
||||||
|
pytest.fail(f"fixture secret material leaked into {label}") # never print the value
|
||||||
|
|
||||||
|
|
||||||
|
def assert_state_dir_clean(material, *roots):
|
||||||
|
for root in roots:
|
||||||
|
for path in pathlib.Path(root).rglob("*"):
|
||||||
|
if path.is_file():
|
||||||
|
assert_no_leak(material, f"state file {path.name}", path.read_bytes())
|
||||||
|
|
||||||
|
|
||||||
|
# -- disposable provider ------------------------------------------------------
|
||||||
|
|
||||||
|
def secret_record(material, mod, version, wrapping_epoch=1):
|
||||||
|
previous = mod.PROJECT_VAULT_PASSPHRASE
|
||||||
|
mod.PROJECT_VAULT_PASSPHRASE = material.passphrase
|
||||||
|
try:
|
||||||
|
bundle = mod.generate_project_data_key_bundle(PROJECT, ACCOUNT, data_key_epoch=version)
|
||||||
|
bundle["keys"]["memory"]["versions"][str(version)]["key_b64"] = base64.b64encode(
|
||||||
|
material.secret(version)).decode("ascii")
|
||||||
|
wrapped = mod.wrapped_project_key_bundle_record(
|
||||||
|
PROJECT, ACCOUNT, "memory", bundle,
|
||||||
|
wrapping_epoch=wrapping_epoch, bundle_version=version, data_key_epoch=version)
|
||||||
|
finally:
|
||||||
|
mod.PROJECT_VAULT_PASSPHRASE = previous
|
||||||
|
return {
|
||||||
|
"secret_kind": "memory", "version": version, "project_secret_version": version,
|
||||||
|
"wrapping_epoch": wrapping_epoch, "bundle_version": version, "data_key_epoch": version,
|
||||||
|
"wrapped_key_bundle": wrapped, "created_at": "2026-09-01T00:00:00Z", "last_rotated_at": "2026-09-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def e2e_node(mod, material, name, version, content, minute=1):
|
||||||
|
secret = {
|
||||||
|
"project_id": PROJECT, "secret_kind": "memory", "version": version, "wrapping_epoch": 1,
|
||||||
|
"bundle_version": version, "data_key_epoch": version, "secret_bytes": material.secret(version),
|
||||||
|
}
|
||||||
|
stamp = f"2026-09-01T12:{minute:02d}:00Z"
|
||||||
|
return {
|
||||||
|
"id": name, "project_id": PROJECT, "full_path": f"/notes/{name}", "folder_path": "/notes",
|
||||||
|
"name": name, "type": "note", "metadata": {}, "tags": [], "aliases": [], "links": [],
|
||||||
|
"content_text": "", "updated_at": stamp, "archived_at": None, "encryption_state": "encrypted",
|
||||||
|
"project_secret_version": version,
|
||||||
|
"encrypted_content_envelope": mod.encrypt_memory_bytes(
|
||||||
|
PROJECT, secret, "node_content", content.encode("utf-8"), {"full_path": f"/notes/{name}"},
|
||||||
|
"text/plain; charset=utf-8"),
|
||||||
|
"encrypted_metadata_envelope": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Provider:
|
||||||
|
def __init__(self):
|
||||||
|
self.nodes = {}
|
||||||
|
self.events = []
|
||||||
|
self.requests = []
|
||||||
|
self.active = None
|
||||||
|
self.versions = {}
|
||||||
|
self.vault_down = False
|
||||||
|
|
||||||
|
def publish(self, record):
|
||||||
|
self.active = record
|
||||||
|
self.versions[record["version"]] = record
|
||||||
|
|
||||||
|
def put(self, node):
|
||||||
|
self.nodes[node["id"]] = node
|
||||||
|
self.events.append({"type": "node_created", "node_id": node["id"], "full_path": node["full_path"],
|
||||||
|
"new_revision": node["updated_at"]})
|
||||||
|
|
||||||
|
|
||||||
|
def start_provider(provider):
|
||||||
|
vault_re = re.compile(rf"/v1/projects/{PROJECT}/vault/secrets/memory/(active|versions/(\d+))")
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *_args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def reply(self, status, payload):
|
||||||
|
body = json.dumps(payload).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
provider.requests.append(("POST", urlsplit(self.path).path))
|
||||||
|
self.reply(404, {"error": {"code": "not_found", "message": "not served"}})
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
url = urlsplit(self.path)
|
||||||
|
query = parse_qs(url.query)
|
||||||
|
provider.requests.append(("GET", url.path))
|
||||||
|
match = vault_re.fullmatch(url.path)
|
||||||
|
if match:
|
||||||
|
if provider.vault_down:
|
||||||
|
return self.reply(503, {"error": {"code": "unavailable", "message": "vault down"}})
|
||||||
|
record = provider.active if match.group(1) == "active" else provider.versions.get(int(match.group(2)))
|
||||||
|
if record is None:
|
||||||
|
return self.reply(404, {"error": {"code": "not_found", "message": "no such secret"}})
|
||||||
|
return self.reply(200, record)
|
||||||
|
if url.path.endswith("/encryption/status"):
|
||||||
|
return self.reply(200, {"state": "encrypted"})
|
||||||
|
if url.path.endswith("/changes"):
|
||||||
|
after = query.get("after", ["head"])[0]
|
||||||
|
start = len(provider.events) if after == "head" else int(after[1:])
|
||||||
|
page = provider.events[start:]
|
||||||
|
items = [{**event, "cursor": f"c{start + i + 1}", "project_id": PROJECT, "changed_fields": [],
|
||||||
|
"created_at": "2026-09-01T12:00:00Z"} for i, event in enumerate(page)]
|
||||||
|
return self.reply(200, {"items": items, "next_cursor": f"c{start + len(page)}", "has_more": False})
|
||||||
|
if url.path == f"/v1/projects/{PROJECT}/nodes":
|
||||||
|
rows = sorted(provider.nodes.values(), key=lambda n: (n["updated_at"], n["id"]), reverse=True)
|
||||||
|
return self.reply(200, {"items": rows, "next_cursor": None})
|
||||||
|
node_id = url.path.rsplit("/", 1)[-1]
|
||||||
|
if "/nodes/" in url.path and node_id in provider.nodes:
|
||||||
|
return self.reply(200, provider.nodes[node_id])
|
||||||
|
return self.reply(404, {"error": {"code": "not_found", "message": "not served"}})
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return server, thread
|
||||||
|
|
||||||
|
|
||||||
|
def load_module(name, state_dir, base_url):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, SCRIPT)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
mod.AUTH_DIR = state_dir
|
||||||
|
mod.INDEX_PATH = state_dir / "index.sqlite"
|
||||||
|
mod.INDEX_LOCK_PATH = state_dir / "index.lock"
|
||||||
|
mod.VAULT_PATH = state_dir / "vault.json"
|
||||||
|
mod.LOCAL_VAULT_KEY_PATH = state_dir / "vault.local-key"
|
||||||
|
mod.MEMORY_SECRET_VAULT_PATH = state_dir / "memory-secrets.vault.json"
|
||||||
|
mod.BASE_URL = base_url
|
||||||
|
mod.TOKEN = "disposable-test-token"
|
||||||
|
mod.AUTH = {**mod.AUTH, "account_id": ACCOUNT, "project_id": PROJECT}
|
||||||
|
mod.DEFAULT_PROJECT_ID = PROJECT
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = {"all"}
|
||||||
|
mod.refresh_public_auth_state = lambda: None
|
||||||
|
mod.resolve_project_id = lambda args: args.get("project_id", PROJECT)
|
||||||
|
mod.project_encryption_state = lambda _project_id: "encrypted"
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env(tmp_path, monkeypatch):
|
||||||
|
for name in ("NAPSEER_VAULT_PASSPHRASE", "NAPSEER_MASTER_PASSPHRASE", "NAPSEER_LOCAL_VAULT_KEY"):
|
||||||
|
monkeypatch.delenv(name, raising=False)
|
||||||
|
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "all")
|
||||||
|
monkeypatch.setenv("NAPSEER_TELEMETRY", "0")
|
||||||
|
sqlite_tmp = tmp_path / "sqlite-tmp"
|
||||||
|
sqlite_tmp.mkdir()
|
||||||
|
monkeypatch.setenv("SQLITE_TMPDIR", str(sqlite_tmp))
|
||||||
|
monkeypatch.syspath_prepend(str(SCRIPT.parent))
|
||||||
|
provider = Provider()
|
||||||
|
server, thread = start_provider(provider)
|
||||||
|
base_url = f"http://127.0.0.1:{server.server_port}"
|
||||||
|
state_dir = tmp_path / "state"
|
||||||
|
mod = load_module("memory_vault_worker_a", state_dir, base_url)
|
||||||
|
material = Material()
|
||||||
|
provider.publish(secret_record(material, mod, 1))
|
||||||
|
provider.put(e2e_node(mod, material, "secretnote", 1, BODY))
|
||||||
|
try:
|
||||||
|
yield mod, provider, material, state_dir, base_url
|
||||||
|
finally:
|
||||||
|
mod.e2e_shards_close(flush=False)
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
def fresh_process_state(mod):
|
||||||
|
"""What a new worker starts with: no cached secret, no passphrase."""
|
||||||
|
mod.e2e_shards_close(flush=True)
|
||||||
|
mod.MEMORY_SECRET_CACHE.clear()
|
||||||
|
mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear()
|
||||||
|
mod.MEMORY_SECRET_ROTATION_OBSERVED.clear()
|
||||||
|
mod.PROJECT_VAULT_PASSPHRASE = None
|
||||||
|
|
||||||
|
|
||||||
|
def error_of(callable_, *args):
|
||||||
|
with pytest.raises(Exception) as info:
|
||||||
|
callable_(*args)
|
||||||
|
return info.value
|
||||||
|
|
||||||
|
|
||||||
|
# -- in-process behavior ------------------------------------------------------
|
||||||
|
|
||||||
|
def test_setup_stores_secret_not_passphrase_and_loads_without_it(env, capsys):
|
||||||
|
mod, provider, material, state_dir, _ = env
|
||||||
|
before = error_of(mod.active_memory_secret, PROJECT)
|
||||||
|
assert getattr(before, "code", None) == "memory_secret_not_provisioned"
|
||||||
|
assert mod.memory_secret_status(PROJECT)["memory_secret"] == "not_provisioned"
|
||||||
|
|
||||||
|
result = mod.memory_secret_setup({"vault_passphrase": material.passphrase})
|
||||||
|
assert result["status"] == "provisioned" and result["memory_secret"] == "provisioned"
|
||||||
|
assert result["passphrase_stored"] is False and result["passphrase_source"] == "argument"
|
||||||
|
assert result["active_identity"] == {"version": 1, "wrapping_epoch": 1, "bundle_version": 1, "data_key_epoch": 1}
|
||||||
|
assert mod.PROJECT_VAULT_PASSPHRASE is None # not retained after setup
|
||||||
|
|
||||||
|
vault = mod.MEMORY_SECRET_VAULT_PATH
|
||||||
|
assert stat.S_IMODE(vault.stat().st_mode) == 0o600
|
||||||
|
assert stat.S_IMODE(mod.LOCAL_VAULT_KEY_PATH.stat().st_mode) == 0o600
|
||||||
|
assert json.loads(vault.read_text())["schema"] == "napseer.memory-secret-vault.v1"
|
||||||
|
|
||||||
|
fresh_process_state(mod)
|
||||||
|
requests_before = len(provider.requests)
|
||||||
|
secret = mod.active_memory_secret(PROJECT)
|
||||||
|
assert secret["secret_bytes"] == material.secret(1)
|
||||||
|
# Identity comes from the server; no unwrap or passphrase was involved.
|
||||||
|
assert provider.requests[requests_before:] == [("GET", f"/v1/projects/{PROJECT}/vault/secrets/memory/active")]
|
||||||
|
whoami = mod.gateway_status()
|
||||||
|
assert whoami["memory_secret"] == "provisioned" and "deprecations" not in whoami
|
||||||
|
assert whoami["memory_secret_versions"] == [1]
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
for label, blob in (("setup result", result), ("whoami", whoami), ("stdout", captured.out),
|
||||||
|
("stderr", captured.err)):
|
||||||
|
assert_no_leak(material, label, blob)
|
||||||
|
assert_state_dir_clean(material, state_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_search_uses_provisioned_secret_and_reports_not_provisioned(env):
|
||||||
|
mod, provider, material, state_dir, _ = env
|
||||||
|
mod.reindex_project({})
|
||||||
|
unprovisioned = mod.search_local_index({"q": BODY_TERM, "limit": 5})
|
||||||
|
assert unprovisioned["items"] == []
|
||||||
|
warning = unprovisioned["warnings"][0]
|
||||||
|
assert warning["code"] == "e2e_index_not_provisioned"
|
||||||
|
assert warning["memory_secret"] == "not_provisioned" and warning["next_action"] == SETUP_COMMAND
|
||||||
|
|
||||||
|
mod.memory_secret_setup({"vault_passphrase": material.passphrase})
|
||||||
|
fresh_process_state(mod)
|
||||||
|
mod.sync_local_index({})
|
||||||
|
found = mod.search_local_index({"q": BODY_TERM, "limit": 5})
|
||||||
|
assert [item["id"] for item in found["items"]] == ["secretnote"]
|
||||||
|
assert found["items"][0]["search_source"] == "local_e2e" and not found.get("warnings")
|
||||||
|
mod.e2e_shards_close(flush=True)
|
||||||
|
assert_state_dir_clean(material, state_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_is_reported_older_versions_still_read_and_passphrase_reprovisions(env):
|
||||||
|
mod, provider, material, state_dir, _ = env
|
||||||
|
mod.memory_secret_setup({"vault_passphrase": material.passphrase})
|
||||||
|
provider.publish(secret_record(material, mod, 2))
|
||||||
|
provider.put(e2e_node(mod, material, "newnote", 2, "second body", minute=2))
|
||||||
|
|
||||||
|
fresh_process_state(mod)
|
||||||
|
rotated = error_of(mod.active_memory_secret, PROJECT)
|
||||||
|
assert rotated.code == "memory_secret_rotated"
|
||||||
|
assert rotated.details == {"memory_secret": "rotated", "next_action": SETUP_COMMAND, "active_version": 2}
|
||||||
|
assert mod.gateway_status()["memory_secret"] == "rotated"
|
||||||
|
assert mod.gateway_status()["memory_secret_next_action"] == SETUP_COMMAND
|
||||||
|
# The stored entry still serves nodes written under the older version...
|
||||||
|
assert mod.memory_secret_for_read(PROJECT, 1)["secret_bytes"] == material.secret(1)
|
||||||
|
assert mod.decrypt_node_for_return(provider.nodes["secretnote"], project_id=PROJECT)["content_text"] == BODY
|
||||||
|
# ...but never the rotated active version.
|
||||||
|
assert error_of(mod.memory_secret_for_read, PROJECT, 2).code == "memory_secret_rotated"
|
||||||
|
mod.reindex_project({})
|
||||||
|
search = mod.search_local_index({"q": BODY_TERM, "limit": 5})
|
||||||
|
assert search["items"] == []
|
||||||
|
assert [(w["code"], w.get("memory_secret")) for w in search["warnings"]] == [("e2e_index_not_provisioned", "rotated")]
|
||||||
|
|
||||||
|
# A doctor in a fresh process detects the rotation from the server identity.
|
||||||
|
fresh_process_state(mod)
|
||||||
|
mod.local_anchor_integrity = lambda: {}
|
||||||
|
doctor = mod.doctor({})
|
||||||
|
assert doctor["memory_secret"]["memory_secret"] == "rotated"
|
||||||
|
assert any(SETUP_COMMAND in warning for warning in doctor["warnings"])
|
||||||
|
|
||||||
|
# A passphrase supplied through the tool-argument path re-provisions automatically.
|
||||||
|
mod.set_project_vault_passphrase(material.passphrase)
|
||||||
|
assert mod.active_memory_secret(PROJECT)["version"] == 2
|
||||||
|
fresh_process_state(mod)
|
||||||
|
assert mod.active_memory_secret(PROJECT)["secret_bytes"] == material.secret(2)
|
||||||
|
assert mod.gateway_status()["memory_secret"] == "provisioned"
|
||||||
|
assert mod.gateway_status()["memory_secret_versions"] == [2, 1]
|
||||||
|
for blob in (rotated.details, str(rotated), doctor):
|
||||||
|
assert_no_leak(material, "rotation output", blob)
|
||||||
|
mod.e2e_shards_close(flush=True)
|
||||||
|
assert_state_dir_clean(material, state_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_after_rotation_stores_active_and_older_versions(env):
|
||||||
|
mod, provider, material, _state_dir, _ = env
|
||||||
|
provider.publish(secret_record(material, mod, 2))
|
||||||
|
provider.publish(secret_record(material, mod, 3))
|
||||||
|
del provider.versions[1] # an unavailable older version is reported, not fatal
|
||||||
|
result = mod.memory_secret_setup({"vault_passphrase": material.passphrase})
|
||||||
|
assert result["active_identity"]["version"] == 3
|
||||||
|
assert result["older_versions_stored"] == [2] and result["older_versions_unavailable"] == [1]
|
||||||
|
fresh_process_state(mod)
|
||||||
|
assert mod.memory_secret_for_read(PROJECT, 2)["secret_bytes"] == material.secret(2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_forget_revokes_access_in_this_and_other_running_processes(env):
|
||||||
|
mod, provider, material, state_dir, base_url = env
|
||||||
|
mod.memory_secret_setup({"vault_passphrase": material.passphrase})
|
||||||
|
mod.reindex_project({})
|
||||||
|
assert [item["id"] for item in mod.search_local_index({"q": BODY_TERM})["items"]] == ["secretnote"]
|
||||||
|
mod.e2e_shards_close(flush=True)
|
||||||
|
assert mod.e2e_shard_path(PROJECT).exists()
|
||||||
|
|
||||||
|
# Another running worker has the secret cached in memory.
|
||||||
|
other = load_module("memory_vault_worker_b", state_dir, base_url)
|
||||||
|
other.MEMORY_SECRET_ROTATION_OBSERVED.clear()
|
||||||
|
assert other.active_memory_secret(PROJECT)["secret_bytes"] == material.secret(1)
|
||||||
|
|
||||||
|
removed = mod.memory_secret_forget({})
|
||||||
|
assert removed["status"] == "removed" and removed["removed_entries"] == 1
|
||||||
|
assert removed["memory_secret"] == "not_provisioned"
|
||||||
|
assert not mod.MEMORY_SECRET_VAULT_PATH.exists()
|
||||||
|
assert not mod.e2e_shard_path(PROJECT).exists()
|
||||||
|
|
||||||
|
assert error_of(mod.active_memory_secret, PROJECT).code == "memory_secret_not_provisioned"
|
||||||
|
# The other worker drops its cached copy on the next memory access.
|
||||||
|
assert error_of(other.active_memory_secret, PROJECT).code == "memory_secret_not_provisioned"
|
||||||
|
assert other.e2e_cached_memory_secret(PROJECT) is None
|
||||||
|
search = mod.search_local_index({"q": BODY_TERM})
|
||||||
|
assert search["items"] == [] and search["warnings"][0]["code"] == "e2e_index_not_provisioned"
|
||||||
|
assert mod.memory_secret_forget({})["status"] == "not_provisioned"
|
||||||
|
assert_no_leak(material, "forget output", removed)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("damage", ["tampered", "wrong_local_key", "missing_local_key", "garbage"])
|
||||||
|
def test_damaged_vault_fails_closed_without_leaking(env, capsys, damage):
|
||||||
|
mod, provider, material, state_dir, _ = env
|
||||||
|
mod.memory_secret_setup({"vault_passphrase": material.passphrase})
|
||||||
|
vault = mod.MEMORY_SECRET_VAULT_PATH
|
||||||
|
if damage == "tampered":
|
||||||
|
envelope = json.loads(vault.read_text())
|
||||||
|
ciphertext = bytearray(base64.b64decode(envelope["cipher"]["ciphertext"]))
|
||||||
|
ciphertext[5] ^= 0x01
|
||||||
|
envelope["cipher"]["ciphertext"] = base64.b64encode(bytes(ciphertext)).decode()
|
||||||
|
vault.write_text(json.dumps(envelope))
|
||||||
|
elif damage == "wrong_local_key":
|
||||||
|
mod.LOCAL_VAULT_KEY_PATH.write_text(base64.b64encode(os.urandom(32)).decode() + "\n")
|
||||||
|
elif damage == "missing_local_key":
|
||||||
|
mod.LOCAL_VAULT_KEY_PATH.unlink()
|
||||||
|
else:
|
||||||
|
vault.write_bytes(b"{not json")
|
||||||
|
|
||||||
|
fresh_process_state(mod)
|
||||||
|
failure = error_of(mod.active_memory_secret, PROJECT)
|
||||||
|
assert failure.code == "memory_secret_not_provisioned"
|
||||||
|
status = mod.gateway_status()
|
||||||
|
assert status["memory_secret"] == "not_provisioned"
|
||||||
|
assert status["memory_secret_detail"] == "local_vault_unreadable"
|
||||||
|
mod.reindex_project({})
|
||||||
|
search = mod.search_local_index({"q": BODY_TERM})
|
||||||
|
assert search["items"] == [] and search["warnings"][0]["code"] == "e2e_index_not_provisioned"
|
||||||
|
payload = mod.safe_exception_payload(failure)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
for label, blob in (("error", payload), ("status", status), ("search", search),
|
||||||
|
("stdout", captured.out), ("stderr", captured.err)):
|
||||||
|
assert_no_leak(material, label, blob)
|
||||||
|
|
||||||
|
# An explicit setup replaces the unreadable vault.
|
||||||
|
mod.memory_secret_setup({"vault_passphrase": material.passphrase})
|
||||||
|
fresh_process_state(mod)
|
||||||
|
assert mod.active_memory_secret(PROJECT)["secret_bytes"] == material.secret(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_passphrase_stores_nothing(env):
|
||||||
|
mod, _provider, material, state_dir, _ = env
|
||||||
|
failure = error_of(mod.memory_secret_setup, {"vault_passphrase": "not-" + material.passphrase})
|
||||||
|
assert failure.code == "memory_secret_unwrap_failed"
|
||||||
|
assert not mod.MEMORY_SECRET_VAULT_PATH.exists()
|
||||||
|
assert_no_leak(material, "unwrap failure", mod.safe_exception_payload(failure))
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_passphrase_still_works_reports_deprecation_and_is_persisted(env, monkeypatch, capsys):
|
||||||
|
mod, provider, material, state_dir, _ = env
|
||||||
|
monkeypatch.setenv("NAPSEER_VAULT_PASSPHRASE", material.passphrase)
|
||||||
|
assert mod.active_memory_secret(PROJECT)["secret_bytes"] == material.secret(1)
|
||||||
|
status = mod.gateway_status()
|
||||||
|
assert status["memory_secret"] == "provisioned"
|
||||||
|
assert [item["code"] for item in status["deprecations"]] == ["vault_passphrase_env_deprecated"]
|
||||||
|
assert status["deprecations"][0]["variables"] == ["NAPSEER_VAULT_PASSPHRASE"]
|
||||||
|
assert status["deprecations"][0]["next_action"] == SETUP_COMMAND
|
||||||
|
mod.local_anchor_integrity = lambda: {}
|
||||||
|
doctor = mod.doctor({})
|
||||||
|
assert any("deprecated" in warning for warning in doctor["warnings"])
|
||||||
|
log = capsys.readouterr().err
|
||||||
|
assert '"event":"vault_passphrase_env_deprecated"' in log
|
||||||
|
|
||||||
|
# Later processes no longer need the variable.
|
||||||
|
monkeypatch.delenv("NAPSEER_VAULT_PASSPHRASE")
|
||||||
|
fresh_process_state(mod)
|
||||||
|
assert mod.active_memory_secret(PROJECT)["secret_bytes"] == material.secret(1)
|
||||||
|
assert "deprecations" not in mod.gateway_status()
|
||||||
|
for label, blob in (("status", status), ("doctor", doctor), ("log", log)):
|
||||||
|
assert_no_leak(material, label, blob)
|
||||||
|
assert_state_dir_clean(material, state_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_mode_never_prompts_even_on_a_tty(env, monkeypatch):
|
||||||
|
mod, _provider, _material, _state_dir, _ = env
|
||||||
|
|
||||||
|
class Tty:
|
||||||
|
def isatty(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def forbidden_prompt(*_args, **_kwargs):
|
||||||
|
raise AssertionError("MCP must never prompt")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.sys, "stdin", Tty())
|
||||||
|
monkeypatch.setattr(mod.getpass, "getpass", forbidden_prompt)
|
||||||
|
mod.MCP_SERVING = True
|
||||||
|
assert error_of(mod.active_memory_secret, PROJECT).code == "memory_secret_not_provisioned"
|
||||||
|
assert "NAPSEER_VAULT_PASSPHRASE" in str(error_of(mod.project_bundle_passphrase))
|
||||||
|
assert error_of(mod.memory_secret_setup, {}).code == "passphrase_required"
|
||||||
|
assert error_of(mod.decrypt_node_for_return, _provider.nodes["secretnote"], PROJECT).code == \
|
||||||
|
"memory_secret_not_provisioned"
|
||||||
|
|
||||||
|
|
||||||
|
# -- fresh processes ----------------------------------------------------------
|
||||||
|
|
||||||
|
def child_env(user_data, base_url, **extra):
|
||||||
|
env = {key: value for key, value in os.environ.items()
|
||||||
|
if not key.startswith("NAPSEER_") or key == "NAPSEER_CRYPTO_BACKEND"}
|
||||||
|
env.update({
|
||||||
|
"NAPSEER_USER_DATA_DIR": str(user_data),
|
||||||
|
"NAPSEER_BASE_URL": base_url,
|
||||||
|
"NAPSEER_TOKEN": "disposable-test-token",
|
||||||
|
"NAPSEER_PROJECT_ID": PROJECT,
|
||||||
|
"NAPSEER_ACCOUNT_ID": ACCOUNT,
|
||||||
|
"NAPSEER_TELEMETRY": "0",
|
||||||
|
"NAPSEER_TOOL_PROFILES": "all",
|
||||||
|
"PYTHONDONTWRITEBYTECODE": "1",
|
||||||
|
})
|
||||||
|
env.update(extra)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def no_echo_pty():
|
||||||
|
master, slave = pty.openpty()
|
||||||
|
attrs = termios.tcgetattr(slave)
|
||||||
|
attrs[3] &= ~termios.ECHO
|
||||||
|
termios.tcsetattr(slave, termios.TCSANOW, attrs)
|
||||||
|
return master, slave
|
||||||
|
|
||||||
|
|
||||||
|
def run_interactive_setup(env, cwd, passphrase):
|
||||||
|
"""`gateway vault setup` on a terminal: the passphrase is typed at the prompt."""
|
||||||
|
master, slave = no_echo_pty()
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[sys.executable, str(SCRIPT), "gateway", "vault", "setup"],
|
||||||
|
stdin=slave, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, env=env, start_new_session=True,
|
||||||
|
)
|
||||||
|
os.close(slave)
|
||||||
|
stderr = b""
|
||||||
|
deadline = time.monotonic() + 30
|
||||||
|
try:
|
||||||
|
while b"passphrase" not in stderr.lower():
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
ready, _, _ = select.select([process.stderr], [], [], max(0.0, remaining))
|
||||||
|
if not ready:
|
||||||
|
raise AssertionError("setup did not prompt for the passphrase")
|
||||||
|
chunk = os.read(process.stderr.fileno(), 4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
stderr += chunk
|
||||||
|
os.write(master, passphrase.encode("utf-8") + b"\n")
|
||||||
|
stdout, rest = process.communicate(timeout=60)
|
||||||
|
finally:
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
os.close(master)
|
||||||
|
return process.returncode, stdout, stderr + rest
|
||||||
|
|
||||||
|
|
||||||
|
class McpProcess:
|
||||||
|
def __init__(self, env, cwd, tty_stdin=False):
|
||||||
|
self.master = None
|
||||||
|
if tty_stdin:
|
||||||
|
self.master, slave = no_echo_pty()
|
||||||
|
stdin = slave
|
||||||
|
else:
|
||||||
|
stdin = subprocess.PIPE
|
||||||
|
self.process = subprocess.Popen(
|
||||||
|
[sys.executable, "-u", str(SCRIPT)], stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
cwd=cwd, env=env, start_new_session=True,
|
||||||
|
)
|
||||||
|
if tty_stdin:
|
||||||
|
os.close(slave)
|
||||||
|
self.lines = queue.Queue()
|
||||||
|
self.stdout = []
|
||||||
|
self.stderr = b""
|
||||||
|
threading.Thread(target=self._read_stdout, daemon=True).start()
|
||||||
|
|
||||||
|
def _read_stdout(self):
|
||||||
|
for line in self.process.stdout:
|
||||||
|
self.stdout.append(line)
|
||||||
|
self.lines.put(line)
|
||||||
|
|
||||||
|
def send(self, message):
|
||||||
|
data = (json.dumps(message) + "\n").encode("utf-8")
|
||||||
|
if self.master is not None:
|
||||||
|
os.write(self.master, data)
|
||||||
|
else:
|
||||||
|
self.process.stdin.write(data)
|
||||||
|
self.process.stdin.flush()
|
||||||
|
|
||||||
|
def request(self, request_id, method, params=None, timeout=60):
|
||||||
|
self.send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params or {}})
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
message = json.loads(self.lines.get(timeout=max(0.01, deadline - time.monotonic())))
|
||||||
|
except queue.Empty:
|
||||||
|
raise AssertionError(f"no MCP response to {method}") from None
|
||||||
|
if message.get("id") == request_id:
|
||||||
|
return message
|
||||||
|
|
||||||
|
def call(self, request_id, name, arguments=None):
|
||||||
|
return self.request(request_id, "tools/call", {"name": name, "arguments": arguments or {}})["result"]
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.request(1, "initialize", {"protocolVersion": "2025-11-25", "capabilities": {},
|
||||||
|
"clientInfo": {"name": "memory-vault-test", "version": "1"}})
|
||||||
|
self.send({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.master is not None:
|
||||||
|
os.close(self.master)
|
||||||
|
elif self.process.stdin:
|
||||||
|
self.process.stdin.close()
|
||||||
|
try:
|
||||||
|
self.process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.process.kill()
|
||||||
|
self.process.wait()
|
||||||
|
self.stderr = self.process.stderr.read()
|
||||||
|
return b"".join(self.stdout), self.stderr
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def processes(env, tmp_path):
|
||||||
|
mod, provider, material, _state_dir, base_url = env
|
||||||
|
user_data = tmp_path / "user-data"
|
||||||
|
workdir = tmp_path / "work"
|
||||||
|
workdir.mkdir()
|
||||||
|
return mod, provider, material, user_data, workdir, child_env(user_data, base_url)
|
||||||
|
|
||||||
|
|
||||||
|
def test_interactive_setup_then_fresh_mcp_process_searches_e2e_automatically(processes):
|
||||||
|
mod, provider, material, user_data, workdir, environment = processes
|
||||||
|
code, stdout, stderr = run_interactive_setup(environment, workdir, material.passphrase)
|
||||||
|
assert code == 0, stderr.decode(errors="replace")[-2000:]
|
||||||
|
result = json.loads(stdout)
|
||||||
|
assert result["status"] == "provisioned" and result["passphrase_source"] == "prompt"
|
||||||
|
assert result["passphrase_stored"] is False
|
||||||
|
assert_no_leak(material, "setup stdout", stdout)
|
||||||
|
assert_no_leak(material, "setup stderr", stderr)
|
||||||
|
|
||||||
|
# A new CLI process builds the encrypted E2E index without any passphrase.
|
||||||
|
reindexed = subprocess.run([sys.executable, str(SCRIPT), "reindex"], cwd=workdir, env=environment,
|
||||||
|
capture_output=True, timeout=120, stdin=subprocess.DEVNULL)
|
||||||
|
assert reindexed.returncode == 0, reindexed.stderr.decode(errors="replace")[-2000:]
|
||||||
|
assert json.loads(reindexed.stdout)["e2e_index"]["status"] == "applied"
|
||||||
|
assert_no_leak(material, "reindex output", reindexed.stdout + reindexed.stderr)
|
||||||
|
|
||||||
|
# A new MCP worker: no passphrase anywhere in its environment.
|
||||||
|
client = McpProcess(environment, workdir)
|
||||||
|
try:
|
||||||
|
client.start()
|
||||||
|
whoami = client.call(2, "nap_whoami")["structuredContent"]
|
||||||
|
assert whoami["memory_secret"] == "provisioned" and "deprecations" not in whoami
|
||||||
|
found = client.call(3, "nap_discover", {"q": BODY_TERM, "mode": "local"})
|
||||||
|
assert found["isError"] is False, found["structuredContent"]
|
||||||
|
found = found["structuredContent"]
|
||||||
|
assert [item["node_id"] for item in found["items"]] == ["secretnote"]
|
||||||
|
assert found["items"][0]["search_source"] == "local_e2e" and not found.get("warnings")
|
||||||
|
node = client.call(4, "nap_node_get", {"node_id": "secretnote"})["structuredContent"]
|
||||||
|
assert BODY in json.dumps(node)
|
||||||
|
finally:
|
||||||
|
out, err = client.close()
|
||||||
|
assert_no_leak(material, "mcp stdout", out)
|
||||||
|
assert_no_leak(material, "mcp stderr", err)
|
||||||
|
assert_state_dir_clean(material, user_data, workdir)
|
||||||
|
assert not (workdir / ".napseer").exists()
|
||||||
|
|
||||||
|
# Removal through the CLI revokes access for the next process.
|
||||||
|
forgot = subprocess.run([sys.executable, str(SCRIPT), "gateway", "vault", "forget"], cwd=workdir,
|
||||||
|
env=environment, capture_output=True, timeout=60, stdin=subprocess.DEVNULL)
|
||||||
|
assert forgot.returncode == 0 and json.loads(forgot.stdout)["status"] == "removed"
|
||||||
|
client = McpProcess(environment, workdir)
|
||||||
|
try:
|
||||||
|
client.start()
|
||||||
|
failed = client.call(2, "nap_node_get", {"node_id": "secretnote"})
|
||||||
|
assert failed["isError"] is True
|
||||||
|
assert failed["structuredContent"]["error"]["code"] == "memory_secret_not_provisioned"
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_worker_on_a_tty_never_prompts(processes):
|
||||||
|
_mod, _provider, material, user_data, workdir, environment = processes
|
||||||
|
client = McpProcess(environment, workdir, tty_stdin=True)
|
||||||
|
try:
|
||||||
|
client.start()
|
||||||
|
failed = client.call(2, "nap_node_get", {"node_id": "secretnote"})
|
||||||
|
assert failed["isError"] is True
|
||||||
|
error = failed["structuredContent"]["error"]
|
||||||
|
assert error["code"] == "memory_secret_not_provisioned"
|
||||||
|
assert error["details"]["next_action"] == SETUP_COMMAND
|
||||||
|
# stdin was not consumed by a prompt: the worker keeps answering.
|
||||||
|
assert client.call(3, "nap_whoami")["structuredContent"]["memory_secret"] == "not_provisioned"
|
||||||
|
finally:
|
||||||
|
_out, err = client.close()
|
||||||
|
assert b"passphrase:" not in err.lower()
|
||||||
|
assert_state_dir_clean(material, user_data)
|
||||||
@@ -36,6 +36,11 @@ def run():
|
|||||||
|
|
||||||
assert mod.handle_gateway(["vault", "rotate-secret", "--kind", "memory"]) == 0
|
assert mod.handle_gateway(["vault", "rotate-secret", "--kind", "memory"]) == 0
|
||||||
assert calls[-1] == ("gateway", ["vault", "rotate-secret", "--kind", "memory"])
|
assert calls[-1] == ("gateway", ["vault", "rotate-secret", "--kind", "memory"])
|
||||||
|
# Memory secret provisioning/removal route to the wrapper, which owns the prompt.
|
||||||
|
assert mod.handle_gateway(["vault", "setup"]) == 0
|
||||||
|
assert calls[-1] == ("gateway", ["vault", "setup"])
|
||||||
|
assert mod.handle_gateway(["vault", "forget"]) == 0
|
||||||
|
assert calls[-1] == ("gateway", ["vault", "forget"])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mod.handle_gateway(["process", "--all"])
|
mod.handle_gateway(["process", "--all"])
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ def load_module():
|
|||||||
def configure_state(mod, root):
|
def configure_state(mod, root):
|
||||||
state_dir = pathlib.Path(root) / ".napseer"
|
state_dir = pathlib.Path(root) / ".napseer"
|
||||||
mod.AUTH_DIR = state_dir
|
mod.AUTH_DIR = state_dir
|
||||||
|
mod.PROJECT_DIR = state_dir
|
||||||
mod.AUTH_PATH = state_dir / "auth.json"
|
mod.AUTH_PATH = state_dir / "auth.json"
|
||||||
mod.BASE_URL = "https://api.example.test"
|
mod.BASE_URL = "https://api.example.test"
|
||||||
mod.TOKEN = None
|
mod.TOKEN = None
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ SMOKES = [
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("filename", SMOKES)
|
@pytest.mark.parametrize("filename", SMOKES)
|
||||||
def test_standalone_smoke(filename):
|
def test_standalone_smoke(filename, tmp_path):
|
||||||
test_dir = pathlib.Path(__file__).resolve().parent
|
test_dir = pathlib.Path(__file__).resolve().parent
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, str(test_dir / filename)],
|
[sys.executable, str(test_dir / filename)],
|
||||||
cwd=test_dir.parent,
|
cwd=tmp_path,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=90,
|
timeout=90,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import pytest
|
|||||||
|
|
||||||
SCRIPT = Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py"
|
SCRIPT = Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py"
|
||||||
sys.path.insert(0, str(SCRIPT.parent))
|
sys.path.insert(0, str(SCRIPT.parent))
|
||||||
from napseer_credentials import CredentialStore, atomic_json, read_json
|
from napseer_credentials import CredentialStore, CredentialError, atomic_json, read_json
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -204,7 +204,8 @@ def test_legacy_migration_preserves_authority_and_general_selection_is_explicit(
|
|||||||
store=seed(tmp_path,url)
|
store=seed(tmp_path,url)
|
||||||
legacy={"base_url":url,"token":"legacy-project","account_mode":"operator_project","project_id":"test-project"}
|
legacy={"base_url":url,"token":"legacy-project","account_mode":"operator_project","project_id":"test-project"}
|
||||||
atomic_json(store.legacy,legacy)
|
atomic_json(store.legacy,legacy)
|
||||||
assert store.selected()[1]=="legacy_project"
|
with pytest.raises(CredentialError, match="needs migration"):
|
||||||
|
store.selected()
|
||||||
store.migrate()
|
store.migrate()
|
||||||
assert not store.legacy.exists()
|
assert not store.legacy.exists()
|
||||||
assert store.read()["token"]=="legacy-project"
|
assert store.read()["token"]=="legacy-project"
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Real Git worktrees and worker processes exercise private-state ownership."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SCRIPTS = Path(__file__).resolve().parents[1] / 'resources/scripts'
|
||||||
|
sys.path.insert(0, str(SCRIPTS))
|
||||||
|
from napseer_credentials import CredentialStore, atomic_json, read_json
|
||||||
|
from test_user_credentials import provider, worker, outcome
|
||||||
|
|
||||||
|
# Released CLI 0.4.0 source revision (pinned by the Napseer deploy workflow).
|
||||||
|
RELEASED_0_4_0 = 'c427e2432ac85038a4f00b2b888bbdb7c851ada1'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def worktrees(tmp_path):
|
||||||
|
main, other = tmp_path/'main', tmp_path/'other'
|
||||||
|
main.mkdir()
|
||||||
|
def git(*args):
|
||||||
|
subprocess.run(['git', '-C', str(main), *args], check=True, capture_output=True)
|
||||||
|
git('init', '-q')
|
||||||
|
atomic_json(main/'.napseer/project.json', {'schema':'napseer.project.v1',
|
||||||
|
'base_url':'https://api.example.test', 'project_id':'33333333-3333-3333-3333-333333333333',
|
||||||
|
'project_slug':'shared'})
|
||||||
|
git('add', '.napseer/project.json')
|
||||||
|
git('-c', 'user.name=Test', '-c', 'user.email=test@example.test', 'commit', '-qm', 'locator')
|
||||||
|
git('worktree', 'add', '-qb', 'other', str(other))
|
||||||
|
return main, other
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_keeps_private_paths_outside_checkout_and_stable_from_subdirectory(worktrees):
|
||||||
|
main, other = worktrees
|
||||||
|
sub = main/'nested'; sub.mkdir()
|
||||||
|
expression = "{'state':str(m.local_state_dir()),'key':str(m.DEFAULT_KEY_PATH),'locator':str(m.project_locator_path())}"
|
||||||
|
a,b,c = [outcome(worker(root, expression))['result'] for root in (main,other,sub)]
|
||||||
|
assert not Path(a['state']).is_relative_to(main)
|
||||||
|
assert not Path(b['state']).is_relative_to(other)
|
||||||
|
assert a['state'] == c['state']
|
||||||
|
assert a['state'] != b['state']
|
||||||
|
assert a['key'] == b['key']
|
||||||
|
assert a['locator'] == str(main/'.napseer/project.json')
|
||||||
|
assert c['locator'] == a['locator']
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_login_is_shared_across_worktrees_and_refreshes_once(worktrees, provider):
|
||||||
|
main, other = worktrees
|
||||||
|
url, state = provider
|
||||||
|
first, second = [CredentialStore(root/'.napseer') for root in (main,other)]
|
||||||
|
first.login({'base_url':url,'token':'old-access','refresh_token':'old-refresh',
|
||||||
|
'account_mode':'operator_project','oauth_client_id':'nap-cli'}, project=True)
|
||||||
|
assert first.project == second.project
|
||||||
|
assert first.selection != second.selection
|
||||||
|
assert not first.selection.is_relative_to(main)
|
||||||
|
requests=[worker(root, "m.request_json('GET','/v1/projects')") for root in (main,other)]
|
||||||
|
assert all(outcome(p)['ok'] for p in requests)
|
||||||
|
assert state['refreshes'] == 1
|
||||||
|
assert outcome(worker(other, "m.request_json('GET','/v1/projects')"))['ok']
|
||||||
|
assert state['refreshes'] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_preserves_keys_and_locator_and_refuses_conflicting_destination(worktrees):
|
||||||
|
from napseer_credentials import WorkspacePaths, CredentialError
|
||||||
|
main, _ = worktrees
|
||||||
|
paths = WorkspacePaths(main)
|
||||||
|
old = main/'.napseer'
|
||||||
|
locator=(old/'project.json').read_bytes()
|
||||||
|
(old/'vault.local-key').write_bytes(b'synthetic-vault-key\n')
|
||||||
|
(old/'vault.json').write_bytes(b'{"synthetic":"ciphertext"}\n')
|
||||||
|
(old/'id_ed25519').write_bytes(b'synthetic-signing-key\n')
|
||||||
|
atomic_json(old/'auth.json', {'token':'synthetic-token','ssh_key_path':str(old/'id_ed25519')})
|
||||||
|
paths.state.mkdir(parents=True)
|
||||||
|
(paths.state/'vault.local-key').write_bytes(b'conflicting-key')
|
||||||
|
with pytest.raises(CredentialError): paths.migrate()
|
||||||
|
assert (old/'vault.local-key').read_bytes() == b'synthetic-vault-key\n'
|
||||||
|
assert (old/'auth.json').exists()
|
||||||
|
(paths.state/'vault.local-key').unlink()
|
||||||
|
paths.migrate()
|
||||||
|
assert (paths.state/'vault.local-key').read_bytes() == b'synthetic-vault-key\n'
|
||||||
|
assert paths.signing_key.read_bytes() == b'synthetic-signing-key\n'
|
||||||
|
assert read_json(CredentialStore(old).project)['ssh_key_path'] == str(paths.signing_key)
|
||||||
|
assert (old/'project.json').read_bytes() == locator
|
||||||
|
assert set(p.name for p in old.iterdir()) == {'project.json'}
|
||||||
|
paths.migrate() # retry after a completed migration is harmless
|
||||||
|
assert paths.signing_key.stat().st_mode & 0o777 == 0o600
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_repositories_do_not_share_project_override(tmp_path):
|
||||||
|
first=CredentialStore(tmp_path/'one/.napseer')
|
||||||
|
second=CredentialStore(tmp_path/'two/.napseer')
|
||||||
|
first.login({'token':'synthetic-project-token'}, project=True)
|
||||||
|
assert first.project != second.project
|
||||||
|
assert not second.read().get('token')
|
||||||
|
|
||||||
|
|
||||||
|
def test_linked_worktree_move_keeps_external_state_identity(worktrees):
|
||||||
|
from napseer_credentials import WorkspacePaths
|
||||||
|
main, other = worktrees
|
||||||
|
before = WorkspacePaths(other)
|
||||||
|
moved = other.with_name('moved')
|
||||||
|
subprocess.run(['git','-C',str(main),'worktree','move',str(other),str(moved)], check=True, capture_output=True)
|
||||||
|
after = WorkspacePaths(moved)
|
||||||
|
assert before.state == after.state
|
||||||
|
assert before.project_credentials == after.project_credentials
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_worktree_signing_key_creation_is_serialized(worktrees):
|
||||||
|
main, other = worktrees
|
||||||
|
processes = [worker(root, 'str(m.ensure_local_files())') for root in (main,other)]
|
||||||
|
results = [outcome(p) for p in processes]
|
||||||
|
assert all(r['ok'] for r in results)
|
||||||
|
assert results[0]['result'] == results[1]['result']
|
||||||
|
key = Path(results[0]['result'])
|
||||||
|
assert key.is_file() and Path(str(key)+'.pub').is_file()
|
||||||
|
assert key.stat().st_mode & 0o777 == 0o600
|
||||||
|
assert all(not (root/'.napseer/id_ed25519').exists() for root in (main,other))
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupted_migration_preserves_originals_and_retries(worktrees, monkeypatch):
|
||||||
|
from napseer_credentials import WorkspacePaths
|
||||||
|
main, _ = worktrees
|
||||||
|
paths=WorkspacePaths(main)
|
||||||
|
for name in ('vault.local-key','vault.json'):(paths.legacy/name).write_bytes(b'synthetic-'+name.encode())
|
||||||
|
original_link=os.link
|
||||||
|
calls=[]
|
||||||
|
def interrupted(source,destination):
|
||||||
|
calls.append(destination)
|
||||||
|
if len(calls)==2:raise OSError('synthetic interrupted publication')
|
||||||
|
return original_link(source,destination)
|
||||||
|
monkeypatch.setattr(os,'link',interrupted)
|
||||||
|
with pytest.raises(OSError):paths.migrate()
|
||||||
|
assert all((paths.legacy/name).exists() for name in ('vault.local-key','vault.json'))
|
||||||
|
monkeypatch.setattr(os,'link',original_link)
|
||||||
|
paths.migrate()
|
||||||
|
assert all((paths.state/name).read_bytes()==b'synthetic-'+name.encode() for name in ('vault.local-key','vault.json'))
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_migration_reports_running_gateway_and_recovers(worktrees):
|
||||||
|
main, _ = worktrees
|
||||||
|
atomic_json(main/'.napseer/gateway.json', {'pid':os.getpid()})
|
||||||
|
key=main/'.napseer/vault.local-key';key.write_text('synthetic-key')
|
||||||
|
env=dict(os.environ,NAPSEER_PROJECT_ROOT=str(main),NAPSEER_TELEMETRY='0')
|
||||||
|
command=[sys.executable,str(SCRIPTS/'napseer_mcp_server.py'),'auth','migrate']
|
||||||
|
result=subprocess.run(command,cwd=main,env=env,capture_output=True,text=True,timeout=15)
|
||||||
|
assert result.returncode!=0 and 'Stop the running' in result.stderr
|
||||||
|
assert 'synthetic-key' not in result.stderr and key.exists()
|
||||||
|
atomic_json(main/'.napseer/gateway.json', {'pid':0})
|
||||||
|
result=subprocess.run(command,cwd=main,env=env,capture_output=True,text=True,timeout=15)
|
||||||
|
assert result.returncode==0
|
||||||
|
report=json.loads(result.stdout)
|
||||||
|
assert report['status']=='migrated'
|
||||||
|
assert (Path(report['state_dir'])/'vault.local-key').read_text()=='synthetic-key'
|
||||||
|
assert not key.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_state_cannot_be_redirected_into_checkout(worktrees, monkeypatch):
|
||||||
|
from napseer_credentials import WorkspacePaths, CredentialError
|
||||||
|
main,_=worktrees
|
||||||
|
monkeypatch.setenv('NAPSEER_USER_DATA_DIR',str(main/'private-state'))
|
||||||
|
with pytest.raises(CredentialError,match='outside'):WorkspacePaths(main)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_tools_file_resolves_like_locator_and_is_invisible_to_migration(worktrees, monkeypatch):
|
||||||
|
from napseer_credentials import WorkspacePaths
|
||||||
|
main, _ = worktrees
|
||||||
|
monkeypatch.delenv('NAPSEER_TOOL_PROFILES', raising=False)
|
||||||
|
tools = main/'.napseer-tools.json'
|
||||||
|
committed = b'{"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}\n'
|
||||||
|
tools.write_bytes(committed)
|
||||||
|
sub = main/'nested'; sub.mkdir()
|
||||||
|
# A worker started below the project root finds the file at the root that
|
||||||
|
# owns .napseer/project.json, the same way it finds the locator.
|
||||||
|
expression = ("{'path':str(m.project_tools_path()),'profiles':sorted(m.tool_profile_configuration()[0]),"
|
||||||
|
"'source':m.tool_profile_configuration()[1],"
|
||||||
|
"'pending':sorted(p.name for p in m.WORKSPACE.migration_sources())}")
|
||||||
|
result = outcome(worker(sub, expression))['result']
|
||||||
|
assert result == {'path': str(tools), 'profiles': ['workflow'], 'source': 'tools_file', 'pending': []}
|
||||||
|
assert set(p.name for p in (main/'.napseer').iterdir()) <= {'project.json', '.gitignore'}
|
||||||
|
paths = WorkspacePaths(main)
|
||||||
|
assert paths.migration_sources() == []
|
||||||
|
paths.require_migrated()
|
||||||
|
paths.migrate()
|
||||||
|
assert tools.read_bytes() == committed
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_tools_file_is_invisible_to_released_0_4_0_migration_check(worktrees, tmp_path):
|
||||||
|
"""CLI 0.4.0 treats any extra .napseer/ entry as private state to migrate."""
|
||||||
|
import importlib.util
|
||||||
|
released = subprocess.run(
|
||||||
|
['git', '-C', str(SCRIPTS.parent.parent), 'show', f'{RELEASED_0_4_0}:resources/scripts/napseer_credentials.py'],
|
||||||
|
capture_output=True)
|
||||||
|
if released.returncode != 0:
|
||||||
|
pytest.skip(f'released 0.4.0 source {RELEASED_0_4_0} is not in this checkout')
|
||||||
|
source = tmp_path/'napseer_credentials_0_4_0.py'
|
||||||
|
source.write_bytes(released.stdout)
|
||||||
|
spec = importlib.util.spec_from_file_location('napseer_credentials_0_4_0', source)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
main, _ = worktrees
|
||||||
|
(main/'.napseer-tools.json').write_text('{"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}\n')
|
||||||
|
paths = module.WorkspacePaths(main)
|
||||||
|
assert paths.migration_sources() == []
|
||||||
|
paths.require_migrated()
|
||||||
|
# Control: the same file inside .napseer/ would block 0.4.0 clients.
|
||||||
|
(main/'.napseer/tools.json').write_text('{}\n')
|
||||||
|
assert [p.name for p in paths.migration_sources()] == ['tools.json']
|
||||||
|
with pytest.raises(module.CredentialError):
|
||||||
|
paths.require_migrated()
|
||||||
Reference in New Issue
Block a user