Release resilient atomic Napseer CLI runtime
This commit is contained in:
+549
-111
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
+1836
-213
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
Reference in New Issue
Block a user