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
+549 -111
View File
@@ -14,6 +14,8 @@ Usage:
nap update
"""
import ast
import contextlib
import json
import hashlib
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"))
SCRIPT_NAMES = (
"napseer_mcp_server.py",
"napseer_mcp_supervisor.py",
"napseer_spake2.py",
"terminal_init.py",
"terminal_protocol.py",
@@ -49,12 +52,57 @@ RUNTIME_SCRIPT_NAMES = (
"terminal_protocol.py",
"terminal_pty_manager.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)))
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_REVISION = os.environ.get("NAPSEER_CLI_SOURCE_REVISION", "unresolved")
CLI_GENERATED_SOURCE_REVISION_STATUS = os.environ.get("NAPSEER_CLI_SOURCE_REVISION_STATUS", "unresolved")
HELP_TOKENS = {"help", "-h", "--help"}
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):
@@ -114,16 +162,23 @@ def read_url_bytes(url, user_agent, label):
raise RuntimeError(f"{label} failed: HTTP {exc.code}: {body}") from exc
def fetch_script(name):
def fetch_json(path, label):
try:
body = read_url_bytes(
f"{BASE_URL}/v1/scripts/{name}",
f"{BASE_URL}{path}",
"nap-install-python/0.1",
f"GET /v1/scripts/{name}",
f"GET {path}",
)
payload = json.loads(body.decode("utf-8"))
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")
if not isinstance(content, str) or not content.strip():
raise RuntimeError(f"{name} response did not include script content")
@@ -159,108 +214,312 @@ def source_revision_status_for(value):
return "resolved"
def install_assets(update_project=False):
scripts = {}
fetched = {}
def bundle_manifest():
return fetch_json("/v1/scripts", "CLI bundle manifest")
def validated_manifest_items(manifest):
if manifest.get("schema_version") != CLI_BUNDLE_SCHEMA_VERSION:
raise RuntimeError(
f"unsupported CLI bundle schema: {manifest.get('schema_version')!r}"
)
contract = manifest.get("contract")
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:
payload, content = fetch_script(name)
fetched[name] = (payload, content)
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}
install_order = []
for name in (*RUNTIME_SCRIPT_NAMES, "nap_install.py", *SCRIPT_NAMES):
if name not in install_order:
install_order.append(name)
for name in install_order:
payload, content = fetched[name]
target = INSTALL_DIR / str(payload.get("filename") or name)
write_file(target, content, executable=name.endswith(".py"))
scripts[name] = {
"path": str(target),
"version": payload.get("version"),
"build_commit": payload.get("build_commit"),
"sha256": payload.get("sha256"),
"source_repo": payload.get("source_repo"),
"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"),
"supported_backend_api_versions": payload.get("supported_backend_api_versions"),
"supported_gateway_contract_versions": payload.get("supported_gateway_contract_versions"),
}
installer_content = (INSTALL_DIR / "nap_install.py").read_text(encoding="utf-8")
nap_path = BIN_DIR / "nap"
write_file(nap_path, installer_content, executable=True)
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":
write_file(BIN_DIR / "nap.cmd", f'@echo off\r\n"{sys.executable}" "{nap_path}" %*\r\n', executable=False)
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
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))
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")
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)
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),
)
result = {
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] = {
"path": str(INSTALL_DIR / "current" / INSTALL_PATHS[name]),
"version": payload.get("version"),
"sha256": payload.get("sha256"),
"source_repo": payload.get("source_repo"),
"source_revision": payload.get("source_revision"),
"contract_version": payload.get("contract_version"),
}
validate_staged_python(staging)
write_file(
staging / "bundle-manifest.json",
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
executable=False,
)
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",
"message": "Napseer CLI and runtime scripts are installed.",
"message": "Napseer CLI and runtime bundle installed atomically.",
"bin": str(nap_path),
"install_dir": str(INSTALL_DIR),
"release_dir": str(final_release),
"bundle_id": manifest.get("bundle_id"),
"scripts": scripts,
"cli_distribution": {
"contract_version": CLI_DISTRIBUTION_CONTRACT_VERSION,
"source_repo": installer_payload.get("source_repo") or CLI_GENERATED_SOURCE_REPO,
"source_revision": None
if source_revision_status_for(cli_source_revision) == "unresolved"
else cli_source_revision,
"source_revision_status": cli_source_revision_status,
"release_version": manifest.get("release_version"),
"contract_version": manifest["contract"]["current"],
"minimum_contract_version": manifest["contract"]["minimum_supported"],
"source_repo": manifest["source"]["repo"],
"source_revision": manifest["source"]["revision"],
"source_revision_status": manifest["source"]["revision_status"],
},
"updated_project_paths": updated_project_paths,
"updated_project_paths": [],
"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():
missing = []
for name in RUNTIME_SCRIPT_NAMES:
path = INSTALL_DIR / {
"terminal_init.py": "terminal/__init__.py",
"terminal_protocol.py": "terminal/protocol.py",
"terminal_pty_manager.py": "terminal/pty_manager.py",
}.get(name, name)
path = runtime_asset_path(name)
if not path.exists() or path.stat().st_size == 0:
missing.append(name)
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()
if missing:
install_assets(update_project=False)
missing = runtime_assets_missing()
if missing:
raise RuntimeError(f"missing installed Napseer runtime scripts after repair: {', '.join(missing)}")
raise RuntimeError(
"missing installed Napseer runtime scripts: "
f"{', '.join(missing)}. Run `nap mcp install` to repair them."
)
def run_wrapper(command_name, args):
wrapper = INSTALL_DIR / "napseer_mcp_server.py"
ensure_runtime_assets()
wrapper = runtime_asset_path("napseer_mcp_server.py")
require_runtime_assets()
return subprocess.call([sys.executable, str(wrapper), command_name, *args])
@@ -277,14 +536,17 @@ def cwd_state_dir():
def state_dir_status():
preferred = preferred_state_dir()
active = cwd_state_dir()
return {
"active_state_dir": str(active),
"active_auth_path": str(active / "auth.json"),
"active_state_dir": str(preferred),
"active_auth_path": str(preferred / "auth.json"),
"state_dir": str(preferred),
"auth_path": str(preferred / "auth.json"),
"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):
wrapper = INSTALL_DIR / "napseer_mcp_server.py"
ensure_runtime_assets()
wrapper = runtime_asset_path("napseer_mcp_server.py")
require_runtime_assets()
existing = read_service_state(kind)
if existing and pid_running(existing.get("pid")):
return {
@@ -629,15 +891,211 @@ def print_json(value):
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):
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")
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":
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(update_project=True)
result = install_assets()
print_json({**result, "status": "updated", "message": "Napseer CLI and runtime scripts are updated."})
return 0
if command == "gateway":
@@ -659,6 +1117,10 @@ def main(argv):
if command == "agent":
return run_wrapper("agent", args)
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)
if command == "feedback":
return run_wrapper("feedback", args)
@@ -668,7 +1130,8 @@ def main(argv):
{
"bin": shutil.which("nap") or str(BIN_DIR / "nap"),
"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_auth_path": state["active_auth_path"],
"cwd_auth_configured": pathlib.Path(state["active_auth_path"]).exists(),
@@ -679,31 +1142,6 @@ def main(argv):
}
)
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}")
-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())