12 Commits
Author SHA1 Message Date
HumbleTurtle bb0fb030c3 Merge branch 'effective-node-types' into agent-coordination-reliability
# Conflicts:
#	tests/test_mcp_e2e_index_shard.py
2026-09-29 00:53:30 -03:00
HumbleTurtleandClaude Opus 5.5 eed1d1d3f2 Provision the E2E memory secret into the local vault
`nap gateway vault setup` unwraps the project memory secret once (prompt
on a terminal, or the existing argument/env paths) and stores the
secret, never the passphrase, in memory-secrets.vault.json under the
existing 0600 local vault key. The CLI and MCP workers load it
automatically and MCP never prompts, per
/decisions/local-gateway-no-interactive-unlock and
/decisions/2026-09-29-memory-secret-local-vault. Older versions stay
readable; a rotated active version reports memory_secret_rotated with
the setup next action. `vault forget` revokes local access, including
for running workers. Env passphrases still work, are persisted into the
vault, and are reported as deprecated. Search warns
e2e_index_not_provisioned instead of e2e_index_locked. Local key
creation is 0600 from the start and never replaces a published key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:49:48 -03:00
HumbleTurtleandClaude Opus 5.5 f201cb358b Type node creation and filter local memory by effective type
- nap_create_node accepts `type`; when omitted (also in nap_bulk and
  agent-node creates) the type is inferred from the folder and sent
  explicitly, so older APIs also store typed plans and decisions.
  Explicit spelling variants are canonicalized; custom types pass as is.
- Local browse, full-text and encrypted-shard searches filter on the
  effective type (canonical spelling, or the folder's type for generic
  notes; `type=note` excludes notes whose folder implies a type); server
  rows are post-filtered the same way. Rows expose `effective_type` when
  it differs from the stored type; discover offers an effective_type
  facet.
- Active-only filters also exclude deployed, released,
  released_verified_production and folded.
Mirrors napseer src/vocab.rs; decision
/decisions/2026-09-29-node-type-status-vocabulary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:32:36 -03:00
HumbleTurtleandClaude Opus 5.5 611a4571f3 Move tool profiles to a root file and version the release as 0.5.0
- Tool profiles come from a committed `.napseer-tools.json` at the
  project root (schema napseer.tools.v1). The unreleased tool_profiles
  field in .napseer/project.json is removed: released clients reject
  unknown locator fields, and 0.4.0 treats any extra file inside .napseer/
  as private state needing migration (a test loads the released 0.4.0
  check from c427e24 to prove the root file is invisible to it).
  Environment still overrides; unusable files warn and fall back to core.
- CLI release 0.5.0 with distribution contract 2026-09-29; the minimum
  supported contract stays 2026-09-10. The newer-contract rejection test
  now derives its date from the installer's own contract.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:33:22 -03:00
HumbleTurtleandClaude Opus 5.5 d86610da8e Search end-to-end encrypted nodes through an encrypted local index
- Crypto backend: use native AES-GCM from `cryptography` when importable
  (~1.5 GiB/s), falling back to the pure-Python implementation with a
  byte-identical format and identical authentication failures.
- E2E shard: decrypted E2E text is indexed in an in-memory SQLite
  database, persisted only as serialize() -> AES-256-GCM with an HKDF key
  bound to the project and memory-secret identity, authenticated header,
  fresh nonce, 0600 atomic writes. It loads only while the project is
  unlocked; tampered, foreign, wrong-key or rotated shards fail closed and
  rebuild. Search merges shard hits (search_source local_e2e) and reports
  locked/unavailable/slow states explicitly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:56:38 -03:00
HumbleTurtle bf6acaacc8 Merge branch 'search-quality' into agent-coordination-reliability 2026-09-28 22:09:12 -03:00
HumbleTurtleandClaude Opus 5.5 0938b3026e Keep the local index fresh from the change feed and improve ranking
- Track a feed cursor in the local index: reindex takes the head cursor
  before listing and commits it with the snapshot; reads refresh from
  /changes (bounded, throttled) and apply creates, updates, archives,
  restores and hard deletes atomically with the cursor. Existing
  cursorless indexes bootstrap tracking on first read. Failures answer
  locally with explicit freshness warnings; 400 triggers a full
  reindex, 404 falls back to timestamp sync.
- Rank by a weighted score: exact identity (including aliases and
  paths), term coverage including body, intent, bm25, authority, a
  status penalty for superseded/completed records and mild recency.
- Return query-centred FTS5 snippets (never for E2E nodes).
- FTS schema v2: porter stemming, prefix index, title column, rowid
  deletes, two-character terms, domain words no longer stopwords;
  v1 indexes upgrade in place.
