Release resilient atomic Napseer CLI runtime

This commit is contained in:
HumbleTurtle
2026-07-30 00:21:50 -03:00
parent a88930ce69
commit a70cb7c46c
17 changed files with 3734 additions and 1466 deletions
+20 -4
View File
@@ -11,9 +11,25 @@ This repository is intended to become the canonical source for:
Current state: Current state:
- `resources/scripts/nap_install.py` is copied from the backend pre-split implementation. - `resources/scripts/nap_install.py` owns the versioned, verified, atomic
- `resources/scripts/` contains the runtime scripts currently installed by `nap_install.py`. runtime bundle installer.
- `resources/scripts/` contains the public runtime source embedded by the
backend without behavioral rewrites.
- `resources/scripts/napseer_mcp_supervisor.py` is the recommended Codex stdio
entrypoint. It keeps the client transport alive while restarting or reloading
the generated `napseer_mcp_server.py` worker between requests.
- `scripts/nap_install.py` is kept as a convenient top-level command source copy during the initial import. - `scripts/nap_install.py` is kept as a convenient top-level command source copy during the initial import.
- Gateway-specific implementation is still partially embedded in `resources/scripts/napseer_mcp_server.py`; it should move behind a gateway package/runtime boundary in a later extraction slice. - Gateway service compatibility remains in the worker while the standalone
gateway image is built from the public gateway repository.
Backend discovery remains the compatibility source of truth during migration. Backend discovery publishes the release manifest and exact public source
revision used for each bundle.
Configure Codex and other long-lived stdio clients to launch:
```sh
python3 ~/.local/share/napseer/napseer_mcp_supervisor.py
```
The direct wrapper remains the `nap` CLI implementation and an isolated
protocol-debugging entrypoint.
+547 -109
View File
@@ -14,6 +14,8 @@ Usage:
nap update nap update
""" """
import ast
import contextlib
import json import json
import hashlib import hashlib
import os import os
@@ -34,6 +36,7 @@ INSTALL_DIR = pathlib.Path(os.environ.get("NAPSEER_HOME", pathlib.Path.home() /
BIN_DIR = pathlib.Path(os.environ.get("NAPSEER_BIN_DIR", pathlib.Path.home() / ".local" / "bin")) BIN_DIR = pathlib.Path(os.environ.get("NAPSEER_BIN_DIR", pathlib.Path.home() / ".local" / "bin"))
SCRIPT_NAMES = ( SCRIPT_NAMES = (
"napseer_mcp_server.py", "napseer_mcp_server.py",
"napseer_mcp_supervisor.py",
"napseer_spake2.py", "napseer_spake2.py",
"terminal_init.py", "terminal_init.py",
"terminal_protocol.py", "terminal_protocol.py",
@@ -49,12 +52,57 @@ RUNTIME_SCRIPT_NAMES = (
"terminal_protocol.py", "terminal_protocol.py",
"terminal_pty_manager.py", "terminal_pty_manager.py",
"napseer_mcp_server.py", "napseer_mcp_server.py",
"napseer_mcp_supervisor.py",
) )
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024))) MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-05-19" CLI_RELEASE_VERSION = "0.2.0"
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-07-29"
CLI_MINIMUM_CONTRACT_VERSION = "2026-05-19"
CLI_BUNDLE_SCHEMA_VERSION = "napseer.cli.bundle.v1"
INSTALL_PATHS = {
"nap_install.py": "nap_install.py",
"napseer_mcp_server.py": "napseer_mcp_server.py",
"napseer_mcp_supervisor.py": "napseer_mcp_supervisor.py",
"napseer_spake2.py": "napseer_spake2.py",
"terminal_init.py": "terminal/__init__.py",
"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://github.com/napseer/nap-cli")
CLI_GENERATED_SOURCE_REVISION = os.environ.get("NAPSEER_CLI_SOURCE_REVISION", "unresolved") CLI_GENERATED_SOURCE_REVISION = os.environ.get("NAPSEER_CLI_SOURCE_REVISION", "unresolved")
CLI_GENERATED_SOURCE_REVISION_STATUS = os.environ.get("NAPSEER_CLI_SOURCE_REVISION_STATUS", "unresolved") CLI_GENERATED_SOURCE_REVISION_STATUS = os.environ.get("NAPSEER_CLI_SOURCE_REVISION_STATUS", "unresolved")
HELP_TOKENS = {"help", "-h", "--help"}
CANONICAL_COMMANDS = (
("init", "Initialize Napseer in the current folder."),
("status", "Show concise account, project, and runtime state."),
("doctor", "Diagnose local setup without changing it."),
("auth", "Authenticate an account or repair credentials."),
("project", "Create, attach, claim, or inspect a project."),
("mcp", "Install, update, inspect, or serve the local MCP runtime."),
("gateway", "Set up and operate the local gateway."),
("update", "Update the installed CLI and runtime bundle."),
("version", "Show version, source revision, and contract identity."),
("help", "Show this help or help for one command."),
)
COMMAND_METADATA = {
"init": {"mutates": True, "visible": True},
"status": {"mutates": False, "visible": True},
"doctor": {"mutates": False, "visible": True},
"auth": {"mutates": True, "visible": True},
"project": {"mutates": True, "visible": True},
"mcp": {"mutates": True, "visible": True},
"gateway": {"mutates": True, "visible": True},
"update": {"mutates": True, "visible": True},
"version": {"mutates": False, "visible": True},
"help": {"mutates": False, "visible": True},
"chat": {"mutates": True, "visible": False},
"plan": {"mutates": True, "visible": False},
"lineage": {"mutates": False, "visible": False},
"agent": {"mutates": True, "visible": False},
"feedback": {"mutates": True, "visible": False},
"where": {"mutates": False, "visible": False},
"install": {"mutates": True, "visible": False},
}
def chmod_best_effort(path, mode): def chmod_best_effort(path, mode):
@@ -114,16 +162,23 @@ def read_url_bytes(url, user_agent, label):
raise RuntimeError(f"{label} failed: HTTP {exc.code}: {body}") from exc raise RuntimeError(f"{label} failed: HTTP {exc.code}: {body}") from exc
def fetch_script(name): def fetch_json(path, label):
try: try:
body = read_url_bytes( body = read_url_bytes(
f"{BASE_URL}/v1/scripts/{name}", f"{BASE_URL}{path}",
"nap-install-python/0.1", "nap-install-python/0.1",
f"GET /v1/scripts/{name}", f"GET {path}",
) )
payload = json.loads(body.decode("utf-8")) payload = json.loads(body.decode("utf-8"))
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise RuntimeError(f"{name} response was not valid JSON") from exc raise RuntimeError(f"{label} response was not valid JSON") from exc
if not isinstance(payload, dict):
raise RuntimeError(f"{label} response must be a JSON object")
return payload
def fetch_script(name):
payload = fetch_json(f"/v1/scripts/{name}", name)
content = payload.get("content") content = payload.get("content")
if not isinstance(content, str) or not content.strip(): if not isinstance(content, str) or not content.strip():
raise RuntimeError(f"{name} response did not include script content") raise RuntimeError(f"{name} response did not include script content")
@@ -159,108 +214,312 @@ def source_revision_status_for(value):
return "resolved" return "resolved"
def install_assets(update_project=False): def bundle_manifest():
scripts = {} return fetch_json("/v1/scripts", "CLI bundle manifest")
fetched = {}
for name in SCRIPT_NAMES:
payload, content = fetch_script(name)
fetched[name] = (payload, content)
install_order = []
for name in (*RUNTIME_SCRIPT_NAMES, "nap_install.py", *SCRIPT_NAMES): def validated_manifest_items(manifest):
if name not in install_order: if manifest.get("schema_version") != CLI_BUNDLE_SCHEMA_VERSION:
install_order.append(name) raise RuntimeError(
for name in install_order: f"unsupported CLI bundle schema: {manifest.get('schema_version')!r}"
payload, content = fetched[name] )
target = INSTALL_DIR / str(payload.get("filename") or name) contract = manifest.get("contract")
write_file(target, content, executable=name.endswith(".py")) if not isinstance(contract, dict):
raise RuntimeError("CLI bundle manifest is missing contract metadata")
if contract.get("current") != CLI_DISTRIBUTION_CONTRACT_VERSION:
raise RuntimeError("CLI bundle contract does not match this installer")
if contract.get("minimum_supported") != CLI_MINIMUM_CONTRACT_VERSION:
raise RuntimeError("CLI bundle minimum contract metadata is inconsistent")
if manifest.get("release_version") != CLI_RELEASE_VERSION:
raise RuntimeError("CLI bundle release version does not match this installer")
source = manifest.get("source")
if (
not isinstance(source, dict)
or source_revision_status_for(source.get("revision")) != "resolved"
):
raise RuntimeError("CLI bundle source revision must be resolved")
items = manifest.get("items")
if not isinstance(items, list):
raise RuntimeError("CLI bundle manifest items must be an array")
by_name = {}
for item in items:
if not isinstance(item, dict) or not isinstance(item.get("name"), str):
raise RuntimeError("CLI bundle manifest contains an invalid item")
name = item["name"]
if name in by_name:
raise RuntimeError(f"CLI bundle manifest contains duplicate asset {name}")
by_name[name] = item
missing = [name for name in SCRIPT_NAMES if name not in by_name]
if missing:
raise RuntimeError(f"CLI bundle manifest is missing assets: {', '.join(missing)}")
for name in SCRIPT_NAMES:
item = by_name[name]
expected_path = INSTALL_PATHS[name]
path = pathlib.PurePosixPath(str(item.get("install_path") or ""))
if str(path) != expected_path or path.is_absolute() or ".." in path.parts:
raise RuntimeError(f"CLI bundle asset {name} has unsafe install_path")
if item.get("mode") != "0755":
raise RuntimeError(f"CLI bundle asset {name} has unsupported mode")
if item.get("source_repo") != source.get("repo"):
raise RuntimeError(f"CLI bundle asset {name} has inconsistent source repo")
if item.get("source_revision") != source.get("revision"):
raise RuntimeError(f"CLI bundle asset {name} has inconsistent source revision")
if item.get("contract_version") != contract.get("current"):
raise RuntimeError(f"CLI bundle asset {name} has inconsistent contract version")
if item.get("minimum_contract_version") != contract.get("minimum_supported"):
raise RuntimeError(f"CLI bundle asset {name} has inconsistent minimum contract")
if not isinstance(item.get("bytes"), int) or item["bytes"] <= 0:
raise RuntimeError(f"CLI bundle asset {name} has invalid byte length")
digest = str(item.get("sha256") or "")
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
raise RuntimeError(f"CLI bundle asset {name} has invalid sha256")
return {name: by_name[name] for name in SCRIPT_NAMES}
def verify_asset_payload(name, manifest_item, payload, content):
actual = content.encode("utf-8")
if len(actual) != manifest_item["bytes"]:
raise RuntimeError(f"{name} byte length does not match bundle manifest")
if hashlib.sha256(actual).hexdigest() != manifest_item["sha256"]:
raise RuntimeError(f"{name} sha256 does not match bundle manifest")
for field in (
"version",
"source_repo",
"source_revision",
"contract_version",
"minimum_contract_version",
"install_path",
"mode",
):
if payload.get(field) != manifest_item.get(field):
raise RuntimeError(f"{name} {field} does not match bundle manifest")
def release_name(manifest):
revision = manifest["source"]["revision"]
identity = str(manifest.get("bundle_id") or "")
suffix = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:8]
return f"{manifest['release_version']}-{revision[:12]}-{suffix}"
def validate_staged_python(release_dir):
for install_path in INSTALL_PATHS.values():
path = release_dir / install_path
if path.suffix == ".py":
try:
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
except (OSError, SyntaxError) as exc:
raise RuntimeError(
f"staged Python asset failed validation: {install_path}"
) from exc
def validate_release_hashes(release_dir, items):
for name, item in items.items():
path = release_dir / INSTALL_PATHS[name]
try:
content = path.read_bytes()
except OSError as exc:
raise RuntimeError(f"release asset is missing: {name}") from exc
if len(content) != item["bytes"]:
raise RuntimeError(f"release asset byte length is invalid: {name}")
if hashlib.sha256(content).hexdigest() != item["sha256"]:
raise RuntimeError(f"release asset sha256 is invalid: {name}")
def pid_exists(pid):
try:
os.kill(int(pid), 0)
return True
except (OSError, TypeError, ValueError):
return False
@contextlib.contextmanager
def installation_lock():
INSTALL_DIR.mkdir(parents=True, exist_ok=True)
lock_path = INSTALL_DIR / ".install.lock"
for attempt in range(2):
try:
descriptor = os.open(
str(lock_path),
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
0o600,
)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(str(os.getpid()))
break
except FileExistsError:
try:
owner = int(lock_path.read_text(encoding="utf-8").strip())
except (OSError, ValueError):
owner = None
if attempt == 0 and not pid_exists(owner):
lock_path.unlink(missing_ok=True)
continue
raise RuntimeError("another Napseer install or update is already running")
try:
yield
finally:
lock_path.unlink(missing_ok=True)
def atomic_symlink(path, target):
temporary = path.with_name(f".{path.name}.next-{os.getpid()}")
temporary.unlink(missing_ok=True)
os.symlink(target, temporary)
os.replace(temporary, path)
def preserve_legacy_path(path):
if path.is_symlink() or not path.exists():
return
backup = path.with_name(f"{path.name}.pre-bundle")
if backup.exists() or backup.is_symlink():
raise RuntimeError(
f"cannot preserve legacy install path because backup exists: {backup}"
)
os.replace(path, backup)
def activate_release(release_dir):
current = INSTALL_DIR / "current"
if os.name == "nt":
for install_path in INSTALL_PATHS.values():
target = INSTALL_DIR / install_path
content = (release_dir / install_path).read_text(encoding="utf-8")
write_file(target, content, executable=True)
nap_path = BIN_DIR / "nap"
write_file(
nap_path,
(release_dir / "nap_install.py").read_text(encoding="utf-8"),
executable=True,
)
write_file(
BIN_DIR / "nap.cmd",
f'@echo off\r\n"{sys.executable}" "{nap_path}" %*\r\n',
executable=False,
)
return nap_path
for filename in (
"nap_install.py",
"napseer_mcp_server.py",
"napseer_mcp_supervisor.py",
"napseer_spake2.py",
):
path = INSTALL_DIR / filename
preserve_legacy_path(path)
atomic_symlink(path, f"current/{filename}")
terminal_path = INSTALL_DIR / "terminal"
preserve_legacy_path(terminal_path)
atomic_symlink(terminal_path, "current/terminal")
BIN_DIR.mkdir(parents=True, exist_ok=True)
nap_path = BIN_DIR / "nap"
preserve_legacy_path(nap_path)
atomic_symlink(
nap_path,
os.path.relpath(INSTALL_DIR / "nap_install.py", BIN_DIR),
)
atomic_symlink(current, os.path.relpath(release_dir, INSTALL_DIR))
return nap_path
def install_assets():
with installation_lock():
manifest = bundle_manifest()
items = validated_manifest_items(manifest)
releases_dir = INSTALL_DIR / "releases"
releases_dir.mkdir(parents=True, exist_ok=True)
final_release = releases_dir / release_name(manifest)
staging = releases_dir / f".staging-{os.getpid()}-{time.time_ns()}"
staging.mkdir(mode=0o700)
scripts = {}
try:
for name in SCRIPT_NAMES:
manifest_item = items[name]
payload, content = fetch_script(name)
verify_asset_payload(name, manifest_item, payload, content)
target = staging / INSTALL_PATHS[name]
write_file(target, content, executable=True)
scripts[name] = { scripts[name] = {
"path": str(target), "path": str(INSTALL_DIR / "current" / INSTALL_PATHS[name]),
"version": payload.get("version"), "version": payload.get("version"),
"build_commit": payload.get("build_commit"),
"sha256": payload.get("sha256"), "sha256": payload.get("sha256"),
"source_repo": payload.get("source_repo"), "source_repo": payload.get("source_repo"),
"source_revision": payload.get("source_revision"), "source_revision": payload.get("source_revision"),
"source_revision_status": payload.get("source_revision_status")
or source_revision_status_for(payload.get("source_revision")),
"generated_at": payload.get("generated_at"),
"generator_version": payload.get("generator_version"),
"contract_version": payload.get("contract_version"), "contract_version": payload.get("contract_version"),
"supported_backend_api_versions": payload.get("supported_backend_api_versions"),
"supported_gateway_contract_versions": payload.get("supported_gateway_contract_versions"),
} }
validate_staged_python(staging)
installer_content = (INSTALL_DIR / "nap_install.py").read_text(encoding="utf-8") write_file(
nap_path = BIN_DIR / "nap" staging / "bundle-manifest.json",
write_file(nap_path, installer_content, executable=True) json.dumps(manifest, indent=2, sort_keys=True) + "\n",
if os.name == "nt": executable=False,
write_file(BIN_DIR / "nap.cmd", f'@echo off\r\n"{sys.executable}" "{nap_path}" %*\r\n', executable=False)
updated_project_paths = []
if update_project:
for script_name in ("napseer_mcp_server.py", "napseer_spake2.py"):
content = (INSTALL_DIR / script_name).read_text(encoding="utf-8")
for candidate in (
pathlib.Path.cwd() / script_name,
pathlib.Path.cwd() / "resources" / "scripts" / script_name,
):
if candidate.exists():
write_file(candidate, content, executable=script_name.endswith(".py"))
updated_project_paths.append(str(candidate))
installer_payload = fetched["nap_install.py"][0]
cli_source_revision = installer_payload.get("source_revision") or CLI_GENERATED_SOURCE_REVISION
cli_source_revision_status = (
installer_payload.get("source_revision_status")
or CLI_GENERATED_SOURCE_REVISION_STATUS
or source_revision_status_for(cli_source_revision)
) )
result = { if final_release.exists():
shutil.rmtree(staging)
else:
os.replace(staging, final_release)
validate_release_hashes(final_release, items)
nap_path = activate_release(final_release)
except Exception:
if staging.exists():
shutil.rmtree(staging)
raise
return {
"status": "installed", "status": "installed",
"message": "Napseer CLI and runtime scripts are installed.", "message": "Napseer CLI and runtime bundle installed atomically.",
"bin": str(nap_path), "bin": str(nap_path),
"install_dir": str(INSTALL_DIR), "install_dir": str(INSTALL_DIR),
"release_dir": str(final_release),
"bundle_id": manifest.get("bundle_id"),
"scripts": scripts, "scripts": scripts,
"cli_distribution": { "cli_distribution": {
"contract_version": CLI_DISTRIBUTION_CONTRACT_VERSION, "release_version": manifest.get("release_version"),
"source_repo": installer_payload.get("source_repo") or CLI_GENERATED_SOURCE_REPO, "contract_version": manifest["contract"]["current"],
"source_revision": None "minimum_contract_version": manifest["contract"]["minimum_supported"],
if source_revision_status_for(cli_source_revision) == "unresolved" "source_repo": manifest["source"]["repo"],
else cli_source_revision, "source_revision": manifest["source"]["revision"],
"source_revision_status": cli_source_revision_status, "source_revision_status": manifest["source"]["revision_status"],
}, },
"updated_project_paths": updated_project_paths, "updated_project_paths": [],
"path_warning": path_warning(), "path_warning": path_warning(),
"next": ["nap project create", "nap gateway configure", "nap gateway start", "nap gateway logs", "nap update"], "next": ["nap init", "nap mcp status", "nap gateway setup"],
} }
return result
def runtime_assets_missing(): def runtime_assets_missing():
missing = [] missing = []
for name in RUNTIME_SCRIPT_NAMES: for name in RUNTIME_SCRIPT_NAMES:
path = INSTALL_DIR / { path = runtime_asset_path(name)
"terminal_init.py": "terminal/__init__.py",
"terminal_protocol.py": "terminal/protocol.py",
"terminal_pty_manager.py": "terminal/pty_manager.py",
}.get(name, name)
if not path.exists() or path.stat().st_size == 0: if not path.exists() or path.stat().st_size == 0:
missing.append(name) missing.append(name)
return missing return missing
def ensure_runtime_assets(): def active_install_root():
current = INSTALL_DIR / "current"
return current if current.exists() else INSTALL_DIR
def runtime_asset_path(name):
return active_install_root() / INSTALL_PATHS.get(name, name)
def require_runtime_assets():
missing = runtime_assets_missing() missing = runtime_assets_missing()
if missing: if missing:
install_assets(update_project=False) raise RuntimeError(
missing = runtime_assets_missing() "missing installed Napseer runtime scripts: "
if missing: f"{', '.join(missing)}. Run `nap mcp install` to repair them."
raise RuntimeError(f"missing installed Napseer runtime scripts after repair: {', '.join(missing)}") )
def run_wrapper(command_name, args): def run_wrapper(command_name, args):
wrapper = INSTALL_DIR / "napseer_mcp_server.py" wrapper = runtime_asset_path("napseer_mcp_server.py")
ensure_runtime_assets() require_runtime_assets()
return subprocess.call([sys.executable, str(wrapper), command_name, *args]) return subprocess.call([sys.executable, str(wrapper), command_name, *args])
@@ -277,14 +536,17 @@ def cwd_state_dir():
def state_dir_status(): def state_dir_status():
preferred = preferred_state_dir() preferred = preferred_state_dir()
active = cwd_state_dir()
return { return {
"active_state_dir": str(active), "active_state_dir": str(preferred),
"active_auth_path": str(active / "auth.json"), "active_auth_path": str(preferred / "auth.json"),
"state_dir": str(preferred), "state_dir": str(preferred),
"auth_path": str(preferred / "auth.json"), "auth_path": str(preferred / "auth.json"),
"state_dir_exists": preferred.exists(), "state_dir_exists": preferred.exists(),
"state_dir_message": ".napseer directory is active.", "state_dir_message": (
".napseer directory is active."
if preferred.exists()
else ".napseer directory is not initialized."
),
} }
@@ -469,8 +731,8 @@ def cli_option(args, *names, default=None):
def start_service(kind, args): def start_service(kind, args):
wrapper = INSTALL_DIR / "napseer_mcp_server.py" wrapper = runtime_asset_path("napseer_mcp_server.py")
ensure_runtime_assets() require_runtime_assets()
existing = read_service_state(kind) existing = read_service_state(kind)
if existing and pid_running(existing.get("pid")): if existing and pid_running(existing.get("pid")):
return { return {
@@ -629,15 +891,211 @@ def print_json(value):
print(json.dumps(value, indent=2)) print(json.dumps(value, indent=2))
def print_root_help():
print("Usage: nap <command> [arguments]")
print()
print("Commands:")
width = max(len(name) for name, _summary in CANONICAL_COMMANDS)
for name, summary in CANONICAL_COMMANDS:
print(f" {name.ljust(width)} {summary}")
print()
print("Run `nap help <command>` for command-specific help.")
def print_command_help(command):
help_text = {
"init": (
"Usage: nap init [--slug SLUG] [--name NAME] [--description TEXT]\n"
"Initialize the current folder and create its first Napseer project."
),
"status": (
"Usage: nap status\n"
"Show the current folder's account, project, and runtime state."
),
"doctor": (
"Usage: nap doctor\n"
"Run read-only checks and print exact repair commands."
),
"auth": (
"Usage: nap auth [status|login|repair]\n"
" login Authenticate an account; does not select a project.\n"
" status Show credential state without credential values.\n"
" repair Recover failed automatic credential renewal."
),
"project": (
"Usage: nap project [init|create|attach|claim|status|encryption]\n"
" init Initialize a fresh folder (same workflow as `nap init`).\n"
" create Create and bind a new project.\n"
" attach Select an existing project owned by the logged-in account.\n"
" claim Move an anonymous project into an authenticated account.\n"
" status Show the current folder binding."
),
"mcp": (
"Usage: nap mcp [status|install|update|serve]\n"
" status Show supervisor and worker installation state.\n"
" install Install or repair local MCP assets.\n"
" update Update local MCP assets from verified published sources.\n"
" serve Run the stable stdio supervisor."
),
"gateway": (
"Usage: nap gateway [setup|start|stop|restart|status|logs|vault|terminal|schedule|service]\n"
"Common lifecycle commands are setup, start, stop, restart, status, and logs.\n"
"Vault, terminal, schedule, and service are advanced capability groups."
),
"update": (
"Usage: nap update\n"
"Update the installed CLI and runtime bundle. Help never performs an update."
),
"version": (
"Usage: nap version\n"
"Show distribution contract and canonical source revision."
),
"install": "Compatibility command. Use `nap mcp install`.",
"where": "Compatibility command. Use `nap doctor` or `nap mcp status`.",
"chat": "Compatibility command. Chat-secret operations are an advanced runtime workflow.",
"plan": "Compatibility command. Agent planning belongs to the authenticated MCP surface.",
"lineage": "Compatibility command. Agent lineage checks belong to the authenticated MCP surface.",
"agent": "Compatibility command. Agent workspace operations belong to the authenticated MCP surface.",
"feedback": "Compatibility command. Feedback administration is not a primary operator workflow.",
}
if command in {"help", "-h", "--help", ""}:
print_root_help()
return 0
text = help_text.get(command)
if text is None:
raise RuntimeError(f"unknown help topic: {command}")
print(text)
return 0
def mcp_status():
worker = runtime_asset_path("napseer_mcp_server.py")
supervisor = runtime_asset_path("napseer_mcp_supervisor.py")
missing = runtime_assets_missing()
return {
"status": "ok" if not missing else "repair_required",
"supervisor_installed": supervisor.is_file(),
"worker_installed": worker.is_file(),
"runtime_assets_missing": missing,
"serve_command": f"{sys.executable} {supervisor}",
"next": None if not missing else "nap mcp install",
}
def handle_mcp(args):
subcommand = args[0] if args else "status"
rest = args[1:]
if subcommand == "status":
print_json(mcp_status())
return 0
if subcommand == "install":
print_json(install_assets())
return 0
if subcommand == "update":
result = install_assets()
print_json({**result, "status": "updated", "message": "Napseer MCP runtime is updated."})
return 0
if subcommand == "serve":
if rest:
raise RuntimeError("nap mcp serve does not accept arguments")
require_runtime_assets()
supervisor = runtime_asset_path("napseer_mcp_supervisor.py")
os.execv(sys.executable, [sys.executable, str(supervisor)])
raise RuntimeError("unknown MCP command; use nap mcp status|install|update|serve")
def doctor_status():
state = state_dir_status()
auth_path = pathlib.Path(state["active_auth_path"])
auth = {}
auth_error = None
if auth_path.exists():
try:
auth = json.loads(auth_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
auth_error = "auth file is unreadable or invalid JSON"
missing = runtime_assets_missing()
issues = []
if auth_error:
issues.append({"code": "auth_file_invalid", "message": auth_error, "next": "nap auth login"})
elif not auth_path.exists():
issues.append({"code": "project_not_initialized", "message": "current folder is not initialized", "next": "nap init"})
elif not auth.get("project_id"):
issues.append({"code": "project_not_attached", "message": "no project is attached", "next": "nap project attach"})
if missing:
issues.append({"code": "runtime_assets_missing", "message": "local MCP runtime is incomplete", "next": "nap mcp install"})
if not runtime_asset_path("napseer_mcp_supervisor.py").is_file():
issues.append({"code": "supervisor_missing", "message": "stable MCP supervisor is not installed", "next": "nap mcp install"})
return {
"status": "ok" if not issues else "repair_required",
"checks": {
"state_directory": state["state_dir_exists"],
"auth_file": auth_path.exists() and auth_error is None,
"account_mode": auth.get("account_mode"),
"token_present": bool(auth.get("token")),
"refresh_present": bool(auth.get("refresh_token")),
"project_attached": bool(auth.get("project_id")),
"mcp_supervisor": runtime_asset_path("napseer_mcp_supervisor.py").is_file(),
"mcp_worker": runtime_asset_path("napseer_mcp_server.py").is_file(),
},
"issues": issues,
}
def version_status():
manifest_path = active_install_root() / "bundle-manifest.json"
if manifest_path.is_file():
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
source = manifest.get("source") or {}
contract = manifest.get("contract") or {}
return {
"release_version": manifest.get("release_version"),
"contract_version": contract.get("current"),
"minimum_contract_version": contract.get("minimum_supported"),
"source_repo": source.get("repo"),
"source_revision": source.get("revision"),
"source_revision_status": source.get("revision_status"),
"bundle_id": manifest.get("bundle_id"),
}
except (OSError, json.JSONDecodeError):
pass
return {
"release_version": CLI_RELEASE_VERSION,
"contract_version": CLI_DISTRIBUTION_CONTRACT_VERSION,
"minimum_contract_version": CLI_MINIMUM_CONTRACT_VERSION,
"source_repo": CLI_GENERATED_SOURCE_REPO,
"source_revision": None
if source_revision_status_for(CLI_GENERATED_SOURCE_REVISION) == "unresolved"
else CLI_GENERATED_SOURCE_REVISION,
"source_revision_status": source_revision_status_for(CLI_GENERATED_SOURCE_REVISION),
}
def main(argv): def main(argv):
invoked_as_nap = pathlib.Path(argv[0]).name in {"nap", "nap.cmd"} invoked_as_nap = pathlib.Path(argv[0]).name in {"nap", "nap.cmd"}
command = argv[1] if len(argv) > 1 else ("help" if invoked_as_nap else "install") command = argv[1] if len(argv) > 1 else ("help" if invoked_as_nap else "install")
args = argv[2:] args = argv[2:]
if command in HELP_TOKENS:
return print_command_help(args[0] if args else "")
if any(item in HELP_TOKENS for item in args):
return print_command_help(command)
if command == "init":
return run_wrapper("project", ["init", *args])
if command == "doctor":
print_json(doctor_status())
return 0
if command == "mcp":
return handle_mcp(args)
if command == "version":
print_json(version_status())
return 0
if command == "install": if command == "install":
print_json(install_assets(update_project=False)) print("`nap install` is deprecated; use `nap mcp install`.", file=sys.stderr)
print_json(install_assets())
return 0 return 0
if command == "update": if command == "update":
result = install_assets(update_project=True) result = install_assets()
print_json({**result, "status": "updated", "message": "Napseer CLI and runtime scripts are updated."}) print_json({**result, "status": "updated", "message": "Napseer CLI and runtime scripts are updated."})
return 0 return 0
if command == "gateway": if command == "gateway":
@@ -659,6 +1117,10 @@ def main(argv):
if command == "agent": if command == "agent":
return run_wrapper("agent", args) return run_wrapper("agent", args)
if command == "auth": if command == "auth":
if args and args[0] == "repair":
return run_wrapper("auth", ["refresh", *args[1:]])
if args and args[0] == "refresh":
print("`nap auth refresh` is deprecated; use `nap auth repair`.", file=sys.stderr)
return run_wrapper("auth", args) return run_wrapper("auth", args)
if command == "feedback": if command == "feedback":
return run_wrapper("feedback", args) return run_wrapper("feedback", args)
@@ -668,7 +1130,8 @@ def main(argv):
{ {
"bin": shutil.which("nap") or str(BIN_DIR / "nap"), "bin": shutil.which("nap") or str(BIN_DIR / "nap"),
"install_dir": str(INSTALL_DIR), "install_dir": str(INSTALL_DIR),
"wrapper": str(INSTALL_DIR / "napseer_mcp_server.py"), "wrapper": str(runtime_asset_path("napseer_mcp_server.py")),
"supervisor": str(runtime_asset_path("napseer_mcp_supervisor.py")),
"cwd": str(pathlib.Path.cwd()), "cwd": str(pathlib.Path.cwd()),
"cwd_auth_path": state["active_auth_path"], "cwd_auth_path": state["active_auth_path"],
"cwd_auth_configured": pathlib.Path(state["active_auth_path"]).exists(), "cwd_auth_configured": pathlib.Path(state["active_auth_path"]).exists(),
@@ -679,31 +1142,6 @@ def main(argv):
} }
) )
return 0 return 0
if command in {"help", "-h", "--help"}:
print("Usage: nap [auth|project|chat|plan|lineage|gateway|agent|feedback|status|update|where|install]")
print(" auth Auth commands: login-local, status.")
print(" Example: nap auth login-local")
print(" project Project commands: init/attach existing, create, status, encryption.")
print(" Examples: nap project init, nap project create, nap project status")
print(" Encrypted project setup is completed by the local gateway: nap gateway vault process")
print(" chat Chat secret commands: secret status, secret setup, secret rotate.")
print(" Examples: nap chat secret setup --vault-passphrase TEXT, nap chat secret status")
print(" plan Plan commands: list, to-kanban.")
print(" Example: nap plan to-kanban /plans/example --column todo")
print(" lineage Generic source-record -> plan -> kanban lineage checks.")
print(" Example: nap lineage status")
print(" gateway Gateway commands: start, stop, configure, setup, restart, kill, logs, status, vault.")
print(" Start auto-selects a local port; use --port to pin one.")
print(" Examples: nap gateway start, nap gateway vault, nap gateway vault process --all")
print(" agent Agent commands: list, workspaces, create, show, edit.")
print(" list shows registered project agents; workspaces shows /agents folders.")
print(" feedback Feedback commands: list, global, status, resolve.")
print(" Examples: nap feedback list, nap feedback global --all, nap feedback resolve <id> --notes TEXT")
print(" status Show cwd Napseer configuration and next setup commands.")
print(" update Refresh the globally installed nap command and wrapper.")
print(" where Show installed nap paths and cwd auth location.")
print(" install Reinstall/repair nap from the hosted script directory.")
return 0
raise RuntimeError(f"unknown command: {command}") raise RuntimeError(f"unknown command: {command}")
-11
View File
@@ -1,11 +0,0 @@
"""Napseer local MCP wrapper package.
Modules:
crypto - Cryptographic primitives (ChaCha20-Poly1305, AES-256-GCM, key derivation)
vault - Vault operations and secret management
telemetry - Event logging and metrics
gateway - Gateway management and relay
constants - Shared constants (discovery views, kanban, plans, etc.)
tools - MCP tool definitions and registry
cli - CLI interface and command handlers
"""
-125
View File
@@ -1,125 +0,0 @@
"""Shared constants for Napseer MCP server."""
# Discovery
DISCOVERY_VIEWS = {"titles", "paths", "summary", "metadata"}
DISCOVERY_DEFAULT_LIMIT = 25
DISCOVERY_COMPACT_MAX_LIMIT = 10_000
DISCOVERY_FULL_MAX_LIMIT = 10_000
DISCOVERY_SOURCE_PAGE_LIMIT = 10_000
DISCOVERY_CURSOR_GUARD_PAGES = 100
# Lifecycle statuses
ACTIVE_EXCLUDED_STATUSES = {
"completed",
"complete_verified_uncommitted",
"complete_pending_human_e2e",
"superseded",
"cancelled",
"archived",
"done",
}
KNOWN_LIFECYCLE_STATUSES = {
"active",
"archived",
"backlog",
"blocked",
"cancelled",
"completed",
"complete_pending_human_e2e",
"complete_verified_uncommitted",
"current",
"doing",
"done",
"draft",
"in_progress",
"pending",
"planned",
"review",
"reviewed_plan",
"superseded",
"todo",
}
# Summary metadata keys by node type
SUMMARY_METADATA_KEYS = {
"/plans": ["status", "priority", "scope", "owner", "agent_owner", "implementation_allowed", "blocked_by"],
"/kanban": ["status", "column", "priority", "owner", "assignee", "blocked", "blocked_by", "lifecycle_state", "rank", "order", "due_date"],
"/decisions": ["status", "date", "decision_category", "decision_area", "supersedes", "superseded_by"],
"/implementation-notes": ["status", "date", "repos", "verification_summary"],
"/reviews": ["status", "date", "severity_counts"],
"/rules": ["status", "scope", "source"],
"/tasks": ["status", "priority", "blocked_by", "owner", "assignee"],
}
# Kanban
KANBAN_DONE_STATUSES = {"done", "cancelled", "archived"}
DEFAULT_KANBAN_COLUMNS = ["backlog", "todo", "doing", "review", "done"]
DEFAULT_KANBAN_PRIORITIES = ["low", "normal", "high", "urgent"]
KANBAN_RELATIONS = {
"blocked_by",
"blocks",
"follows",
"precedes",
"relates_to",
"duplicates",
"duplicate_of",
"parent_of",
"child_of",
"tracks",
"tracked_by",
"milestone_of",
"has_milestone",
"mutually_exclusive_with",
"implements",
"references",
}
KANBAN_RELATION_BLOCKED_BY = "blocked_by"
KANBAN_RELATION_BLOCKS = "blocks"
KANBAN_RELATIONS_SOFT_DEPENDENCY = {"follows", "precedes"}
KANBAN_RELATIONS_RELATED = {"relates_to", "tracks", "tracked_by"}
KANBAN_RELATION_CHILD_OF = "child_of"
KANBAN_RELATION_PARENT_OF = "parent_of"
KANBAN_RELATIONS_DUPLICATE = {"duplicates", "duplicate_of"}
KANBAN_RELATIONS_MILESTONE = {"milestone_of", "has_milestone"}
KANBAN_RELATION_MUTUALLY_EXCLUSIVE = "mutually_exclusive_with"
KANBAN_DEPENDENCY_VIEWS = ["ready", "blocked", "blocking", "related", "children", "duplicates"]
KANBAN_DEPENDENCY_VIEW_READY = "ready"
KANBAN_DEPENDENCY_VIEW_BLOCKED = "blocked"
KANBAN_DEPENDENCY_VIEW_BLOCKING = "blocking"
KANBAN_DEPENDENCY_VIEW_RELATED = "related"
KANBAN_DEPENDENCY_VIEW_CHILDREN = "children"
KANBAN_DEPENDENCY_VIEW_DUPLICATES = "duplicates"
KANBAN_EXTERNAL_BLOCKERS_KEY = "external_blockers"
KANBAN_BLOCK_STATE_OPEN_HARD_KEY = "open_hard_blockers"
KANBAN_RESOLVED_EXTERNAL_STATES = {"done", "closed", "resolved"}
KANBAN_SOFT_BLOCKER_STRENGTH = "soft"
KANBAN_PRIORITY_RANK = {"urgent": 0, "high": 1, "normal": 2, "low": 3}
KANBAN_COLUMN_LIFECYCLE = {
"backlog": "open",
"todo": "open",
"doing": "active",
"review": "review",
"done": "done",
}
# Planning
PLANNING_SOURCE_RELATIONS = {
"created_from",
"derived_from",
"implements",
"references",
"tracks",
"verifies",
}
PLAN_ORIGIN_RELATIONS = {
"created_from",
"derived_from",
"implements",
"references",
"tracks",
}
def discovery_max_limit(view):
"""Get the max limit for a discovery view."""
return DISCOVERY_FULL_MAX_LIMIT if view == "full" else DISCOVERY_COMPACT_MAX_LIMIT
-227
View File
@@ -1,227 +0,0 @@
"""Cryptographic primitives for Napseer vault operations.
Implements:
- ChaCha20-Poly1305 AEAD
- AES-256-GCM
- PBKDF2 key derivation
- Relay encryption/decryption
"""
import hashlib
import hmac
import secrets
import struct
def _rotl32(value, shift):
return ((value << shift) & 0xFFFFFFFF) | (value >> (32 - shift))
def _quarter_round(state, a, b, c, d):
state[a] = (state[a] + state[b]) & 0xFFFFFFFF
state[d] ^= state[a]
state[d] = _rotl32(state[d], 16)
state[c] = (state[c] + state[d]) & 0xFFFFFFFF
state[b] ^= state[c]
state[b] = _rotl32(state[b], 12)
state[a] = (state[a] + state[b]) & 0xFFFFFFFF
state[d] ^= state[a]
state[d] = _rotl32(state[d], 8)
state[c] = (state[c] + state[d]) & 0xFFFFFFFF
state[b] ^= state[c]
state[b] = _rotl32(state[b], 7)
def _chacha20_block(key, counter, nonce):
"""Generate a ChaCha20 block."""
constants = b"expand 32-byte k"
state = list(struct.unpack("<4I", constants))
state.extend(struct.unpack("<8I", key))
state.append(counter & 0xFFFFFFFF)
state.extend(struct.unpack("<3I", nonce))
working = state[:]
for _ in range(10):
_quarter_round(working, 0, 4, 8, 12)
_quarter_round(working, 1, 5, 9, 13)
_quarter_round(working, 2, 6, 10, 14)
_quarter_round(working, 3, 7, 11, 15)
_quarter_round(working, 0, 5, 10, 15)
_quarter_round(working, 1, 6, 11, 12)
_quarter_round(working, 2, 7, 8, 13)
_quarter_round(working, 3, 4, 9, 14)
return struct.pack("<16I", *[((working[i] + state[i]) & 0xFFFFFFFF) for i in range(16)])
def _chacha20_xor(key, nonce, data):
"""XOR data with ChaCha20 cipher."""
output = bytearray()
counter = 1
for offset in range(0, len(data), 64):
block = _chacha20_block(key, counter, nonce)
chunk = data[offset : offset + 64]
output.extend(bytes(left ^ right for left, right in zip(chunk, block)))
counter = (counter + 1) & 0xFFFFFFFF
return bytes(output)
def _poly1305_mac(message, key):
"""Poly1305 message authentication code."""
r = int.from_bytes(key[:16], "little")
r &= 0x0FFFFFFC0FFFFFFC0FFFFFFC0FFFFFFF
s = int.from_bytes(key[16:], "little")
p = (1 << 130) - 5
acc = 0
for offset in range(0, len(message), 16):
block = message[offset : offset + 16]
acc = (acc + int.from_bytes(block + b"\x01", "little")) % p
acc = (acc * r) % p
return ((acc + s) % (1 << 128)).to_bytes(16, "little")
def _aead_mac_data(aad, ciphertext):
"""Prepare data for AEAD MAC."""
def pad16(data):
remainder = len(data) % 16
return b"" if remainder == 0 else b"\x00" * (16 - remainder)
return aad + pad16(aad) + ciphertext + pad16(ciphertext) + struct.pack("<QQ", len(aad), len(ciphertext))
def _encrypt_vault_payload(key, plaintext, aad=b""):
"""Encrypt vault payload using ChaCha20-Poly1305."""
nonce = secrets.token_bytes(12)
one_time_key = _chacha20_block(key, 0, nonce)[:32]
ciphertext = _chacha20_xor(key, nonce, plaintext)
tag = _poly1305_mac(_aead_mac_data(aad, ciphertext), one_time_key)
return nonce, ciphertext, tag
def _decrypt_vault_payload(key, nonce, ciphertext, tag, aad=b""):
"""Decrypt vault payload using ChaCha20-Poly1305."""
one_time_key = _chacha20_block(key, 0, nonce)[:32]
expected = _poly1305_mac(_aead_mac_data(aad, ciphertext), one_time_key)
if not hmac.compare_digest(expected, tag):
raise ValueError("local gateway vault open failed")
return _chacha20_xor(key, nonce, ciphertext)
def _derive_vault_key(passphrase, salt, iterations):
"""Derive vault key from passphrase using PBKDF2."""
if not isinstance(passphrase, str) or not passphrase:
raise ValueError("local gateway vault key is required")
return hashlib.pbkdf2_hmac("sha256", passphrase.encode("utf-8"), salt, iterations, dklen=32)
# AES-256-GCM helpers
def _aes_xtime(value):
"""AES GF(2^8) multiplication by 2."""
return ((value << 1) ^ 0x1B) & 0xFF if value & 0x80 else value << 1
def _aes_sub_bytes(state):
"""AES SubBytes transformation (simplified S-box lookup)."""
sbox = bytes(range(256))
return bytes(sbox[b] for b in state)
def _aes_shift_rows(state):
"""AES ShiftRows transformation."""
return bytes([
state[0], state[5], state[10], state[15],
state[4], state[9], state[14], state[3],
state[8], state[13], state[2], state[7],
state[12], state[1], state[6], state[11],
])
def _aes_mix_single_column(column):
"""Mix a single AES column."""
a, b, c, d = column
return bytes([a ^ b ^ c, a ^ b ^ d, b ^ c ^ d, a ^ c ^ d])
def _aes_mix_columns(state):
"""AES MixColumns transformation."""
result = bytearray(16)
for i in range(4):
col = [state[i], state[i+4], state[i+8], state[i+12]]
mixed = _aes_mix_single_column(col)
result[i] = mixed[0]
result[i+4] = mixed[1]
result[i+8] = mixed[2]
result[i+12] = mixed[3]
return bytes(result)
def _aes_add_round_key(state, words, round_index):
"""AES AddRoundKey transformation."""
result = bytearray(16)
for i in range(4):
word = words[round_index * 4 + i]
for j in range(4):
result[j * 4 + i] = state[j * 4 + i] ^ ((word >> (24 - j * 8)) & 0xFF)
return bytes(result)
def _aes_key_expand_256(key):
"""Expand AES-256 key to round keys."""
rcon = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40]
nk, nr = 8, 14
total_words = 4 * (nr + 1)
words = list(struct.unpack("<8I", key[:32]))
for i in range(nk, total_words):
temp = words[i - 1]
if i % nk == 0:
temp = struct.unpack("<I", _aes_sub_bytes(struct.pack("<I", (temp >> 16) & 0xFF | (temp << 16) & 0xFF0000 | (temp >> 8) & 0xFF00 | (temp << 8) & 0xFF000000)))[0] ^ (rcon[i // nk - 1] << 24)
elif i % nk == 4:
temp = struct.unpack("<I", _aes_sub_bytes(struct.pack("<I", temp)))[0]
words.append(words[i - nk] ^ temp)
return words
def _aes_encrypt_block_256(key, block):
"""Encrypt a single AES-256 block (simplified)."""
words = _aes_key_expand_256(key)
state = bytes(a ^ b for a, b in zip(block, key[:16]))
for round_idx in range(1, nr):
state = _aes_sub_bytes(state)
state = _aes_shift_rows(state)
state = _aes_mix_columns(state)
state = _aes_add_round_key(state, words, round_idx)
state = _aes_sub_bytes(state)
state = _aes_shift_rows(state)
state = _aes_add_round_key(state, words, nr)
return state
def _aes_gcm_crypt(key, nonce, aad, data):
"""AES-256-GCM encryption/decryption."""
import os
if len(data) % 16 != 0:
padding = 16 - (len(data) % 16)
data = data + bytes([0] * padding)
ciphertext = b""
counter = struct.unpack(">I", nonce[0:4])[0] ^ 1
for i in range(0, len(data), 16):
keystream = _aes_encrypt_block_256(key, struct.pack(">4sI", nonce[4:8], counter))
ciphertext += bytes(a ^ b for a, b in zip(data[i:i+16], keystream))
counter += 1
tag_key = _aes_encrypt_block_256(key, nonce)
tag = hashlib.sha256(tag_key + aad + ciphertext[:len(data)]).digest()[:16]
return ciphertext[:len(data)], tag
def aes_gcm_encrypt(key, nonce, aad, plaintext):
"""AES-256-GCM encryption."""
return _aes_gcm_crypt(key, nonce, aad, plaintext)
def aes_gcm_decrypt(key, nonce, aad, packed):
"""AES-256-GCM decryption."""
return _aes_gcm_crypt(key, nonce, aad, packed)
-282
View File
@@ -1,282 +0,0 @@
"""Gateway management and relay operations."""
import base64
import hashlib
import json
import os
import pathlib
import secrets
import socket
import ssl
import struct
import subprocess
import sys
import tempfile
import threading
import time
import uuid
import webbrowser
from datetime import datetime, timedelta, timezone
from . import crypto
# Gateway state files
def gateway_state_path():
return local_state_dir() / "gateway.json"
def gateway_schedules_state_path():
return local_state_dir() / "gateway-schedules.json"
def gateway_relay_secret_path():
return local_state_dir() / "gateway-relay.secret"
def local_state_dir():
"""Get the local state directory."""
if os.name == "nt":
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~\\AppData\\Local")
return pathlib.Path(base) / "napseer"
return pathlib.Path.home() / ".napseer"
def read_gateway_relay_passphrase(create=False):
"""Read or create gateway relay passphrase."""
value = os.environ.get("NAPSEER_GATEWAY_PASSPHRASE")
if value:
return value
path = gateway_relay_secret_path()
if path.exists():
return path.read_text().strip()
if create:
passphrase = secrets.token_urlsafe(32)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(passphrase)
path.chmod(0o600)
return passphrase
return None
def read_project_vault_passphrase(create=False):
"""Read or create project vault passphrase."""
value = os.environ.get("NAPSEER_VAULT_PASSPHRASE")
if value:
return value
path = local_state_dir() / "vault-passphrase.secret"
if path.exists():
return path.read_text().strip()
if create:
passphrase = secrets.token_urlsafe(32)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(passphrase)
path.chmod(0o600)
return passphrase
return None
def vault_exists():
"""Check if vault file exists."""
return (local_state_dir() / "vault.json").exists()
# Gateway status and management
def gateway_status():
"""Get gateway status."""
state_path = gateway_state_path()
if not state_path.exists():
return {"running": False, "configured": False, "message": "gateway not configured"}
try:
state = json.loads(state_path.read_text())
running = False
pid = state.get("pid")
if pid:
try:
os.kill(pid, 0)
running = True
except OSError:
running = False
return {
"running": running,
"configured": True,
"pid": pid,
"port": state.get("port"),
"relay_lanes": state.get("relay_lanes", ["terminal", "chat"]),
}
except (json.JSONDecodeError, IOError):
return {"running": False, "configured": False, "message": "invalid gateway state"}
def gateway_lock():
"""Acquire gateway lock file."""
lock_path = local_state_dir() / "gateway.lock"
try:
lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.close(lock_fd)
return True
except FileExistsError:
return False
def gateway_unlock():
"""Release gateway lock file."""
lock_path = local_state_dir() / "gateway.lock"
try:
os.remove(lock_path)
except FileExistsError:
pass
def clear_gateway_runtime_caches():
"""Clear gateway runtime caches."""
pass # Placeholder for cache clearing
def touch_gateway():
"""Update gateway state timestamp."""
state_path = gateway_state_path()
if state_path.exists():
try:
state = json.loads(state_path.read_text())
state["last_seen"] = datetime.now(timezone.utc).isoformat()
state_path.write_text(json.dumps(state))
except (json.JSONDecodeError, IOError):
pass
def gateway_is_unlocked():
"""Check if gateway is unlocked (has required secrets)."""
return vault_exists() and read_gateway_relay_passphrase() is not None
def gateway_locked_reason():
"""Get reason why gateway is locked."""
if not vault_exists():
return "vault_not_initialized"
if not read_gateway_relay_passphrase():
return "relay_passphrase_not_configured"
return None
# Relay operations
def derive_gateway_relay_secret(passphrase):
"""Derive relay secret from passphrase."""
return hashlib.sha256(passphrase.encode("utf-8")).digest()
def write_gateway_relay_secret(passphrase):
"""Write gateway relay secret."""
secret = derive_gateway_relay_secret(passphrase)
path = gateway_relay_secret_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(base64.b64encode(secret).decode())
path.chmod(0o600)
def read_gateway_relay_secret():
"""Read gateway relay secret."""
path = gateway_relay_secret_path()
if not path.exists():
return None
try:
return base64.b64decode(path.read_text())
except Exception:
return None
def gateway_relay_secret_fingerprint(secret):
"""Get fingerprint of relay secret."""
if not secret:
return None
return hashlib.sha256(secret).hexdigest()[:16]
def read_gateway_relay_state():
"""Read gateway relay state."""
state_path = local_state_dir() / "gateway-relay.json"
if not state_path.exists():
return {}
try:
return json.loads(state_path.read_text())
except (json.JSONDecodeError, IOError):
return {}
def write_gateway_relay_state(**updates):
"""Update gateway relay state."""
state_path = local_state_dir() / "gateway-relay.json"
state = read_gateway_relay_state()
state.update(updates)
state_path.parent.mkdir(parents=True, exist_ok=True)
state_path.write_text(json.dumps(state))
# Terminal management
def clean_terminal_name(value, fallback="terminal"):
"""Clean terminal name for safe use."""
if not value:
return fallback
cleaned = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(value))
return cleaned[:64] or fallback
def clean_tmux_name(value, fallback="napseer"):
"""Clean tmux session name."""
if not value:
return fallback
cleaned = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(value))
return cleaned[:200] or fallback
def current_tmux_target():
"""Get current tmux target."""
return os.environ.get("TMUX_TARGET", os.environ.get("TMUX_PANE", None))
# Gateway periodic logging
def gateway_log(event, level="info", **fields):
"""Log gateway event."""
timestamp = datetime.now(timezone.utc).isoformat()
log_entry = {
"timestamp": timestamp,
"level": level,
"event": event,
**fields,
}
# Write to gateway log file
log_path = local_state_dir() / "gateway.log"
try:
with open(log_path, "a") as f:
f.write(json.dumps(log_entry) + "\n")
except IOError:
pass
return log_entry
def gateway_periodic_log(key, interval_seconds, event, level="info", **fields):
"""Log gateway event with periodic throttle."""
throttle_path = local_state_dir() / f"gateway-log-throttle-{key}"
now = time.time()
try:
last_log = float(throttle_path.read_text().strip())
if now - last_log < interval_seconds:
return None
except (IOError, ValueError):
pass
try:
throttle_path.write_text(str(now))
except IOError:
pass
return gateway_log(event, level, **fields)
-165
View File
@@ -1,165 +0,0 @@
"""Telemetry and logging for Napseer MCP server."""
import hashlib
import json
import os
import pathlib
import platform
import sys
import threading
import time
import uuid
# Telemetry state
_telemetry_enabled = None
_telemetry_install_id = None
_telemetry_state_path = None
def local_state_dir():
"""Get the local state directory."""
if os.name == "nt":
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~\\AppData\\Local")
return pathlib.Path(base) / "napseer"
return pathlib.Path.home() / ".napseer"
def state_dir_status_payload():
"""Get state directory status information."""
state_dir = local_state_dir()
auth_path = state_dir / "auth.json"
active_project = None
if auth_path.exists():
try:
auth_data = json.loads(auth_path.read_text())
active_project = auth_data.get("project_id")
except (json.JSONDecodeError, IOError):
pass
return {
"state_dir": str(state_dir),
"active_auth_path": str(auth_path),
"active_project_id": active_project,
"state_dir_exists": state_dir.exists(),
"auth_configured": auth_path.exists(),
}
def telemetry_state():
"""Get or initialize telemetry state."""
global _telemetry_enabled, _telemetry_install_id, _telemetry_state_path
if _telemetry_enabled is not None:
return {"enabled": _telemetry_enabled, "install_id": _telemetry_install_id}
_telemetry_state_path = local_state_dir() / "telemetry.json"
if _telemetry_state_path.exists():
try:
state = json.loads(_telemetry_state_path.read_text())
_telemetry_enabled = state.get("enabled", True)
_telemetry_install_id = state.get("install_id")
except (json.JSONDecodeError, IOError):
_telemetry_enabled = True
_telemetry_install_id = None
else:
_telemetry_enabled = True
_telemetry_install_id = None
return {"enabled": _telemetry_enabled, "install_id": _telemetry_install_id}
def telemetry_enabled():
"""Check if telemetry is enabled."""
return telemetry_state()["enabled"]
def telemetry_install_id():
"""Get or generate telemetry install ID."""
state = telemetry_state()
if state["install_id"]:
return state["install_id"]
install_id = str(uuid.uuid4())
_telemetry_state_path = local_state_dir() / "telemetry.json"
try:
_telemetry_state_path.parent.mkdir(parents=True, exist_ok=True)
_telemetry_state_path.write_text(json.dumps({
"enabled": True,
"install_id": install_id,
}))
global _telemetry_install_id
_telemetry_install_id = install_id
except IOError:
pass
return install_id
def telemetry_safe_text(value, max_len=128):
"""Sanitize value for telemetry."""
if value is None:
return None
text = str(value)
if len(text) > max_len:
text = text[:max_len] + "..."
return text.replace("\n", " ").replace("\r", "")
def telemetry_error_code(exc):
"""Extract error code from exception."""
exc_type = type(exc).__name__
error_map = {
"RuntimeError": "RUNTIME_ERROR",
"ValueError": "VALUE_ERROR",
"KeyError": "KEY_ERROR",
"IOError": "IO_ERROR",
"HTTPError": "HTTP_ERROR",
"TimeoutError": "TIMEOUT_ERROR",
"ConnectionError": "CONNECTION_ERROR",
}
return error_map.get(exc_type, "UNKNOWN_ERROR")
def send_telemetry_event(event, component, outcome="success", **fields):
"""Send a telemetry event (synchronous)."""
if not telemetry_enabled():
return
payload = {
"event": event,
"component": component,
"outcome": outcome,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"platform": platform.system(),
"platform_version": platform.release(),
"python_version": platform.python_version(),
"install_id": telemetry_install_id(),
**fields,
}
try:
import urllib.error
import urllib.parse
import urllib.request
base_url = os.environ.get("NAPSEER_BASE_URL", "https://api.napseer.com")
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
f"{base_url}/v1/telemetry",
data=data,
headers={"Content-Type": "application/json", "User-Agent": "napseer-mcp/0.1"},
method="POST",
)
urllib.request.urlopen(request, timeout=5)
except Exception:
pass # Telemetry failures are silent
async def send_telemetry_event_async(event, component, outcome="success", **fields):
"""Send a telemetry event (async wrapper)."""
send_telemetry_event(event, component, outcome, **fields)
File diff suppressed because it is too large Load Diff
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Stable stdio supervisor for the generated Napseer MCP worker.
Codex keeps the stdio process it launches for the lifetime of a client
session. If that process exits, Codex keeps a closed transport and does not
respawn it. This supervisor owns the client-facing pipes and treats
``napseer_mcp_server.py`` as a replaceable worker:
- a worker that exited between requests is restarted before the next request;
- a wrapper file replaced by ``nap update`` is reloaded before the next request;
- a worker that exits during a tool call is restarted, while the interrupted
call receives a bounded JSON-RPC error and is never replayed automatically.
The supervisor never logs MCP payloads, tool results, auth state, or child
stderr. Protocol stdout contains JSON-RPC responses only.
"""
from __future__ import annotations
import json
import os
import pathlib
import selectors
import signal
import subprocess
import sys
import threading
from typing import BinaryIO
WORKER_PATH = pathlib.Path(
os.environ.get(
"NAPSEER_MCP_WORKER_PATH",
pathlib.Path(__file__).resolve().with_name("napseer_mcp_server.py"),
)
).expanduser()
RESPONSE_TIMEOUT_SECONDS = max(
1,
int(os.environ.get("NAPSEER_MCP_RESPONSE_TIMEOUT_SECONDS", "300")),
)
WORKER_RESTARTED_ERROR = -32098
class WorkerUnavailable(RuntimeError):
"""The replaceable MCP worker could not complete a request."""
def source_identity(path: pathlib.Path) -> tuple[int, int, int]:
stat = path.stat()
return stat.st_ino, stat.st_size, stat.st_mtime_ns
def request_id(message: bytes):
try:
payload = json.loads(message)
except (TypeError, ValueError):
return None
if not isinstance(payload, dict):
return None
return payload.get("id")
def is_notification(message: bytes) -> bool:
try:
payload = json.loads(message)
except (TypeError, ValueError):
return False
return isinstance(payload, dict) and "id" not in payload
def worker_error(message: bytes) -> bytes:
payload = {
"jsonrpc": "2.0",
"id": request_id(message),
"error": {
"code": WORKER_RESTARTED_ERROR,
"message": "Napseer MCP worker restarted; retry this tool call.",
},
}
return json.dumps(payload, separators=(",", ":")).encode("utf-8") + b"\n"
class Worker:
def __init__(self, path: pathlib.Path):
self.path = path
self.process: subprocess.Popen[bytes] | None = None
self.identity: tuple[int, int, int] | None = None
def _discard_stderr(self, stream: BinaryIO) -> None:
try:
for _line in stream:
pass
finally:
stream.close()
def start(self) -> None:
self.stop()
try:
identity = source_identity(self.path)
process = subprocess.Popen(
[sys.executable, "-u", str(self.path)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
except (OSError, subprocess.SubprocessError) as exc:
raise WorkerUnavailable("unable to start Napseer MCP worker") from exc
if process.stdin is None or process.stdout is None or process.stderr is None:
process.kill()
process.wait()
raise WorkerUnavailable("Napseer MCP worker pipes are unavailable")
self.process = process
self.identity = identity
threading.Thread(
target=self._discard_stderr,
args=(process.stderr,),
daemon=True,
).start()
def stop(self) -> None:
process = self.process
self.process = None
self.identity = None
if process is None:
return
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
for stream in (process.stdin, process.stdout):
if stream is not None:
stream.close()
def ensure_current(self) -> None:
try:
current_identity = source_identity(self.path)
except OSError as exc:
self.stop()
raise WorkerUnavailable("Napseer MCP worker is missing") from exc
if (
self.process is None
or self.process.poll() is not None
or self.identity != current_identity
):
self.start()
def _read_response(self) -> bytes:
process = self.process
if process is None or process.stdout is None:
raise WorkerUnavailable("Napseer MCP worker is not running")
selector = selectors.DefaultSelector()
try:
selector.register(process.stdout, selectors.EVENT_READ)
if not selector.select(RESPONSE_TIMEOUT_SECONDS):
raise WorkerUnavailable("Napseer MCP worker response timed out")
response = process.stdout.readline()
finally:
selector.close()
if not response:
raise WorkerUnavailable("Napseer MCP worker exited before responding")
try:
json.loads(response)
except ValueError as exc:
raise WorkerUnavailable("Napseer MCP worker returned invalid JSON") from exc
return response if response.endswith(b"\n") else response + b"\n"
def exchange(self, message: bytes) -> bytes | None:
self.ensure_current()
process = self.process
if process is None or process.stdin is None:
raise WorkerUnavailable("Napseer MCP worker is not running")
try:
process.stdin.write(message if message.endswith(b"\n") else message + b"\n")
process.stdin.flush()
except (BrokenPipeError, OSError) as exc:
raise WorkerUnavailable("Napseer MCP worker input closed") from exc
if is_notification(message):
return None
return self._read_response()
def supervise() -> int:
worker = Worker(WORKER_PATH)
def stop_worker(_signum=None, _frame=None):
worker.stop()
raise SystemExit(0)
signal.signal(signal.SIGTERM, stop_worker)
signal.signal(signal.SIGINT, stop_worker)
for message in sys.stdin.buffer:
if not message.strip():
continue
try:
response = worker.exchange(message)
except WorkerUnavailable:
worker.stop()
try:
worker.start()
except WorkerUnavailable:
pass
response = None if is_notification(message) else worker_error(message)
if response is not None:
sys.stdout.buffer.write(response)
sys.stdout.buffer.flush()
worker.stop()
return 0
if __name__ == "__main__":
raise SystemExit(supervise())
Regular → Executable
+580 -161
View File
@@ -3,18 +3,19 @@
Usage: Usage:
python3 nap_install.py python3 nap_install.py
nap project init
nap project create nap project create
nap project status nap project status
nap service start nap gateway start
nap service status nap gateway stop
nap service logs
nap gateway configure nap gateway configure
nap gateway logs
nap status nap status
nap agent list nap agent list
nap update nap update
""" """
import ast
import contextlib
import json import json
import hashlib import hashlib
import os import os
@@ -35,6 +36,7 @@ INSTALL_DIR = pathlib.Path(os.environ.get("NAPSEER_HOME", pathlib.Path.home() /
BIN_DIR = pathlib.Path(os.environ.get("NAPSEER_BIN_DIR", pathlib.Path.home() / ".local" / "bin")) BIN_DIR = pathlib.Path(os.environ.get("NAPSEER_BIN_DIR", pathlib.Path.home() / ".local" / "bin"))
SCRIPT_NAMES = ( SCRIPT_NAMES = (
"napseer_mcp_server.py", "napseer_mcp_server.py",
"napseer_mcp_supervisor.py",
"napseer_spake2.py", "napseer_spake2.py",
"terminal_init.py", "terminal_init.py",
"terminal_protocol.py", "terminal_protocol.py",
@@ -50,12 +52,57 @@ RUNTIME_SCRIPT_NAMES = (
"terminal_protocol.py", "terminal_protocol.py",
"terminal_pty_manager.py", "terminal_pty_manager.py",
"napseer_mcp_server.py", "napseer_mcp_server.py",
"napseer_mcp_supervisor.py",
) )
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024))) MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-05-19" CLI_RELEASE_VERSION = "0.2.0"
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-07-29"
CLI_MINIMUM_CONTRACT_VERSION = "2026-05-19"
CLI_BUNDLE_SCHEMA_VERSION = "napseer.cli.bundle.v1"
INSTALL_PATHS = {
"nap_install.py": "nap_install.py",
"napseer_mcp_server.py": "napseer_mcp_server.py",
"napseer_mcp_supervisor.py": "napseer_mcp_supervisor.py",
"napseer_spake2.py": "napseer_spake2.py",
"terminal_init.py": "terminal/__init__.py",
"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://github.com/napseer/nap-cli")
CLI_GENERATED_SOURCE_REVISION = os.environ.get("NAPSEER_CLI_SOURCE_REVISION", "unresolved") CLI_GENERATED_SOURCE_REVISION = os.environ.get("NAPSEER_CLI_SOURCE_REVISION", "unresolved")
CLI_GENERATED_SOURCE_REVISION_STATUS = os.environ.get("NAPSEER_CLI_SOURCE_REVISION_STATUS", "unresolved") CLI_GENERATED_SOURCE_REVISION_STATUS = os.environ.get("NAPSEER_CLI_SOURCE_REVISION_STATUS", "unresolved")
HELP_TOKENS = {"help", "-h", "--help"}
CANONICAL_COMMANDS = (
("init", "Initialize Napseer in the current folder."),
("status", "Show concise account, project, and runtime state."),
("doctor", "Diagnose local setup without changing it."),
("auth", "Authenticate an account or repair credentials."),
("project", "Create, attach, claim, or inspect a project."),
("mcp", "Install, update, inspect, or serve the local MCP runtime."),
("gateway", "Set up and operate the local gateway."),
("update", "Update the installed CLI and runtime bundle."),
("version", "Show version, source revision, and contract identity."),
("help", "Show this help or help for one command."),
)
COMMAND_METADATA = {
"init": {"mutates": True, "visible": True},
"status": {"mutates": False, "visible": True},
"doctor": {"mutates": False, "visible": True},
"auth": {"mutates": True, "visible": True},
"project": {"mutates": True, "visible": True},
"mcp": {"mutates": True, "visible": True},
"gateway": {"mutates": True, "visible": True},
"update": {"mutates": True, "visible": True},
"version": {"mutates": False, "visible": True},
"help": {"mutates": False, "visible": True},
"chat": {"mutates": True, "visible": False},
"plan": {"mutates": True, "visible": False},
"lineage": {"mutates": False, "visible": False},
"agent": {"mutates": True, "visible": False},
"feedback": {"mutates": True, "visible": False},
"where": {"mutates": False, "visible": False},
"install": {"mutates": True, "visible": False},
}
def chmod_best_effort(path, mode): def chmod_best_effort(path, mode):
@@ -115,16 +162,23 @@ def read_url_bytes(url, user_agent, label):
raise RuntimeError(f"{label} failed: HTTP {exc.code}: {body}") from exc raise RuntimeError(f"{label} failed: HTTP {exc.code}: {body}") from exc
def fetch_script(name): def fetch_json(path, label):
try: try:
body = read_url_bytes( body = read_url_bytes(
f"{BASE_URL}/v1/scripts/{name}", f"{BASE_URL}{path}",
"nap-install-python/0.1", "nap-install-python/0.1",
f"GET /v1/scripts/{name}", f"GET {path}",
) )
payload = json.loads(body.decode("utf-8")) payload = json.loads(body.decode("utf-8"))
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise RuntimeError(f"{name} response was not valid JSON") from exc raise RuntimeError(f"{label} response was not valid JSON") from exc
if not isinstance(payload, dict):
raise RuntimeError(f"{label} response must be a JSON object")
return payload
def fetch_script(name):
payload = fetch_json(f"/v1/scripts/{name}", name)
content = payload.get("content") content = payload.get("content")
if not isinstance(content, str) or not content.strip(): if not isinstance(content, str) or not content.strip():
raise RuntimeError(f"{name} response did not include script content") raise RuntimeError(f"{name} response did not include script content")
@@ -160,108 +214,312 @@ def source_revision_status_for(value):
return "resolved" return "resolved"
def install_assets(update_project=False): def bundle_manifest():
scripts = {} return fetch_json("/v1/scripts", "CLI bundle manifest")
fetched = {}
for name in SCRIPT_NAMES:
payload, content = fetch_script(name)
fetched[name] = (payload, content)
install_order = []
for name in (*RUNTIME_SCRIPT_NAMES, "nap_install.py", *SCRIPT_NAMES): def validated_manifest_items(manifest):
if name not in install_order: if manifest.get("schema_version") != CLI_BUNDLE_SCHEMA_VERSION:
install_order.append(name) raise RuntimeError(
for name in install_order: f"unsupported CLI bundle schema: {manifest.get('schema_version')!r}"
payload, content = fetched[name] )
target = INSTALL_DIR / str(payload.get("filename") or name) contract = manifest.get("contract")
write_file(target, content, executable=name.endswith(".py")) if not isinstance(contract, dict):
raise RuntimeError("CLI bundle manifest is missing contract metadata")
if contract.get("current") != CLI_DISTRIBUTION_CONTRACT_VERSION:
raise RuntimeError("CLI bundle contract does not match this installer")
if contract.get("minimum_supported") != CLI_MINIMUM_CONTRACT_VERSION:
raise RuntimeError("CLI bundle minimum contract metadata is inconsistent")
if manifest.get("release_version") != CLI_RELEASE_VERSION:
raise RuntimeError("CLI bundle release version does not match this installer")
source = manifest.get("source")
if (
not isinstance(source, dict)
or source_revision_status_for(source.get("revision")) != "resolved"
):
raise RuntimeError("CLI bundle source revision must be resolved")
items = manifest.get("items")
if not isinstance(items, list):
raise RuntimeError("CLI bundle manifest items must be an array")
by_name = {}
for item in items:
if not isinstance(item, dict) or not isinstance(item.get("name"), str):
raise RuntimeError("CLI bundle manifest contains an invalid item")
name = item["name"]
if name in by_name:
raise RuntimeError(f"CLI bundle manifest contains duplicate asset {name}")
by_name[name] = item
missing = [name for name in SCRIPT_NAMES if name not in by_name]
if missing:
raise RuntimeError(f"CLI bundle manifest is missing assets: {', '.join(missing)}")
for name in SCRIPT_NAMES:
item = by_name[name]
expected_path = INSTALL_PATHS[name]
path = pathlib.PurePosixPath(str(item.get("install_path") or ""))
if str(path) != expected_path or path.is_absolute() or ".." in path.parts:
raise RuntimeError(f"CLI bundle asset {name} has unsafe install_path")
if item.get("mode") != "0755":
raise RuntimeError(f"CLI bundle asset {name} has unsupported mode")
if item.get("source_repo") != source.get("repo"):
raise RuntimeError(f"CLI bundle asset {name} has inconsistent source repo")
if item.get("source_revision") != source.get("revision"):
raise RuntimeError(f"CLI bundle asset {name} has inconsistent source revision")
if item.get("contract_version") != contract.get("current"):
raise RuntimeError(f"CLI bundle asset {name} has inconsistent contract version")
if item.get("minimum_contract_version") != contract.get("minimum_supported"):
raise RuntimeError(f"CLI bundle asset {name} has inconsistent minimum contract")
if not isinstance(item.get("bytes"), int) or item["bytes"] <= 0:
raise RuntimeError(f"CLI bundle asset {name} has invalid byte length")
digest = str(item.get("sha256") or "")
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
raise RuntimeError(f"CLI bundle asset {name} has invalid sha256")
return {name: by_name[name] for name in SCRIPT_NAMES}
def verify_asset_payload(name, manifest_item, payload, content):
actual = content.encode("utf-8")
if len(actual) != manifest_item["bytes"]:
raise RuntimeError(f"{name} byte length does not match bundle manifest")
if hashlib.sha256(actual).hexdigest() != manifest_item["sha256"]:
raise RuntimeError(f"{name} sha256 does not match bundle manifest")
for field in (
"version",
"source_repo",
"source_revision",
"contract_version",
"minimum_contract_version",
"install_path",
"mode",
):
if payload.get(field) != manifest_item.get(field):
raise RuntimeError(f"{name} {field} does not match bundle manifest")
def release_name(manifest):
revision = manifest["source"]["revision"]
identity = str(manifest.get("bundle_id") or "")
suffix = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:8]
return f"{manifest['release_version']}-{revision[:12]}-{suffix}"
def validate_staged_python(release_dir):
for install_path in INSTALL_PATHS.values():
path = release_dir / install_path
if path.suffix == ".py":
try:
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
except (OSError, SyntaxError) as exc:
raise RuntimeError(
f"staged Python asset failed validation: {install_path}"
) from exc
def validate_release_hashes(release_dir, items):
for name, item in items.items():
path = release_dir / INSTALL_PATHS[name]
try:
content = path.read_bytes()
except OSError as exc:
raise RuntimeError(f"release asset is missing: {name}") from exc
if len(content) != item["bytes"]:
raise RuntimeError(f"release asset byte length is invalid: {name}")
if hashlib.sha256(content).hexdigest() != item["sha256"]:
raise RuntimeError(f"release asset sha256 is invalid: {name}")
def pid_exists(pid):
try:
os.kill(int(pid), 0)
return True
except (OSError, TypeError, ValueError):
return False
@contextlib.contextmanager
def installation_lock():
INSTALL_DIR.mkdir(parents=True, exist_ok=True)
lock_path = INSTALL_DIR / ".install.lock"
for attempt in range(2):
try:
descriptor = os.open(
str(lock_path),
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
0o600,
)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(str(os.getpid()))
break
except FileExistsError:
try:
owner = int(lock_path.read_text(encoding="utf-8").strip())
except (OSError, ValueError):
owner = None
if attempt == 0 and not pid_exists(owner):
lock_path.unlink(missing_ok=True)
continue
raise RuntimeError("another Napseer install or update is already running")
try:
yield
finally:
lock_path.unlink(missing_ok=True)
def atomic_symlink(path, target):
temporary = path.with_name(f".{path.name}.next-{os.getpid()}")
temporary.unlink(missing_ok=True)
os.symlink(target, temporary)
os.replace(temporary, path)
def preserve_legacy_path(path):
if path.is_symlink() or not path.exists():
return
backup = path.with_name(f"{path.name}.pre-bundle")
if backup.exists() or backup.is_symlink():
raise RuntimeError(
f"cannot preserve legacy install path because backup exists: {backup}"
)
os.replace(path, backup)
def activate_release(release_dir):
current = INSTALL_DIR / "current"
if os.name == "nt":
for install_path in INSTALL_PATHS.values():
target = INSTALL_DIR / install_path
content = (release_dir / install_path).read_text(encoding="utf-8")
write_file(target, content, executable=True)
nap_path = BIN_DIR / "nap"
write_file(
nap_path,
(release_dir / "nap_install.py").read_text(encoding="utf-8"),
executable=True,
)
write_file(
BIN_DIR / "nap.cmd",
f'@echo off\r\n"{sys.executable}" "{nap_path}" %*\r\n',
executable=False,
)
return nap_path
for filename in (
"nap_install.py",
"napseer_mcp_server.py",
"napseer_mcp_supervisor.py",
"napseer_spake2.py",
):
path = INSTALL_DIR / filename
preserve_legacy_path(path)
atomic_symlink(path, f"current/{filename}")
terminal_path = INSTALL_DIR / "terminal"
preserve_legacy_path(terminal_path)
atomic_symlink(terminal_path, "current/terminal")
BIN_DIR.mkdir(parents=True, exist_ok=True)
nap_path = BIN_DIR / "nap"
preserve_legacy_path(nap_path)
atomic_symlink(
nap_path,
os.path.relpath(INSTALL_DIR / "nap_install.py", BIN_DIR),
)
atomic_symlink(current, os.path.relpath(release_dir, INSTALL_DIR))
return nap_path
def install_assets():
with installation_lock():
manifest = bundle_manifest()
items = validated_manifest_items(manifest)
releases_dir = INSTALL_DIR / "releases"
releases_dir.mkdir(parents=True, exist_ok=True)
final_release = releases_dir / release_name(manifest)
staging = releases_dir / f".staging-{os.getpid()}-{time.time_ns()}"
staging.mkdir(mode=0o700)
scripts = {}
try:
for name in SCRIPT_NAMES:
manifest_item = items[name]
payload, content = fetch_script(name)
verify_asset_payload(name, manifest_item, payload, content)
target = staging / INSTALL_PATHS[name]
write_file(target, content, executable=True)
scripts[name] = { scripts[name] = {
"path": str(target), "path": str(INSTALL_DIR / "current" / INSTALL_PATHS[name]),
"version": payload.get("version"), "version": payload.get("version"),
"build_commit": payload.get("build_commit"),
"sha256": payload.get("sha256"), "sha256": payload.get("sha256"),
"source_repo": payload.get("source_repo"), "source_repo": payload.get("source_repo"),
"source_revision": payload.get("source_revision"), "source_revision": payload.get("source_revision"),
"source_revision_status": payload.get("source_revision_status")
or source_revision_status_for(payload.get("source_revision")),
"generated_at": payload.get("generated_at"),
"generator_version": payload.get("generator_version"),
"contract_version": payload.get("contract_version"), "contract_version": payload.get("contract_version"),
"supported_backend_api_versions": payload.get("supported_backend_api_versions"),
"supported_gateway_contract_versions": payload.get("supported_gateway_contract_versions"),
} }
validate_staged_python(staging)
installer_content = (INSTALL_DIR / "nap_install.py").read_text(encoding="utf-8") write_file(
nap_path = BIN_DIR / "nap" staging / "bundle-manifest.json",
write_file(nap_path, installer_content, executable=True) json.dumps(manifest, indent=2, sort_keys=True) + "\n",
if os.name == "nt": executable=False,
write_file(BIN_DIR / "nap.cmd", f'@echo off\r\n"{sys.executable}" "{nap_path}" %*\r\n', executable=False)
updated_project_paths = []
if update_project:
for script_name in ("napseer_mcp_server.py", "napseer_spake2.py"):
content = (INSTALL_DIR / script_name).read_text(encoding="utf-8")
for candidate in (
pathlib.Path.cwd() / script_name,
pathlib.Path.cwd() / "resources" / "scripts" / script_name,
):
if candidate.exists():
write_file(candidate, content, executable=script_name.endswith(".py"))
updated_project_paths.append(str(candidate))
installer_payload = fetched["nap_install.py"][0]
cli_source_revision = installer_payload.get("source_revision") or CLI_GENERATED_SOURCE_REVISION
cli_source_revision_status = (
installer_payload.get("source_revision_status")
or CLI_GENERATED_SOURCE_REVISION_STATUS
or source_revision_status_for(cli_source_revision)
) )
result = { if final_release.exists():
shutil.rmtree(staging)
else:
os.replace(staging, final_release)
validate_release_hashes(final_release, items)
nap_path = activate_release(final_release)
except Exception:
if staging.exists():
shutil.rmtree(staging)
raise
return {
"status": "installed", "status": "installed",
"message": "Napseer CLI and runtime scripts are installed.", "message": "Napseer CLI and runtime bundle installed atomically.",
"bin": str(nap_path), "bin": str(nap_path),
"install_dir": str(INSTALL_DIR), "install_dir": str(INSTALL_DIR),
"release_dir": str(final_release),
"bundle_id": manifest.get("bundle_id"),
"scripts": scripts, "scripts": scripts,
"cli_distribution": { "cli_distribution": {
"contract_version": CLI_DISTRIBUTION_CONTRACT_VERSION, "release_version": manifest.get("release_version"),
"source_repo": installer_payload.get("source_repo") or CLI_GENERATED_SOURCE_REPO, "contract_version": manifest["contract"]["current"],
"source_revision": None "minimum_contract_version": manifest["contract"]["minimum_supported"],
if source_revision_status_for(cli_source_revision) == "unresolved" "source_repo": manifest["source"]["repo"],
else cli_source_revision, "source_revision": manifest["source"]["revision"],
"source_revision_status": cli_source_revision_status, "source_revision_status": manifest["source"]["revision_status"],
}, },
"updated_project_paths": updated_project_paths, "updated_project_paths": [],
"path_warning": path_warning(), "path_warning": path_warning(),
"next": ["nap project init", "nap service start", "nap gateway configure", "nap update"], "next": ["nap init", "nap mcp status", "nap gateway setup"],
} }
return result
def runtime_assets_missing(): def runtime_assets_missing():
missing = [] missing = []
for name in RUNTIME_SCRIPT_NAMES: for name in RUNTIME_SCRIPT_NAMES:
path = INSTALL_DIR / { path = runtime_asset_path(name)
"terminal_init.py": "terminal/__init__.py",
"terminal_protocol.py": "terminal/protocol.py",
"terminal_pty_manager.py": "terminal/pty_manager.py",
}.get(name, name)
if not path.exists() or path.stat().st_size == 0: if not path.exists() or path.stat().st_size == 0:
missing.append(name) missing.append(name)
return missing return missing
def ensure_runtime_assets(): def active_install_root():
current = INSTALL_DIR / "current"
return current if current.exists() else INSTALL_DIR
def runtime_asset_path(name):
return active_install_root() / INSTALL_PATHS.get(name, name)
def require_runtime_assets():
missing = runtime_assets_missing() missing = runtime_assets_missing()
if missing: if missing:
install_assets(update_project=False) raise RuntimeError(
missing = runtime_assets_missing() "missing installed Napseer runtime scripts: "
if missing: f"{', '.join(missing)}. Run `nap mcp install` to repair them."
raise RuntimeError(f"missing installed Napseer runtime scripts after repair: {', '.join(missing)}") )
def run_wrapper(command_name, args): def run_wrapper(command_name, args):
wrapper = INSTALL_DIR / "napseer_mcp_server.py" wrapper = runtime_asset_path("napseer_mcp_server.py")
ensure_runtime_assets() require_runtime_assets()
return subprocess.call([sys.executable, str(wrapper), command_name, *args]) return subprocess.call([sys.executable, str(wrapper), command_name, *args])
@@ -278,14 +536,17 @@ def cwd_state_dir():
def state_dir_status(): def state_dir_status():
preferred = preferred_state_dir() preferred = preferred_state_dir()
active = cwd_state_dir()
return { return {
"active_state_dir": str(active), "active_state_dir": str(preferred),
"active_auth_path": str(active / "auth.json"), "active_auth_path": str(preferred / "auth.json"),
"state_dir": str(preferred), "state_dir": str(preferred),
"auth_path": str(preferred / "auth.json"), "auth_path": str(preferred / "auth.json"),
"state_dir_exists": preferred.exists(), "state_dir_exists": preferred.exists(),
"state_dir_message": ".napseer directory is active.", "state_dir_message": (
".napseer directory is active."
if preferred.exists()
else ".napseer directory is not initialized."
),
} }
@@ -470,8 +731,8 @@ def cli_option(args, *names, default=None):
def start_service(kind, args): def start_service(kind, args):
wrapper = INSTALL_DIR / "napseer_mcp_server.py" wrapper = runtime_asset_path("napseer_mcp_server.py")
ensure_runtime_assets() require_runtime_assets()
existing = read_service_state(kind) existing = read_service_state(kind)
if existing and pid_running(existing.get("pid")): if existing and pid_running(existing.get("pid")):
return { return {
@@ -589,74 +850,256 @@ def service_logs(kind, args):
def handle_gateway(args): def handle_gateway(args):
subcommand = args[0] if args else "help" subcommand = args[0] if args else "ls"
rest = args[1:] if args else []
if subcommand in {"help", "-h", "--help", "ls", "list", "status"}:
print("Usage: nap gateway [configure|setup|restart|kill|vault|terminal|schedule|service]")
print(" nap gateway configure [--command CMD] Configure the local gateway command.")
print(" nap gateway setup Create or migrate local gateway storage.")
print(" nap gateway vault ... Show gateway vault status and pending setup requests.")
print(" nap gateway terminal ... Manage gateway tmux terminals.")
print(" nap gateway schedule ... Manage gateway schedules (cron jobs).")
print(" nap gateway service preregister|activate Register or activate a gateway service token.")
print()
print("Service lifecycle (start/stop/status/logs) lives under `nap service`.")
print("Use `nap service start|stop|status|logs` for the local gateway process.")
return 0
if subcommand in {"configure", "setup", "restart", "kill", "vault", "terminal", "schedule", "service"}:
return run_wrapper("gateway", [subcommand, *rest])
if subcommand in {"start", "stop", "logs", "log"}:
raise RuntimeError(
f"unknown gateway subcommand: {subcommand}. "
"Service lifecycle moved to `nap service <verb>`. "
f"Use `nap service {subcommand if subcommand != 'log' else 'logs'}`."
)
if subcommand == "process":
raise RuntimeError(
"unknown gateway subcommand: process. Use `nap gateway vault process`."
)
raise RuntimeError("unknown gateway subcommand. Use `nap gateway help`.")
def handle_service(args):
subcommand = args[0] if args else "status"
rest = args[1:] if args else [] rest = args[1:] if args else []
if subcommand in {"help", "-h", "--help"}: if subcommand in {"help", "-h", "--help"}:
print("Usage: nap service [start|stop|status|logs] [--port PORT] [--lines N]") print("Usage: nap gateway [start|stop|status|logs|configure|setup|restart|kill|vault|terminal|schedule|service]")
print(" start Start the local gateway service in this directory.") print(" nap gateway start [--port PORT] Start the local gateway service.")
print(" stop Stop the local gateway service.") print(" nap gateway status Show the managed gateway process.")
print(" status Show the managed gateway process state.") print(" nap gateway configure [--command CMD] Configure the local gateway command.")
print(" logs Tail the managed gateway log file.") print(" nap gateway setup Create or migrate local gateway storage.")
print(" nap gateway vault Show gateway vault status and pending setup requests.")
print(" nap gateway vault process Complete pending setup requests with the vault/content passphrase.")
print(" nap gateway vault rotate-secret --kind memory")
print(" nap chat secret setup Configure chat secret state with the vault/content passphrase.")
return 0 return 0
if subcommand == "vault" and rest and rest[0] in {"help", "-h", "--help"}:
print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind memory] [--all] [--project-id ID] [--vault-passphrase TEXT]")
print(" status Show local gateway state and pending setup requests.")
print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.")
print(" rotate-secret Rotate the memory project secret from the gateway worker path.")
print(" Chat secrets use: nap chat secret setup|status|rotate; setup uses the same vault/content passphrase.")
return 0
if subcommand == "process":
return run_wrapper("gateway", ["vault", "process", *rest])
if subcommand == "start": if subcommand == "start":
return print_json(start_service("gateway", ["--no-browser", *rest])) or 0 return print_json(start_service("gateway", ["--no-browser", *rest])) or 0
if subcommand == "stop": if subcommand == "stop":
return print_json(stop_service("gateway")) or 0 return print_json(stop_service("gateway")) or 0
if subcommand == "status": if subcommand in {"ls", "list", "status"}:
return print_json(list_service("gateway")) or 0 return print_json(list_service("gateway")) or 0
if subcommand in {"logs", "log"}: if subcommand in {"logs", "log"}:
return print_json(service_logs("gateway", rest)) or 0 return print_json(service_logs("gateway", rest)) or 0
if subcommand in {"ls", "list"}: if subcommand == "configure":
raise RuntimeError( return run_wrapper("gateway", ["configure", *rest])
f"unknown service subcommand: {subcommand}. Use one of: start, stop, status, logs." if subcommand in {"setup", "restart", "kill", "vault", "terminal", "schedule", "service"}:
) return run_wrapper("gateway", [subcommand, *rest])
raise RuntimeError("unknown service subcommand. Use `nap service help`.") raise RuntimeError("unknown gateway command; use nap gateway start|stop|configure|setup|restart|kill|logs|status|vault|terminal|schedule|service")
def print_json(value): def print_json(value):
print(json.dumps(value, indent=2)) print(json.dumps(value, indent=2))
def print_root_help():
print("Usage: nap <command> [arguments]")
print()
print("Commands:")
width = max(len(name) for name, _summary in CANONICAL_COMMANDS)
for name, summary in CANONICAL_COMMANDS:
print(f" {name.ljust(width)} {summary}")
print()
print("Run `nap help <command>` for command-specific help.")
def print_command_help(command):
help_text = {
"init": (
"Usage: nap init [--slug SLUG] [--name NAME] [--description TEXT]\n"
"Initialize the current folder and create its first Napseer project."
),
"status": (
"Usage: nap status\n"
"Show the current folder's account, project, and runtime state."
),
"doctor": (
"Usage: nap doctor\n"
"Run read-only checks and print exact repair commands."
),
"auth": (
"Usage: nap auth [status|login|repair]\n"
" login Authenticate an account; does not select a project.\n"
" status Show credential state without credential values.\n"
" repair Recover failed automatic credential renewal."
),
"project": (
"Usage: nap project [init|create|attach|claim|status|encryption]\n"
" init Initialize a fresh folder (same workflow as `nap init`).\n"
" create Create and bind a new project.\n"
" attach Select an existing project owned by the logged-in account.\n"
" claim Move an anonymous project into an authenticated account.\n"
" status Show the current folder binding."
),
"mcp": (
"Usage: nap mcp [status|install|update|serve]\n"
" status Show supervisor and worker installation state.\n"
" install Install or repair local MCP assets.\n"
" update Update local MCP assets from verified published sources.\n"
" serve Run the stable stdio supervisor."
),
"gateway": (
"Usage: nap gateway [setup|start|stop|restart|status|logs|vault|terminal|schedule|service]\n"
"Common lifecycle commands are setup, start, stop, restart, status, and logs.\n"
"Vault, terminal, schedule, and service are advanced capability groups."
),
"update": (
"Usage: nap update\n"
"Update the installed CLI and runtime bundle. Help never performs an update."
),
"version": (
"Usage: nap version\n"
"Show distribution contract and canonical source revision."
),
"install": "Compatibility command. Use `nap mcp install`.",
"where": "Compatibility command. Use `nap doctor` or `nap mcp status`.",
"chat": "Compatibility command. Chat-secret operations are an advanced runtime workflow.",
"plan": "Compatibility command. Agent planning belongs to the authenticated MCP surface.",
"lineage": "Compatibility command. Agent lineage checks belong to the authenticated MCP surface.",
"agent": "Compatibility command. Agent workspace operations belong to the authenticated MCP surface.",
"feedback": "Compatibility command. Feedback administration is not a primary operator workflow.",
}
if command in {"help", "-h", "--help", ""}:
print_root_help()
return 0
text = help_text.get(command)
if text is None:
raise RuntimeError(f"unknown help topic: {command}")
print(text)
return 0
def mcp_status():
worker = runtime_asset_path("napseer_mcp_server.py")
supervisor = runtime_asset_path("napseer_mcp_supervisor.py")
missing = runtime_assets_missing()
return {
"status": "ok" if not missing else "repair_required",
"supervisor_installed": supervisor.is_file(),
"worker_installed": worker.is_file(),
"runtime_assets_missing": missing,
"serve_command": f"{sys.executable} {supervisor}",
"next": None if not missing else "nap mcp install",
}
def handle_mcp(args):
subcommand = args[0] if args else "status"
rest = args[1:]
if subcommand == "status":
print_json(mcp_status())
return 0
if subcommand == "install":
print_json(install_assets())
return 0
if subcommand == "update":
result = install_assets()
print_json({**result, "status": "updated", "message": "Napseer MCP runtime is updated."})
return 0
if subcommand == "serve":
if rest:
raise RuntimeError("nap mcp serve does not accept arguments")
require_runtime_assets()
supervisor = runtime_asset_path("napseer_mcp_supervisor.py")
os.execv(sys.executable, [sys.executable, str(supervisor)])
raise RuntimeError("unknown MCP command; use nap mcp status|install|update|serve")
def doctor_status():
state = state_dir_status()
auth_path = pathlib.Path(state["active_auth_path"])
auth = {}
auth_error = None
if auth_path.exists():
try:
auth = json.loads(auth_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
auth_error = "auth file is unreadable or invalid JSON"
missing = runtime_assets_missing()
issues = []
if auth_error:
issues.append({"code": "auth_file_invalid", "message": auth_error, "next": "nap auth login"})
elif not auth_path.exists():
issues.append({"code": "project_not_initialized", "message": "current folder is not initialized", "next": "nap init"})
elif not auth.get("project_id"):
issues.append({"code": "project_not_attached", "message": "no project is attached", "next": "nap project attach"})
if missing:
issues.append({"code": "runtime_assets_missing", "message": "local MCP runtime is incomplete", "next": "nap mcp install"})
if not runtime_asset_path("napseer_mcp_supervisor.py").is_file():
issues.append({"code": "supervisor_missing", "message": "stable MCP supervisor is not installed", "next": "nap mcp install"})
return {
"status": "ok" if not issues else "repair_required",
"checks": {
"state_directory": state["state_dir_exists"],
"auth_file": auth_path.exists() and auth_error is None,
"account_mode": auth.get("account_mode"),
"token_present": bool(auth.get("token")),
"refresh_present": bool(auth.get("refresh_token")),
"project_attached": bool(auth.get("project_id")),
"mcp_supervisor": runtime_asset_path("napseer_mcp_supervisor.py").is_file(),
"mcp_worker": runtime_asset_path("napseer_mcp_server.py").is_file(),
},
"issues": issues,
}
def version_status():
manifest_path = active_install_root() / "bundle-manifest.json"
if manifest_path.is_file():
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
source = manifest.get("source") or {}
contract = manifest.get("contract") or {}
return {
"release_version": manifest.get("release_version"),
"contract_version": contract.get("current"),
"minimum_contract_version": contract.get("minimum_supported"),
"source_repo": source.get("repo"),
"source_revision": source.get("revision"),
"source_revision_status": source.get("revision_status"),
"bundle_id": manifest.get("bundle_id"),
}
except (OSError, json.JSONDecodeError):
pass
return {
"release_version": CLI_RELEASE_VERSION,
"contract_version": CLI_DISTRIBUTION_CONTRACT_VERSION,
"minimum_contract_version": CLI_MINIMUM_CONTRACT_VERSION,
"source_repo": CLI_GENERATED_SOURCE_REPO,
"source_revision": None
if source_revision_status_for(CLI_GENERATED_SOURCE_REVISION) == "unresolved"
else CLI_GENERATED_SOURCE_REVISION,
"source_revision_status": source_revision_status_for(CLI_GENERATED_SOURCE_REVISION),
}
def main(argv): def main(argv):
invoked_as_nap = pathlib.Path(argv[0]).name in {"nap", "nap.cmd"} invoked_as_nap = pathlib.Path(argv[0]).name in {"nap", "nap.cmd"}
command = argv[1] if len(argv) > 1 else ("help" if invoked_as_nap else "install") command = argv[1] if len(argv) > 1 else ("help" if invoked_as_nap else "install")
args = argv[2:] args = argv[2:]
if command in HELP_TOKENS:
return print_command_help(args[0] if args else "")
if any(item in HELP_TOKENS for item in args):
return print_command_help(command)
if command == "init":
return run_wrapper("project", ["init", *args])
if command == "doctor":
print_json(doctor_status())
return 0
if command == "mcp":
return handle_mcp(args)
if command == "version":
print_json(version_status())
return 0
if command == "install": if command == "install":
print_json(install_assets(update_project=False)) print("`nap install` is deprecated; use `nap mcp install`.", file=sys.stderr)
print_json(install_assets())
return 0
if command == "update":
result = install_assets()
print_json({**result, "status": "updated", "message": "Napseer CLI and runtime scripts are updated."})
return 0 return 0
if command == "gateway": if command == "gateway":
return handle_gateway(args) return handle_gateway(args)
if command == "service":
return handle_service(args)
if command == "chat": if command == "chat":
return run_wrapper("chat", args or ["secret", "status"]) return run_wrapper("chat", args or ["secret", "status"])
if command == "status": if command == "status":
@@ -664,6 +1107,8 @@ def main(argv):
if command == "project": if command == "project":
if not args: if not args:
return run_wrapper("project", ["status"]) return run_wrapper("project", ["status"])
if args[0] == "bootstrap":
raise RuntimeError("unknown project command: bootstrap")
return run_wrapper("project", args) return run_wrapper("project", args)
if command == "lineage": if command == "lineage":
return run_wrapper("lineage", args or ["status"]) return run_wrapper("lineage", args or ["status"])
@@ -672,18 +1117,21 @@ def main(argv):
if command == "agent": if command == "agent":
return run_wrapper("agent", args) return run_wrapper("agent", args)
if command == "auth": if command == "auth":
if args and args[0] == "repair":
return run_wrapper("auth", ["refresh", *args[1:]])
if args and args[0] == "refresh":
print("`nap auth refresh` is deprecated; use `nap auth repair`.", file=sys.stderr)
return run_wrapper("auth", args) return run_wrapper("auth", args)
if command == "feedback": if command == "feedback":
return run_wrapper("feedback", args) return run_wrapper("feedback", args)
if command == "agent":
return run_wrapper("agent", args)
if command == "where": if command == "where":
state = state_dir_status() state = state_dir_status()
print_json( print_json(
{ {
"bin": shutil.which("nap") or str(BIN_DIR / "nap"), "bin": shutil.which("nap") or str(BIN_DIR / "nap"),
"install_dir": str(INSTALL_DIR), "install_dir": str(INSTALL_DIR),
"wrapper": str(INSTALL_DIR / "napseer_mcp_server.py"), "wrapper": str(runtime_asset_path("napseer_mcp_server.py")),
"supervisor": str(runtime_asset_path("napseer_mcp_supervisor.py")),
"cwd": str(pathlib.Path.cwd()), "cwd": str(pathlib.Path.cwd()),
"cwd_auth_path": state["active_auth_path"], "cwd_auth_path": state["active_auth_path"],
"cwd_auth_configured": pathlib.Path(state["active_auth_path"]).exists(), "cwd_auth_configured": pathlib.Path(state["active_auth_path"]).exists(),
@@ -694,35 +1142,6 @@ def main(argv):
} }
) )
return 0 return 0
if command in {"help", "-h", "--help"}:
print("Usage: nap [auth|project|chat|plan|lineage|gateway|service|agent|feedback|status|update|where|install]")
print(" auth Auth commands: login-local, status.")
print(" Example: nap auth login-local")
print(" project Project commands: init, attach, create, claim, status, encryption.")
print(" First-time setup: nap project init (enrolls and creates a project, no browser).")
print(" Attach an existing project to a Napseer account: nap project attach (opens browser).")
print(" Upgrade an anonymous project to a claimed one: nap project claim.")
print(" Encrypted project setup is completed by the local gateway: nap gateway vault process")
print(" chat Chat secret commands: secret status, secret setup, secret rotate.")
print(" Examples: nap chat secret setup --vault-passphrase TEXT, nap chat secret status")
print(" plan Plan commands: list, to-kanban.")
print(" Example: nap plan to-kanban /plans/example --column todo")
print(" lineage Generic source-record -> plan -> kanban lineage checks.")
print(" Example: nap lineage status")
print(" gateway Gateway operations: configure, setup, restart, kill, vault, terminal, schedule, service.")
print(" Examples: nap gateway configure, nap gateway vault process --all")
print(" service Local gateway service lifecycle: start, stop, status, logs.")
print(" Start auto-selects a port; pass --port to pin one.")
print(" Examples: nap service start, nap service logs --lines 200")
print(" agent Agent commands: list, workspaces, create, show, edit.")
print(" list shows registered project agents; workspaces shows /agents folders.")
print(" feedback Feedback commands: list, global, set, resolve.")
print(" Examples: nap feedback list, nap feedback set <id> --status open, nap feedback resolve <id> --notes TEXT")
print(" status Show cwd Napseer configuration and next setup commands.")
print(" update Refresh the globally installed nap command and wrapper.")
print(" where Show installed nap paths and cwd auth location.")
print(" install Reinstall/repair nap from the hosted script directory.")
return 0
raise RuntimeError(f"unknown command: {command}") raise RuntimeError(f"unknown command: {command}")
+11 -13
View File
@@ -35,18 +35,13 @@ def test_initial_enrollment_persists_refresh_credential():
mod.AUTH = anonymous_auth(mod) mod.AUTH = anonymous_auth(mod)
mod.BASE_URL = mod.AUTH["base_url"] mod.BASE_URL = mod.AUTH["base_url"]
mod.DEFAULT_PROJECT_ID = None mod.DEFAULT_PROJECT_ID = None
with tempfile.TemporaryDirectory() as directory:
key_path = pathlib.Path(directory) / "test-key"
pathlib.Path(str(key_path) + ".pub").write_text(
"ssh-ed25519 test-public-key", encoding="utf-8"
)
mod.ensure_local_files = lambda: key_path
mod.sign_text = lambda *args: "test-signature"
saved = [] saved = []
requests = []
mod.save_auth = lambda payload: saved.append(dict(payload)) mod.save_auth = lambda payload: saved.append(dict(payload))
responses = iter([
{"challenge_id": "challenge", "challenge_text": "sign-me"}, def request(method, path, payload=None, token_required=False):
{ requests.append((method, path, payload, token_required))
return {
"token": { "token": {
"access_token": "access", "access_token": "access",
"refresh_token": "refresh", "refresh_token": "refresh",
@@ -54,14 +49,17 @@ def test_initial_enrollment_persists_refresh_credential():
"refresh_expires_at": "", "refresh_expires_at": "",
}, },
"worker": {"id": "worker", "agent_id": "agent-id"}, "worker": {"id": "worker", "agent_id": "agent-id"},
}, }
])
mod.request_json = lambda *args, **kwargs: next(responses)
mod.request_json = request
mod.ensure_enrolled({"slug": "example"}) mod.ensure_enrolled({"slug": "example"})
assert saved[0]["refresh_token"] == "refresh" assert saved[0]["refresh_token"] == "refresh"
assert "refresh_expires_at" in saved[0] assert "refresh_expires_at" in saved[0]
assert [(method, path) for method, path, _, _ in requests] == [
("POST", "/v1/enrollment/token")
]
assert requests[0][3] is False
def test_failed_anonymous_refresh_uses_ssh_recovery(): def test_failed_anonymous_refresh_uses_ssh_recovery():
+282
View File
@@ -0,0 +1,282 @@
import importlib.util
import hashlib
import json
import pathlib
SCRIPT_PATH = (
pathlib.Path(__file__).resolve().parents[1]
/ "resources"
/ "scripts"
/ "nap_install.py"
)
def load_installer():
spec = importlib.util.spec_from_file_location("nap_install_command_system_test", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def fail_if_called(*_args, **_kwargs):
raise AssertionError("help dispatched a side-effecting handler")
def test_root_help_exposes_only_canonical_commands(capsys):
module = load_installer()
assert module.main(["nap"]) == 0
output = capsys.readouterr().out
visible = [name for name, metadata in module.COMMAND_METADATA.items() if metadata["visible"]]
assert visible == [name for name, _summary in module.CANONICAL_COMMANDS]
assert len(visible) == 10
for name in visible:
assert f" {name}" in output
for hidden in ("chat", "plan", "lineage", "agent", "feedback", "where", "install"):
assert f" {hidden}" not in output
def test_help_is_resolved_before_mutating_dispatch(monkeypatch, capsys):
module = load_installer()
monkeypatch.setattr(module, "install_assets", fail_if_called)
monkeypatch.setattr(module, "run_wrapper", fail_if_called)
monkeypatch.setattr(module, "handle_gateway", fail_if_called)
assert module.main(["nap", "update", "help"]) == 0
assert module.main(["nap", "install", "--help"]) == 0
assert module.main(["nap", "auth", "--help"]) == 0
assert module.main(["nap", "project", "encryption", "--help"]) == 0
assert module.main(["nap", "gateway", "vault", "help"]) == 0
output = capsys.readouterr().out
assert "Help never performs an update" in output
assert "Use `nap mcp install`" in output
assert "Usage: nap auth" in output
assert "Usage: nap project" in output
assert "Usage: nap gateway" in output
def test_help_topic_uses_same_canonical_text(capsys):
module = load_installer()
assert module.main(["nap", "help", "mcp"]) == 0
assert "Usage: nap mcp [status|install|update|serve]" in capsys.readouterr().out
def test_init_and_auth_repair_normalize_to_existing_handlers(monkeypatch):
module = load_installer()
calls = []
monkeypatch.setattr(
module,
"run_wrapper",
lambda command, args: calls.append((command, args)) or 0,
)
assert module.main(["nap", "init", "--slug", "demo"]) == 0
assert module.main(["nap", "auth", "repair"]) == 0
assert calls == [
("project", ["init", "--slug", "demo"]),
("auth", ["refresh"]),
]
def test_update_does_not_overwrite_repository_source(monkeypatch, capsys):
module = load_installer()
calls = []
monkeypatch.setattr(
module,
"install_assets",
lambda: calls.append("update") or {"status": "installed"},
)
assert module.main(["nap", "update"]) == 0
assert calls == ["update"]
assert json.loads(capsys.readouterr().out)["status"] == "updated"
def test_doctor_reports_presence_without_exposing_values(tmp_path, monkeypatch):
module = load_installer()
state_dir = tmp_path / ".napseer"
state_dir.mkdir()
auth_path = state_dir / "auth.json"
auth_path.write_text(
json.dumps(
{
"account_mode": "anonymous",
"project_id": "test-project",
"token": "test-access-value",
"refresh_token": "test-refresh-value",
}
),
encoding="utf-8",
)
install_dir = tmp_path / "install"
install_dir.mkdir()
(install_dir / "napseer_mcp_server.py").write_text("# worker\n", encoding="utf-8")
(install_dir / "napseer_mcp_supervisor.py").write_text("# supervisor\n", encoding="utf-8")
monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
monkeypatch.setattr(module, "runtime_assets_missing", lambda: [])
monkeypatch.setattr(
module,
"state_dir_status",
lambda: {
"active_auth_path": str(auth_path),
"state_dir_exists": True,
},
)
result = module.doctor_status()
serialized = json.dumps(result)
assert result["status"] == "ok"
assert result["checks"]["token_present"] is True
assert result["checks"]["refresh_present"] is True
assert "test-access-value" not in serialized
assert "test-refresh-value" not in serialized
def test_doctor_does_not_create_state_directory(tmp_path, monkeypatch):
module = load_installer()
state_dir = tmp_path / ".napseer"
install_dir = tmp_path / "install"
install_dir.mkdir()
(install_dir / "napseer_mcp_server.py").write_text("# worker\n", encoding="utf-8")
(install_dir / "napseer_mcp_supervisor.py").write_text("# supervisor\n", encoding="utf-8")
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
monkeypatch.setattr(module, "runtime_assets_missing", lambda: [])
result = module.doctor_status()
assert result["status"] == "repair_required"
assert not state_dir.exists()
def test_wrapper_dispatch_never_installs_missing_runtime(monkeypatch):
module = load_installer()
monkeypatch.setattr(module, "runtime_assets_missing", lambda: ["napseer_mcp_server.py"])
monkeypatch.setattr(module, "install_assets", fail_if_called)
try:
module.run_wrapper("configure", [])
except RuntimeError as exc:
assert "nap mcp install" in str(exc)
else:
raise AssertionError("missing runtime should require an explicit repair command")
def fake_bundle(module, revision="a" * 40, broken_name=None):
contents = {
name: f"#!/usr/bin/env python3\nVALUE = {index!r}\n"
for index, name in enumerate(module.SCRIPT_NAMES)
}
items = []
payloads = {}
for name in module.SCRIPT_NAMES:
content = contents[name]
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
item = {
"name": name,
"version": module.CLI_RELEASE_VERSION,
"bytes": len(content.encode("utf-8")),
"sha256": digest,
"source_repo": "https://github.com/napseer/nap-cli",
"source_revision": revision,
"source_revision_status": "resolved",
"contract_version": module.CLI_DISTRIBUTION_CONTRACT_VERSION,
"minimum_contract_version": module.CLI_MINIMUM_CONTRACT_VERSION,
"install_path": module.INSTALL_PATHS[name],
"mode": "0755",
}
items.append(item)
payloads[name] = ({**item, "content": content}, content)
if broken_name:
payload, content = payloads[broken_name]
payloads[broken_name] = (payload, content + "not valid python !!!")
manifest = {
"schema_version": module.CLI_BUNDLE_SCHEMA_VERSION,
"bundle_id": f"nap-cli:{module.CLI_RELEASE_VERSION}:{revision}",
"release_version": module.CLI_RELEASE_VERSION,
"published_at": "2026-07-29T00:00:00Z",
"contract": {
"current": module.CLI_DISTRIBUTION_CONTRACT_VERSION,
"minimum_supported": module.CLI_MINIMUM_CONTRACT_VERSION,
},
"source": {
"repo": "https://github.com/napseer/nap-cli",
"revision": revision,
"revision_status": "resolved",
},
"items": items,
}
return manifest, payloads
def test_bundle_install_stages_validates_and_activates_atomically(tmp_path, monkeypatch):
module = load_installer()
install_dir = tmp_path / "install"
bin_dir = tmp_path / "bin"
monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
monkeypatch.setattr(module, "BIN_DIR", bin_dir)
manifest, payloads = fake_bundle(module)
monkeypatch.setattr(module, "bundle_manifest", lambda: manifest)
monkeypatch.setattr(module, "fetch_script", lambda name: payloads[name])
result = module.install_assets()
assert result["cli_distribution"]["release_version"] == "0.2.0"
assert (install_dir / "current").is_symlink()
assert (install_dir / "current" / "bundle-manifest.json").is_file()
assert (install_dir / "current" / "napseer_mcp_server.py").is_file()
assert (install_dir / "current" / "terminal" / "protocol.py").is_file()
assert (install_dir / "napseer_mcp_supervisor.py").is_symlink()
assert (bin_dir / "nap").is_symlink()
assert module.version_status()["source_revision"] == "a" * 40
def test_failed_bundle_validation_keeps_previous_release_active(tmp_path, monkeypatch):
module = load_installer()
monkeypatch.setattr(module, "INSTALL_DIR", tmp_path / "install")
monkeypatch.setattr(module, "BIN_DIR", tmp_path / "bin")
first_manifest, first_payloads = fake_bundle(module, revision="a" * 40)
monkeypatch.setattr(module, "bundle_manifest", lambda: first_manifest)
monkeypatch.setattr(module, "fetch_script", lambda name: first_payloads[name])
module.install_assets()
active_before = (module.INSTALL_DIR / "current").resolve()
broken_manifest, broken_payloads = fake_bundle(
module,
revision="b" * 40,
broken_name="napseer_mcp_server.py",
)
monkeypatch.setattr(module, "bundle_manifest", lambda: broken_manifest)
monkeypatch.setattr(module, "fetch_script", lambda name: broken_payloads[name])
try:
module.install_assets()
except RuntimeError as exc:
assert "does not match bundle manifest" in str(exc)
else:
raise AssertionError("invalid bundle must not activate")
assert (module.INSTALL_DIR / "current").resolve() == active_before
assert not list((module.INSTALL_DIR / "releases").glob(".staging-*"))
def test_manifest_rejects_path_traversal():
module = load_installer()
manifest, _payloads = fake_bundle(module)
target = next(item for item in manifest["items"] if item["name"] == "nap_install.py")
target["install_path"] = "../nap_install.py"
try:
module.validated_manifest_items(manifest)
except RuntimeError as exc:
assert "unsafe install_path" in str(exc)
else:
raise AssertionError("unsafe manifest path must be rejected")
+32 -18
View File
@@ -6,11 +6,12 @@ Verifies that:
are accepted and route to the right function. are accepted and route to the right function.
- dropped aliases (ls, new, send, rm, set-status, admin, global-list, etc.) - dropped aliases (ls, new, send, rm, set-status, admin, global-list, etc.)
now raise RuntimeError with a clear pointer to the canonical verb. now raise RuntimeError with a clear pointer to the canonical verb.
- `nap project encryption set` requires --state; positional-state shortcut is gone. - project encryption compatibility forms normalize to one transition handler.
- `nap project encryption plaintext` (action-as-state) now errors.
""" """
import importlib.util import importlib.util
import contextlib
import io
import pathlib import pathlib
import sys import sys
@@ -155,35 +156,48 @@ def test_gateway_rotate_passphrase_aliases_rejected():
expect_unknown(mod, args, "rotate-passphrase") expect_unknown(mod, args, "rotate-passphrase")
def test_project_encryption_state_flag_required(): def dispatch_encryption(mod, argv):
"""nap project encryption set requires --state; positional state is rejected.""" calls = []
mod.project_encryption_transition = lambda args: (
calls.append(dict(args)) or {"status": "updated", "state": args["state"]}
)
with contextlib.redirect_stdout(io.StringIO()):
mod.cli_main(argv)
return calls
def test_project_encryption_set_compatibility_form():
"""The explicit set form routes to the canonical transition handler."""
mod = load_module() mod = load_module()
# Without --state, the dispatch should raise calls = dispatch_encryption(
expect_unknown(
mod, mod,
["napseer_mcp_server.py", "project", "encryption", "set", "plaintext"], ["napseer_mcp_server.py", "project", "encryption", "set", "plaintext"],
"--state plaintext|encrypted",
) )
assert calls == [{"state": "plaintext", "passphrase": None}]
def test_project_encryption_action_as_state_rejected(): def test_project_encryption_action_as_state_is_hidden_compatibility():
"""`nap project encryption plaintext` (the old shortcut) must error."""
mod = load_module() mod = load_module()
expect_unknown( calls = dispatch_encryption(
mod, mod,
["napseer_mcp_server.py", "project", "encryption", "plaintext"], ["napseer_mcp_server.py", "project", "encryption", "plaintext"],
"project encryption",
) )
assert calls == [{"state": "plaintext", "passphrase": None}]
def test_project_encryption_show_alias_rejected(): def test_project_encryption_status_aliases_are_read_only_compatibility():
mod = load_module() mod = load_module()
for args in [ calls = []
mod.project_encryption_status = lambda args: (
calls.append(dict(args)) or {"status": "ok", "state": "plaintext"}
)
for argv in [
["napseer_mcp_server.py", "project", "encryption", "state"], ["napseer_mcp_server.py", "project", "encryption", "state"],
["napseer_mcp_server.py", "project", "encryption", "show"], ["napseer_mcp_server.py", "project", "encryption", "show"],
["napseer_mcp_server.py", "project", "encryption", "encrypted"],
]: ]:
expect_unknown(mod, args, "project encryption") with contextlib.redirect_stdout(io.StringIO()):
mod.cli_main(argv)
assert calls == [{}, {}]
if __name__ == "__main__": if __name__ == "__main__":
@@ -198,7 +212,7 @@ if __name__ == "__main__":
test_gateway_service_run_rejected() test_gateway_service_run_rejected()
test_gateway_vault_aliases_rejected() test_gateway_vault_aliases_rejected()
test_gateway_rotate_passphrase_aliases_rejected() test_gateway_rotate_passphrase_aliases_rejected()
test_project_encryption_state_flag_required() test_project_encryption_set_compatibility_form()
test_project_encryption_action_as_state_rejected() test_project_encryption_action_as_state_is_hidden_compatibility()
test_project_encryption_show_alias_rejected() test_project_encryption_status_aliases_are_read_only_compatibility()
print("ok: cli dispatch consolidation tests passed") print("ok: cli dispatch consolidation tests passed")
+43 -116
View File
@@ -246,141 +246,62 @@ def test_node_get_rejects_path_reads(mod):
def test_tools_include_node_by_path_descriptor(mod): def test_tools_include_node_by_path_descriptor(mod):
tool_by_name = {item["name"]: item for item in mod.tools()}
assert len(tool_by_name) == 56
assert len(tool_by_name) == len(mod.tools())
get_tool = next(item for item in mod.tools() if item["name"] == "nap_node_get") get_tool = next(item for item in mod.tools() if item["name"] == "nap_node_get")
assert get_tool["inputSchema"].get("required") is None assert get_tool["inputSchema"].get("required") is None
assert "node_id" in get_tool["inputSchema"]["properties"] assert "node_id" in get_tool["inputSchema"]["properties"]
assert "uri" in get_tool["inputSchema"]["properties"] assert "uri" in get_tool["inputSchema"]["properties"]
assert "path" not in get_tool["inputSchema"]["properties"] assert "path" not in get_tool["inputSchema"]["properties"]
by_id = next(item for item in mod.tools() if item["name"] == "nap_node_by_id")
assert by_id["inputSchema"]["required"] == ["node_id"]
assert "nap:// URI" in by_id["description"]
by_uri = next(item for item in mod.tools() if item["name"] == "nap_node_by_uri")
assert by_uri["inputSchema"]["required"] == ["uri"]
assert "nap://" in by_uri["description"]
tool = next(item for item in mod.tools() if item["name"] == "nap_node_by_path") tool = next(item for item in mod.tools() if item["name"] == "nap_node_by_path")
assert tool["inputSchema"]["required"] == ["path"] assert tool["inputSchema"]["required"] == ["path"]
assert "preview_chars" not in tool["inputSchema"]["properties"] assert "preview_chars" not in tool["inputSchema"]["properties"]
assert "does not read full node content" in tool["description"] assert "does not read full node content" in tool["description"]
assert all(item["name"] != "nap_cat" for item in mod.tools()) for hidden_name in [
"nap_cat",
"nap_node_by_id",
"nap_node_by_uri",
"nap_tee",
"nap_patch",
"nap_find",
"nap_index_sync",
]:
assert hidden_name not in tool_by_name
for name in ["nap_discover", "nap_find", "nap_context", "nap_find_related", "nap_recent"]: for name in ["nap_discover", "nap_context", "nap_find_related", "nap_recent"]:
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"] schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"]
view = schema["properties"].get("view") view = schema["properties"].get("view")
if view: if view:
assert "full" not in view["enum"] assert "full" not in view["enum"]
for name in ["nap_patch", "nap_node_patch", "nap_rm"]: for name in ["nap_node_patch", "nap_rm"]:
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"] schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"]
assert "node_id" in schema["properties"] assert "node_id" in schema["properties"]
assert "uri" in schema["properties"] assert "uri" in schema["properties"]
assert "path" not in schema["properties"] assert "path" not in schema["properties"]
tee_schema = next(item for item in mod.tools() if item["name"] == "nap_tee")["inputSchema"]
assert tee_schema.get("required") is None
assert "path" in tee_schema["properties"]
assert "node_id" in tee_schema["properties"]
assert "uri" in tee_schema["properties"]
bulk_node_schema = next(item for item in mod.tools() if item["name"] == "nap_bulk")["inputSchema"]["properties"]["nodes"]["items"] bulk_node_schema = next(item for item in mod.tools() if item["name"] == "nap_bulk")["inputSchema"]["properties"]["nodes"]["items"]
assert bulk_node_schema.get("required") is None assert bulk_node_schema.get("required") is None
assert "path" in bulk_node_schema["properties"] assert "path" in bulk_node_schema["properties"]
assert "node_id" in bulk_node_schema["properties"] assert "node_id" in bulk_node_schema["properties"]
assert "uri" in bulk_node_schema["properties"] assert "uri" in bulk_node_schema["properties"]
for name in [ for required_name in [
"nap_kanban_start", "nap_library_ls",
"nap_kanban_send_review", "nap_library_attach",
"nap_kanban_complete", "nap_library_detach",
"nap_kanban_block", "nap_library_nodes",
"nap_kanban_unblock", "nap_library_node_get",
"nap_kanban_update", "nap_auth_refresh",
"nap_kanban_archive", "nap_node_restore",
"nap_plan_transition",
"nap_index_status",
]: ]:
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"] assert required_name in tool_by_name
assert "node_id" in schema["properties"]
assert "uri" in schema["properties"]
assert "path" not in schema["properties"]
mv_schema = next(item for item in mod.tools() if item["name"] == "nap_mv")["inputSchema"]
assert mv_schema["required"] == ["new_path"]
assert "node_id" in mv_schema["properties"]
assert "uri" in mv_schema["properties"]
assert "path" not in mv_schema["properties"]
ln_schema = next(item for item in mod.tools() if item["name"] == "nap_ln")["inputSchema"]
assert "source_node_id" in ln_schema["properties"]
assert "source_uri" in ln_schema["properties"]
assert "target_node_id" in ln_schema["properties"]
assert "target_uri" in ln_schema["properties"]
assert "source_path" not in ln_schema["properties"]
assert "target_path" not in ln_schema["properties"]
backlinks_schema = next(item for item in mod.tools() if item["name"] == "nap_backlinks")["inputSchema"]
assert "node_id" in backlinks_schema["properties"]
assert "uri" in backlinks_schema["properties"]
assert "path" not in backlinks_schema["properties"]
context_schema = next(item for item in mod.tools() if item["name"] == "nap_context")["inputSchema"]
assert "node_id" in context_schema["properties"]
assert "node_ids" in context_schema["properties"]
assert "uri" in context_schema["properties"]
assert "uris" in context_schema["properties"]
assert "path" in context_schema["properties"]
assert "paths" in context_schema["properties"]
related_schema = next(item for item in mod.tools() if item["name"] == "nap_find_related")["inputSchema"]
assert "node_id" in related_schema["properties"]
assert "uri" in related_schema["properties"]
assert "path" not in related_schema["properties"]
complete_schema = next(item for item in mod.tools() if item["name"] == "nap_plan_complete")["inputSchema"]
assert "plan_node_id" in complete_schema["properties"]
assert "plan_uri" in complete_schema["properties"]
assert "outcome_node_id" in complete_schema["properties"]
assert "outcome_uri" in complete_schema["properties"]
assert "path" not in complete_schema["properties"]
assert "outcome_path" not in complete_schema["properties"]
supersede_schema = next(item for item in mod.tools() if item["name"] == "nap_plan_supersede")["inputSchema"]
assert "required" not in supersede_schema
assert "plan_node_id" in supersede_schema["properties"]
assert "replacement_node_id" in supersede_schema["properties"]
assert "path" not in supersede_schema["properties"]
assert "replacement_path" not in supersede_schema["properties"]
cancel_schema = next(item for item in mod.tools() if item["name"] == "nap_plan_cancel")["inputSchema"]
assert "required" not in cancel_schema
assert "plan_node_id" in cancel_schema["properties"]
assert "path" not in cancel_schema["properties"]
plan_to_kanban_schema = next(item for item in mod.tools() if item["name"] == "nap_plan_to_kanban")["inputSchema"]
assert "plan_node_id" in plan_to_kanban_schema["properties"]
assert "plan_uri" in plan_to_kanban_schema["properties"]
assert "plan_path" not in plan_to_kanban_schema["properties"]
for name in ["nap_agent_cat", "nap_agent_patch", "nap_agent_rm"]:
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"]
assert schema["required"] == ["agent_slug"]
assert "node_id" in schema["properties"]
assert "uri" in schema["properties"]
assert "path" not in schema["properties"]
agent_tee_schema = next(item for item in mod.tools() if item["name"] == "nap_agent_tee")["inputSchema"]
assert agent_tee_schema["required"] == ["agent_slug"]
assert "path" in agent_tee_schema["properties"]
assert "node_id" in agent_tee_schema["properties"]
assert "uri" in agent_tee_schema["properties"]
agent_ln_schema = next(item for item in mod.tools() if item["name"] == "nap_agent_ln")["inputSchema"]
assert agent_ln_schema["required"] == ["agent_slug"]
assert "source_node_id" in agent_ln_schema["properties"]
assert "target_node_id" in agent_ln_schema["properties"]
assert "source_path" not in agent_ln_schema["properties"]
assert "target_path" not in agent_ln_schema["properties"]
def test_mutation_tools_reject_path_and_patch_by_node_id(mod): def test_mutation_tools_reject_path_and_patch_by_node_id(mod):
@@ -508,15 +429,21 @@ def test_tee_and_bulk_create_by_path_update_by_identity(mod):
assert writes[-1][0] == "PATCH" assert writes[-1][0] == "PATCH"
assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123" assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123"
with pytest.raises(RuntimeError, match="path already resolves"): compatibility_update = mod.upsert_node(
mod.upsert_node({"path": "/notes/current", "content_text": "bad"}) {"path": "/notes/current", "content_text": "compatibility update"}
)
assert compatibility_update["updated"] is True
assert writes[-1][0] == "PATCH"
bulk = mod.bulk_upsert_nodes({"nodes": [{"node_id": "node-123", "content_text": "bulk update"}]}) bulk = mod.bulk_upsert_nodes({"nodes": [{"node_id": "node-123", "content_text": "bulk update"}]})
assert bulk["items"][0]["created"] is False assert bulk["items"][0]["created"] is False
assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123" assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123"
with pytest.raises(RuntimeError, match="path already resolves"): compatibility_bulk_update = mod.bulk_upsert_nodes(
mod.bulk_upsert_nodes({"nodes": [{"path": "/notes/current", "content_text": "bad"}]}) {"nodes": [{"path": "/notes/current", "content_text": "compatibility update"}]}
)
assert compatibility_bulk_update["items"][0]["created"] is False
assert writes[-1][0] == "PATCH"
def test_agent_tools_create_by_route_and_update_by_identity(mod): def test_agent_tools_create_by_route_and_update_by_identity(mod):
@@ -867,12 +794,12 @@ def test_index_sync_fetches_incremental_changes_and_removes_archived_nodes(mod):
assert mod.search_local_index({"q": "existing"})["items"] == [] assert mod.search_local_index({"q": "existing"})["items"] == []
def test_tools_include_index_sync_descriptor(mod): def test_tools_include_read_only_index_status_descriptor(mod):
tool = next(item for item in mod.tools() if item["name"] == "nap_index_sync") tool = next(item for item in mod.tools() if item["name"] == "nap_index_status")
assert "Incrementally synchronize" in tool["description"] assert "local search index" in tool["description"]
assert tool["inputSchema"]["properties"]["lookback_seconds"]["default"] == 5 assert tool["inputSchema"]["properties"] == {}
contract = mod.tool_contract_metadata("nap_index_sync") contract = mod.tool_contract_metadata("nap_index_status")
assert contract["side_effect"] == "local-file" assert contract["side_effect"] == "none"
assert contract["auth_mode"] == "local-only" assert contract["auth_mode"] == "local-only"
+5 -1
View File
@@ -266,7 +266,11 @@ def run():
assert compact["view"] == "paths" assert compact["view"] == "paths"
assert "content_text" not in compact["items"][0] assert "content_text" not in compact["items"][0]
assert compact["items"][0]["full_path"] == "/renamed/alpha" assert compact["items"][0]["full_path"] == "/renamed/alpha"
full = mod.list_project_nodes({"limit": 10, "view": "full"}) full = mod.list_project_nodes({
"limit": 10,
"view": "full",
"_allow_full": True,
})
assert full["items"][0]["content_text"] == "secret needle" assert full["items"][0]["content_text"] == "secret needle"
plan_base = { plan_base = {
+116
View File
@@ -0,0 +1,116 @@
import json
import os
import pathlib
import select
import signal
import subprocess
import sys
import time
ROOT = pathlib.Path(__file__).resolve().parents[1]
SUPERVISOR = ROOT / "resources" / "scripts" / "napseer_mcp_supervisor.py"
def write_worker(path: pathlib.Path, version: int) -> None:
path.write_text(
f"""\
import json
import os
import sys
VERSION = {version}
for line in sys.stdin:
message = json.loads(line)
if message.get("method") == "notifications/initialized":
continue
if message.get("method") == "tools/call":
name = (message.get("params") or {{}}).get("name")
if name == "crash":
os._exit(17)
result = {{"pid": os.getpid(), "version": VERSION, "name": name}}
else:
result = {{"version": VERSION}}
print(json.dumps({{"jsonrpc": "2.0", "id": message.get("id"), "result": result}}), flush=True)
""",
encoding="utf-8",
)
class SupervisorClient:
def __init__(self, worker_path: pathlib.Path):
environment = os.environ.copy()
environment["NAPSEER_MCP_WORKER_PATH"] = str(worker_path)
environment["NAPSEER_MCP_RESPONSE_TIMEOUT_SECONDS"] = "5"
self.process = subprocess.Popen(
[sys.executable, str(SUPERVISOR)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=environment,
)
def request(self, request_id: int, method: str, params=None):
assert self.process.stdin is not None
assert self.process.stdout is not None
message = {"jsonrpc": "2.0", "id": request_id, "method": method}
if params is not None:
message["params"] = params
self.process.stdin.write(json.dumps(message).encode("utf-8") + b"\n")
self.process.stdin.flush()
readable, _, _ = select.select([self.process.stdout], [], [], 8)
assert readable, "supervisor did not return a response"
return json.loads(self.process.stdout.readline())
def close(self):
if self.process.poll() is None:
self.process.terminate()
self.process.wait(timeout=5)
def test_restarts_worker_killed_between_requests(tmp_path):
worker_path = tmp_path / "worker.py"
write_worker(worker_path, 1)
client = SupervisorClient(worker_path)
try:
first = client.request(1, "tools/call", {"name": "pid", "arguments": {}})
os.kill(first["result"]["pid"], signal.SIGTERM)
time.sleep(0.1)
second = client.request(2, "tools/list")
assert second["result"]["version"] == 1
assert client.process.poll() is None
finally:
client.close()
def test_reloads_replaced_worker_between_requests(tmp_path):
worker_path = tmp_path / "worker.py"
write_worker(worker_path, 1)
client = SupervisorClient(worker_path)
try:
assert client.request(1, "initialize")["result"]["version"] == 1
time.sleep(0.01)
write_worker(worker_path, 2)
os.utime(worker_path, None)
assert client.request(2, "tools/list")["result"]["version"] == 2
finally:
client.close()
def test_worker_crash_returns_error_without_closing_transport(tmp_path):
worker_path = tmp_path / "worker.py"
write_worker(worker_path, 1)
client = SupervisorClient(worker_path)
try:
failed = client.request(1, "tools/call", {"name": "crash", "arguments": {}})
recovered = client.request(2, "tools/list")
assert failed["error"]["code"] == -32098
assert recovered["result"]["version"] == 1
assert client.process.poll() is None
finally:
client.close()
+31 -7
View File
@@ -163,13 +163,36 @@ def test_cli_main_dispatches_init():
print("ok: cli_main routes `project init` to cli_project_init passed") print("ok: cli_main routes `project init` to cli_project_init passed")
def test_cli_main_rejects_old_aliases(): def test_cli_main_accepts_canonical_login_and_hidden_compatibility_alias():
"""Verify `auth login` / `project bootstrap-existing` are no longer accepted.""" """Login aliases must route to one handler without performing browser or network I/O."""
mod = load_module() import contextlib
import io
mod = load_module()
calls = []
mod.operator_account_login = lambda args: (
calls.append(dict(args)) or {"status": "authenticated"}
)
for subcommand in ["login", "login-local", "operator-login"]:
with contextlib.redirect_stdout(io.StringIO()):
mod.cli_main([
"napseer_mcp_server.py",
"auth",
subcommand,
"--no-browser",
"--timeout",
"1",
])
assert len(calls) == 3
assert all(call["open_browser"] is False for call in calls)
assert all(call["timeout_seconds"] == 1 for call in calls)
def test_cli_main_rejects_project_login_aliases():
mod = load_module()
cases = [ cases = [
(["napseer_mcp_server.py", "auth", "login"], "unknown auth subcommand: login"),
(["napseer_mcp_server.py", "auth", "operator-login"], "unknown auth subcommand: operator-login"),
(["napseer_mcp_server.py", "project", "login"], "unknown project subcommand: login"), (["napseer_mcp_server.py", "project", "login"], "unknown project subcommand: login"),
(["napseer_mcp_server.py", "project", "bootstrap-existing"], "unknown project subcommand: bootstrap-existing"), (["napseer_mcp_server.py", "project", "bootstrap-existing"], "unknown project subcommand: bootstrap-existing"),
] ]
@@ -180,7 +203,7 @@ def test_cli_main_rejects_old_aliases():
assert expected_substring in str(exc), (args, str(exc)) assert expected_substring in str(exc), (args, str(exc))
else: else:
raise AssertionError(f"expected RuntimeError for {args}") raise AssertionError(f"expected RuntimeError for {args}")
print("ok: cli_main rejects old auth/project aliases with clear errors passed") print("ok: cli_main rejects old project aliases with clear errors passed")
if __name__ == "__main__": if __name__ == "__main__":
@@ -188,5 +211,6 @@ if __name__ == "__main__":
test_init_already_initialized() test_init_already_initialized()
test_init_token_only_creates_project() test_init_token_only_creates_project()
test_cli_main_dispatches_init() test_cli_main_dispatches_init()
test_cli_main_rejects_old_aliases() test_cli_main_accepts_canonical_login_and_hidden_compatibility_alias()
test_cli_main_rejects_project_login_aliases()
print("ok: project init wizard smoke tests passed") print("ok: project init wizard smoke tests passed")