- Hyphenated alias/path literals match as adjacent-token phrases and
  rank as exact identity.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:09:12 -03:00
HumbleTurtleandClaude Opus 5.5 c6bc28442a Rebind the committed project on login instead of forcing project consent
In a checkout whose private project credential had been revoked, `nap
auth login` refreshed only the account login while the dead project
override kept being selected, and attach fell through to a second,
project-scoped browser consent. Login now proves an OAuth project grant
dead (tombstoned, expired, or refresh rejected; unknown outcomes keep
it), selects the account login for this worktree, and attaches the
project named by .napseer/project.json without another consent. A
still-valid override is kept and reported. Attach takes the same fast
path, and a rejected project grant names the recovery step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:47:36 -03:00
HumbleTurtleandClaude Opus 5.5 7eaaffb63f Make MCP writes retry-safe and coordination state visible to agents
- Send Idempotency-Key on node create/patch/update writes (including bulk,
  link, move, kanban and agent-node tools) and return the key used, so an
  ambiguous failure can be retried without duplicating the write.
- Record a non-secret marker when login renewal is rejected; nap_whoami
  reports auth_required ahead of gateway state, and local-index answers
  carry stale warnings instead of silently serving old memory.
- Add nap_kanban_claim_next: pick and claim in one call, skipping cards
  held by another operation, never replaying an ambiguous claim.
- Add nap_changes_since over /v1/projects/{id}/changes with a
  per-worktree cursor that only advances after a successful read.
- Report real links_count/backlinks_count from indexed edges.
- Accept optional tool_profiles in .napseer/project.json when
  NAPSEER_TOOL_PROFILES is unset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:34:35 -03:00
HumbleTurtle c427e2432a Move private runtime state outside checkouts and isolate worktrees 2026-09-21 13:19:27 -03:00
HumbleTurtle 2a78262754 chore: move CLI source metadata to Gitea 2026-09-17 09:40:02 -03:00
HumbleTurtle 2a4a054549 fix: preserve local memory across failed index rebuilds 2026-09-11 22:07:22 -03:00
27 changed files with 8667 additions and 474 deletions
+21 -3
View File
@@ -28,9 +28,15 @@ Current state:
- `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
on macOS/Windows). `NAPSEER_USER_DATA_DIR` overrides that location.
`nap auth login --project` selects a local override. Legacy repository
credentials remain an explicit override until `nap auth migrate` moves them.
Gateway identities remain separate in `.napseer/gateway-auth.json`.
`nap auth login --project` selects a repository override shared by its Git
worktrees. Separate repositories retain separate overrides.
- 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
the schema, API origin, project UUID, and slug; it never contains tokens,
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
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:
```sh
+45 -18
View File
@@ -66,8 +66,8 @@ RUNTIME_SCRIPT_NAMES = (
"napseer_mcp_supervisor.py",
)
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
CLI_RELEASE_VERSION = "0.3.0"
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-10"
CLI_RELEASE_VERSION = "0.5.0"
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-29"
# Older installers have a fixed asset list and would omit the credentials module.
# Reject them before activation; the published bootstrap performs the upgrade.
CLI_MINIMUM_CONTRACT_VERSION = "2026-09-10"
@@ -82,7 +82,7 @@ INSTALL_PATHS = {
"terminal_protocol.py": "terminal/protocol.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_STATUS = os.environ.get("NAPSEER_CLI_SOURCE_REVISION_STATUS", "unresolved")
HELP_TOKENS = {"help", "-h", "--help"}
@@ -630,32 +630,34 @@ def run_wrapper(command_name, args):
def preferred_state_dir():
return pathlib.Path.cwd() / ".napseer"
from napseer_credentials import WorkspacePaths
return WorkspacePaths().state
def cwd_state_dir():
preferred = preferred_state_dir()
preferred.mkdir(exist_ok=True)
preferred.mkdir(parents=True, exist_ok=True)
chmod_best_effort(preferred, 0o700)
return preferred
def state_dir_status():
preferred = preferred_state_dir()
from napseer_credentials import CredentialStore
try: auth_path, credential_source = CredentialStore(preferred).selected()
except RuntimeError: auth_path, credential_source = CredentialStore(preferred).user, "invalid_selection"
from napseer_credentials import CredentialStore, WorkspacePaths
try: auth_path, credential_source = CredentialStore(WorkspacePaths().legacy).selected()
except RuntimeError: auth_path, credential_source = CredentialStore(WorkspacePaths().legacy).user, "invalid_selection"
return {
"credential_source": credential_source,
"migration_required": bool(WorkspacePaths().migration_sources()),
"active_state_dir": str(preferred),
"active_auth_path": str(auth_path),
"state_dir": str(preferred),
"auth_path": str(auth_path),
"state_dir_exists": preferred.exists(),
"state_dir_message": (
".napseer directory is active."
"Private Napseer state is stored in user data."
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):
from napseer_credentials import WorkspacePaths
WorkspacePaths().require_migrated()
wrapper = runtime_asset_path("napseer_mcp_server.py")
require_runtime_assets()
existing = read_service_state(kind)
@@ -1038,8 +1042,12 @@ def print_gateway_help(topic=None):
nap gateway schedule delete [ID | --schedule ID]""")
return 0
if topic == "vault":
print("Usage: nap gateway vault [status|process|rotate-secret] [--kind memory] [--all] [--project-id ID] [--vault-passphrase TEXT]")
print(" status Show local gateway state and pending setup requests.")
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, 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(" rotate-secret Rotate the memory project secret from the gateway worker path.")
return 0
@@ -1050,6 +1058,7 @@ def print_gateway_help(topic=None):
print(" nap gateway repair Create dedicated gateway worker credentials.")
print(" nap gateway terminal help Show terminal 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.")
return 0
@@ -1089,12 +1098,12 @@ def print_command_help(command):
),
"auth": (
"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"
" repair Recover failed automatic credential renewal.\n"
" logout [--project] Revoke the selected login.\n"
" login --project Store a separate project login in user data.\n"
" migrate Move legacy project credentials into user data.\n"
" 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"
" api-key --env NAME Configure project access from a named environment variable.\n"
" keys list|create|revoke Manage named, expiring delegated access."
@@ -1304,6 +1313,10 @@ def mcp_runtime_probe(timeout_seconds=6):
"verified": False,
},
"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:
return {
@@ -1366,17 +1379,19 @@ def doctor_status():
auth = {}
auth_error = None
try:
from napseer_credentials import CredentialStore, read_json
store=CredentialStore(preferred_state_dir())
from napseer_credentials import CredentialStore, WorkspacePaths, read_json
store=CredentialStore(WorkspacePaths().legacy)
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")
except RuntimeError:
auth_error="credential state is unreadable; run nap auth login or inspect the selected override"
missing = runtime_assets_missing()
issues = []
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"})
elif not auth_path.exists():
setup.append({
@@ -1411,6 +1426,18 @@ def doctor_status():
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")
return {
"status": status,
+213 -20
View File
@@ -8,6 +8,7 @@ import json
import os
from pathlib import Path
import stat
import subprocess
import sys
import tempfile
import threading
@@ -34,12 +35,201 @@ _held = threading.local()
def user_data_dir():
if os.environ.get("NAPSEER_USER_DATA_DIR"):
return Path(os.environ["NAPSEER_USER_DATA_DIR"]).expanduser().absolute()
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":
return Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "napseer"
if sys.platform == "darwin":
return Path.home() / "Library" / "Application Support" / "napseer"
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "napseer"
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):
@@ -137,11 +327,11 @@ def credential_lock(path, timeout=30):
class CredentialStore:
def __init__(self, state_dir, legacy_path=None):
self.state_dir = Path(state_dir)
self.legacy = Path(legacy_path or self.state_dir / "auth.json")
self.workspace = WorkspacePaths(Path(state_dir).parent)
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"
project_key = hashlib.sha256(str(self.state_dir.parent.resolve()).encode()).hexdigest()
self.project = user_data_dir() / "credentials" / "projects" / f"{project_key}.json"
self.project = self.workspace.project_credentials
self.selection = self.state_dir / "state.json"
def selected(self):
@@ -149,15 +339,21 @@ class CredentialStore:
if bound: return bound
explicit = os.environ.get("NAPSEER_AUTH_FILE")
if explicit:
return Path(explicit).expanduser().absolute(), "explicit"
if self.legacy != self.state_dir / "auth.json":
path = Path(explicit).expanduser().absolute()
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"
self.workspace.require_migrated()
if read_json(self.selection).get("credential_profile") == "user":
return self.user, "user"
if self.project.exists() or self.project.is_symlink():
return self.project, "project"
if self.legacy.exists() or self.legacy.is_symlink():
return self.legacy, "legacy_project"
return self.user, "user"
@contextmanager
@@ -185,14 +381,8 @@ class CredentialStore:
return self.user
def migrate(self):
with credential_lock(self.legacy), credential_lock(self.project):
legacy = read_json(self.legacy)
if not legacy: raise CredentialError("There is no legacy project credential to migrate.")
if self.project.exists(): raise CredentialError("A project credential already exists; migration will not overwrite it.")
atomic_json(self.project, legacy)
if read_json(self.project) != legacy: raise CredentialError("Migration verification failed; original credentials were preserved.")
self.legacy.unlink()
return self.project
self.workspace.migrate()
return self.project
def read(self):
path, source = self.selected()
@@ -205,12 +395,15 @@ class CredentialStore:
def write(self, data):
path, source = self.selected()
with credential_lock(path):
if source in {"legacy_project", "explicit"}:
if source == "explicit":
atomic_json(path, data)
return
credentials = {k: v for k, v in data.items() if k in CREDENTIAL_KEYS}
if source == "project":
credentials.update({k: v for k, v in data.items() if k in SELECTION_KEYS})
# 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)
# Reading project metadata must not churn or overwrite shared
# credentials, including after another process logged out.
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -3,8 +3,8 @@ import pytest
@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.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"):
monkeypatch.delenv(name, raising=False)
+5 -3
View File
@@ -21,7 +21,7 @@ def test_stdin_bootstrap_installs_verified_runtime_and_preserves_credentials(tmp
content = (source / installer.INSTALL_PATHS[name]).read_text()
item = {"name": name, "version": installer.CLI_RELEASE_VERSION,
"bytes": len(content.encode()), "sha256": hashlib.sha256(content.encode()).hexdigest(),
"source_repo": "https://github.com/napseer/nap-cli", "source_revision": revision,
"source_repo": "https://gitea.powc-r.com/napseer/nap-cli", "source_revision": revision,
"source_revision_status": "resolved", "contract_version": installer.CLI_DISTRIBUTION_CONTRACT_VERSION,
"minimum_contract_version": installer.CLI_MINIMUM_CONTRACT_VERSION,
"install_path": installer.INSTALL_PATHS[name], "mode": "0755"}
@@ -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,
"bundle_id": "synthetic-bootstrap", "release_version": installer.CLI_RELEASE_VERSION,
"contract": {"current": installer.CLI_DISTRIBUTION_CONTRACT_VERSION, "minimum_supported": installer.CLI_MINIMUM_CONTRACT_VERSION},
"source": {"repo": "https://github.com/napseer/nap-cli", "revision": revision, "revision_status": "resolved"}, "items": items}
"source": {"repo": "https://gitea.powc-r.com/napseer/nap-cli", "revision": revision, "revision_status": "resolved"}, "items": items}
class Provider(BaseHTTPRequestHandler):
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)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
workspace = tmp_path / "workspace"
workspace.mkdir()
user_data = tmp_path / "user-data"
user_data.mkdir(exist_ok=True)
auth = user_data / "auth.json"
auth.write_text('{"refresh_token":"synthetic-preserved"}')
env = {"PATH": os.environ["PATH"], "NAPSEER_HOME": str(tmp_path / "runtime"),
"NAPSEER_BIN_DIR": str(tmp_path / "bin"), "NAPSEER_USER_DATA_DIR": str(user_data),
"NAPSEER_PROJECT_ROOT": str(tmp_path), "NAPSEER_BASE_URL": f"http://127.0.0.1:{server.server_port}"}
"NAPSEER_PROJECT_ROOT": str(workspace), "NAPSEER_BASE_URL": f"http://127.0.0.1:{server.server_port}"}
try:
result = subprocess.run([sys.executable, "-"], input=(source / "nap_install.py").read_text(),
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30)
+16 -6
View File
@@ -1,4 +1,5 @@
import importlib.util
import datetime
import hashlib
import json
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()
serialized = json.dumps(result)
@@ -395,7 +400,7 @@ def fake_bundle(module, revision="a" * 40, broken_name=None):
"version": module.CLI_RELEASE_VERSION,
"bytes": len(content.encode("utf-8")),
"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_status": "resolved",
"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,
},
"source": {
"repo": "https://github.com/napseer/nap-cli",
"repo": "https://gitea.powc-r.com/napseer/nap-cli",
"revision": revision,
"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():
module = load_installer()
manifest, _payloads = fake_bundle(module)
manifest["contract"]["current"] = "2026-09-11"
manifest["contract"]["minimum_supported"] = "2026-09-11"
# One day past this installer's own contract, whatever it currently is.
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"]:
item["contract_version"] = "2026-09-11"
item["minimum_contract_version"] = "2026-09-11"
item["contract_version"] = newer
item["minimum_contract_version"] = newer
try:
module.validated_manifest_items(manifest)
+7
View File
@@ -188,6 +188,13 @@ def test_gateway_vault_aliases_rejected():
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():
mod = load_module()
for args in [
+175
View File
@@ -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
+152
View File
@@ -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"}
+397
View File
@@ -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"] == []
+502
View File
@@ -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)
+404
View File
@@ -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
+347
View File
@@ -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"]
+4 -1
View File
@@ -283,7 +283,7 @@ def test_node_get_rejects_path_reads(mod):
def test_tools_include_node_by_path_descriptor(mod):
tool_by_name = {item["name"]: item for item in mod.tools()}
assert len(tool_by_name) == 58
assert len(tool_by_name) == 60
assert len(tool_by_name) == len(mod.tools())
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 = []
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)
assert method == "GET"
assert "view=full" in path
+22 -2
View File
@@ -116,6 +116,8 @@ def run():
mod = load_module()
test_state = tempfile.TemporaryDirectory()
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.PROJECT_VAULT_PASSPHRASE = PASSPHRASE
mod.DEFAULT_PROJECT_ID = "project-1"
@@ -305,6 +307,8 @@ def run():
"node_id": "done",
"full_path": "/plans/done",
"type": "note",
# A generic note under /plans reports its effective type as well.
"effective_type": "plan",
"status": "completed",
}]
@@ -472,8 +476,24 @@ def run():
indexed["metadata"] = {"private": "secret metadata"}
mod.index_node(indexed)
assert mod.search_local_index({"q": "alpha", "limit": 5})["items"]
assert mod.search_local_index({"q": "needle", "limit": 5})["items"] == []
assert mod.search_local_index({"q": "metadata", "limit": 5})["items"] == []
# E2E body/metadata text never reaches the on-disk index; with the
# 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")
+1 -1
View File
@@ -36,7 +36,7 @@ def test_default_surface_includes_knowledge_files_and_coordination(mod):
assert [tool["name"] for tool in mod.tools()] == [
"nap_apropos", "nap_man", "nap_doctor", "nap_whoami",
"nap_operation_list", "nap_operation", "nap_file_list", "nap_file_upload", "nap_file_download", "nap_file_archive",
"nap_discover", "nap_context", "nap_node_by_path", "nap_node_get",
"nap_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_mv", "nap_rm",
]
+721
View File
@@ -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")
+30 -8
View File
@@ -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):
mod.AUTH_DIR = tmp_path
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"
mod.index_node(item)
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(
{
"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 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):
@@ -482,11 +500,13 @@ def test_complete_filtered_local_search_stays_off_network(tmp_path):
)
)
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(
{
"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"]] == [
"/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():
+308
View File
@@ -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"]
+1 -1
View File
@@ -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):
mod = load_module()
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
monkeypatch.setattr(mod, "refresh_public_auth_state", lambda: None)
calls = []
+674
View File
@@ -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)
+5
View File
@@ -36,6 +36,11 @@ def run():
assert mod.handle_gateway(["vault", "rotate-secret", "--kind", "memory"]) == 0
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:
mod.handle_gateway(["process", "--all"])
+1
View File
@@ -26,6 +26,7 @@ def load_module():
def configure_state(mod, root):
state_dir = pathlib.Path(root) / ".napseer"
mod.AUTH_DIR = state_dir
mod.PROJECT_DIR = state_dir
mod.AUTH_PATH = state_dir / "auth.json"
mod.BASE_URL = "https://api.example.test"
mod.TOKEN = None
+2 -2
View File
@@ -29,11 +29,11 @@ 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
result = subprocess.run(
[sys.executable, str(test_dir / filename)],
cwd=test_dir.parent,
cwd=tmp_path,
capture_output=True,
text=True,
timeout=90,
+3 -2
View File
@@ -13,7 +13,7 @@ import pytest
SCRIPT = Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py"
sys.path.insert(0, str(SCRIPT.parent))
from napseer_credentials import CredentialStore, atomic_json, read_json
from napseer_credentials import CredentialStore, CredentialError, atomic_json, read_json
@pytest.fixture
@@ -204,7 +204,8 @@ def test_legacy_migration_preserves_authority_and_general_selection_is_explicit(
store=seed(tmp_path,url)
legacy={"base_url":url,"token":"legacy-project","account_mode":"operator_project","project_id":"test-project"}
atomic_json(store.legacy,legacy)
assert store.selected()[1]=="legacy_project"
with pytest.raises(CredentialError, match="needs migration"):
store.selected()
store.migrate()
assert not store.legacy.exists()
assert store.read()["token"]=="legacy-project"
+212
View File
@@ -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()