Release resilient atomic Napseer CLI runtime
This commit is contained in:
@@ -11,9 +11,25 @@ This repository is intended to become the canonical source for:
|
||||
|
||||
Current state:
|
||||
|
||||
- `resources/scripts/nap_install.py` is copied from the backend pre-split implementation.
|
||||
- `resources/scripts/` contains the runtime scripts currently installed by `nap_install.py`.
|
||||
- `resources/scripts/nap_install.py` owns the versioned, verified, atomic
|
||||
runtime bundle installer.
|
||||
- `resources/scripts/` contains the public runtime source embedded by the
|
||||
backend without behavioral rewrites.
|
||||
- `resources/scripts/napseer_mcp_supervisor.py` is the recommended Codex stdio
|
||||
entrypoint. It keeps the client transport alive while restarting or reloading
|
||||
the generated `napseer_mcp_server.py` worker between requests.
|
||||
- `scripts/nap_install.py` is kept as a convenient top-level command source copy during the initial import.
|
||||
- Gateway-specific implementation is still partially embedded in `resources/scripts/napseer_mcp_server.py`; it should move behind a gateway package/runtime boundary in a later extraction slice.
|
||||
- Gateway service compatibility remains in the worker while the standalone
|
||||
gateway image is built from the public gateway repository.
|
||||
|
||||
Backend discovery remains the compatibility source of truth during migration.
|
||||
Backend discovery publishes the release manifest and exact public source
|
||||
revision used for each bundle.
|
||||
|
||||
Configure Codex and other long-lived stdio clients to launch:
|
||||
|
||||
```sh
|
||||
python3 ~/.local/share/napseer/napseer_mcp_supervisor.py
|
||||
```
|
||||
|
||||
The direct wrapper remains the `nap` CLI implementation and an isolated
|
||||
protocol-debugging entrypoint.
|
||||
|
||||
+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())
|
||||
Regular → Executable
+582
-163
@@ -3,18 +3,19 @@
|
||||
|
||||
Usage:
|
||||
python3 nap_install.py
|
||||
nap project init
|
||||
nap project create
|
||||
nap project status
|
||||
nap service start
|
||||
nap service status
|
||||
nap service logs
|
||||
nap gateway start
|
||||
nap gateway stop
|
||||
nap gateway configure
|
||||
nap gateway logs
|
||||
nap status
|
||||
nap agent list
|
||||
nap update
|
||||
"""
|
||||
|
||||
import ast
|
||||
import contextlib
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
@@ -35,6 +36,7 @@ INSTALL_DIR = pathlib.Path(os.environ.get("NAPSEER_HOME", pathlib.Path.home() /
|
||||
BIN_DIR = pathlib.Path(os.environ.get("NAPSEER_BIN_DIR", pathlib.Path.home() / ".local" / "bin"))
|
||||
SCRIPT_NAMES = (
|
||||
"napseer_mcp_server.py",
|
||||
"napseer_mcp_supervisor.py",
|
||||
"napseer_spake2.py",
|
||||
"terminal_init.py",
|
||||
"terminal_protocol.py",
|
||||
@@ -50,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):
|
||||
@@ -115,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")
|
||||
@@ -160,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 init", "nap service start", "nap gateway configure", "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])
|
||||
|
||||
|
||||
@@ -278,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."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -470,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 {
|
||||
@@ -589,74 +850,256 @@ def service_logs(kind, args):
|
||||
|
||||
|
||||
def handle_gateway(args):
|
||||
subcommand = args[0] if args else "help"
|
||||
rest = args[1:] if args else []
|
||||
if subcommand in {"help", "-h", "--help", "ls", "list", "status"}:
|
||||
print("Usage: nap gateway [configure|setup|restart|kill|vault|terminal|schedule|service]")
|
||||
print(" nap gateway configure [--command CMD] Configure the local gateway command.")
|
||||
print(" nap gateway setup Create or migrate local gateway storage.")
|
||||
print(" nap gateway vault ... Show gateway vault status and pending setup requests.")
|
||||
print(" nap gateway terminal ... Manage gateway tmux terminals.")
|
||||
print(" nap gateway schedule ... Manage gateway schedules (cron jobs).")
|
||||
print(" nap gateway service preregister|activate Register or activate a gateway service token.")
|
||||
print()
|
||||
print("Service lifecycle (start/stop/status/logs) lives under `nap service`.")
|
||||
print("Use `nap service start|stop|status|logs` for the local gateway process.")
|
||||
return 0
|
||||
if subcommand in {"configure", "setup", "restart", "kill", "vault", "terminal", "schedule", "service"}:
|
||||
return run_wrapper("gateway", [subcommand, *rest])
|
||||
if subcommand in {"start", "stop", "logs", "log"}:
|
||||
raise RuntimeError(
|
||||
f"unknown gateway subcommand: {subcommand}. "
|
||||
"Service lifecycle moved to `nap service <verb>`. "
|
||||
f"Use `nap service {subcommand if subcommand != 'log' else 'logs'}`."
|
||||
)
|
||||
if subcommand == "process":
|
||||
raise RuntimeError(
|
||||
"unknown gateway subcommand: process. Use `nap gateway vault process`."
|
||||
)
|
||||
raise RuntimeError("unknown gateway subcommand. Use `nap gateway help`.")
|
||||
|
||||
def handle_service(args):
|
||||
subcommand = args[0] if args else "status"
|
||||
subcommand = args[0] if args else "ls"
|
||||
rest = args[1:] if args else []
|
||||
if subcommand in {"help", "-h", "--help"}:
|
||||
print("Usage: nap service [start|stop|status|logs] [--port PORT] [--lines N]")
|
||||
print(" start Start the local gateway service in this directory.")
|
||||
print(" stop Stop the local gateway service.")
|
||||
print(" status Show the managed gateway process state.")
|
||||
print(" logs Tail the managed gateway log file.")
|
||||
print("Usage: nap gateway [start|stop|status|logs|configure|setup|restart|kill|vault|terminal|schedule|service]")
|
||||
print(" nap gateway start [--port PORT] Start the local gateway service.")
|
||||
print(" nap gateway status Show the managed gateway process.")
|
||||
print(" nap gateway configure [--command CMD] Configure the local gateway command.")
|
||||
print(" nap gateway setup Create or migrate local gateway storage.")
|
||||
print(" nap gateway vault Show gateway vault status and pending setup requests.")
|
||||
print(" nap gateway vault process Complete pending setup requests with the vault/content passphrase.")
|
||||
print(" nap gateway vault rotate-secret --kind memory")
|
||||
print(" nap chat secret setup Configure chat secret state with the vault/content passphrase.")
|
||||
return 0
|
||||
if subcommand == "vault" and rest and rest[0] in {"help", "-h", "--help"}:
|
||||
print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind memory] [--all] [--project-id ID] [--vault-passphrase TEXT]")
|
||||
print(" status Show local gateway state and pending setup requests.")
|
||||
print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.")
|
||||
print(" rotate-secret Rotate the memory project secret from the gateway worker path.")
|
||||
print(" Chat secrets use: nap chat secret setup|status|rotate; setup uses the same vault/content passphrase.")
|
||||
return 0
|
||||
if subcommand == "process":
|
||||
return run_wrapper("gateway", ["vault", "process", *rest])
|
||||
if subcommand == "start":
|
||||
return print_json(start_service("gateway", ["--no-browser", *rest])) or 0
|
||||
if subcommand == "stop":
|
||||
return print_json(stop_service("gateway")) or 0
|
||||
if subcommand == "status":
|
||||
if subcommand in {"ls", "list", "status"}:
|
||||
return print_json(list_service("gateway")) or 0
|
||||
if subcommand in {"logs", "log"}:
|
||||
return print_json(service_logs("gateway", rest)) or 0
|
||||
if subcommand in {"ls", "list"}:
|
||||
raise RuntimeError(
|
||||
f"unknown service subcommand: {subcommand}. Use one of: start, stop, status, logs."
|
||||
)
|
||||
raise RuntimeError("unknown service subcommand. Use `nap service help`.")
|
||||
if subcommand == "configure":
|
||||
return run_wrapper("gateway", ["configure", *rest])
|
||||
if subcommand in {"setup", "restart", "kill", "vault", "terminal", "schedule", "service"}:
|
||||
return run_wrapper("gateway", [subcommand, *rest])
|
||||
raise RuntimeError("unknown gateway command; use nap gateway start|stop|configure|setup|restart|kill|logs|status|vault|terminal|schedule|service")
|
||||
|
||||
|
||||
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()
|
||||
print_json({**result, "status": "updated", "message": "Napseer CLI and runtime scripts are updated."})
|
||||
return 0
|
||||
if command == "gateway":
|
||||
return handle_gateway(args)
|
||||
if command == "service":
|
||||
return handle_service(args)
|
||||
if command == "chat":
|
||||
return run_wrapper("chat", args or ["secret", "status"])
|
||||
if command == "status":
|
||||
@@ -664,6 +1107,8 @@ def main(argv):
|
||||
if command == "project":
|
||||
if not args:
|
||||
return run_wrapper("project", ["status"])
|
||||
if args[0] == "bootstrap":
|
||||
raise RuntimeError("unknown project command: bootstrap")
|
||||
return run_wrapper("project", args)
|
||||
if command == "lineage":
|
||||
return run_wrapper("lineage", args or ["status"])
|
||||
@@ -672,18 +1117,21 @@ 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)
|
||||
if command == "agent":
|
||||
return run_wrapper("agent", args)
|
||||
if command == "where":
|
||||
state = state_dir_status()
|
||||
print_json(
|
||||
{
|
||||
"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(),
|
||||
@@ -694,35 +1142,6 @@ def main(argv):
|
||||
}
|
||||
)
|
||||
return 0
|
||||
if command in {"help", "-h", "--help"}:
|
||||
print("Usage: nap [auth|project|chat|plan|lineage|gateway|service|agent|feedback|status|update|where|install]")
|
||||
print(" auth Auth commands: login-local, status.")
|
||||
print(" Example: nap auth login-local")
|
||||
print(" project Project commands: init, attach, create, claim, status, encryption.")
|
||||
print(" First-time setup: nap project init (enrolls and creates a project, no browser).")
|
||||
print(" Attach an existing project to a Napseer account: nap project attach (opens browser).")
|
||||
print(" Upgrade an anonymous project to a claimed one: nap project claim.")
|
||||
print(" Encrypted project setup is completed by the local gateway: nap gateway vault process")
|
||||
print(" chat Chat secret commands: secret status, secret setup, secret rotate.")
|
||||
print(" Examples: nap chat secret setup --vault-passphrase TEXT, nap chat secret status")
|
||||
print(" plan Plan commands: list, to-kanban.")
|
||||
print(" Example: nap plan to-kanban /plans/example --column todo")
|
||||
print(" lineage Generic source-record -> plan -> kanban lineage checks.")
|
||||
print(" Example: nap lineage status")
|
||||
print(" gateway Gateway operations: configure, setup, restart, kill, vault, terminal, schedule, service.")
|
||||
print(" Examples: nap gateway configure, nap gateway vault process --all")
|
||||
print(" service Local gateway service lifecycle: start, stop, status, logs.")
|
||||
print(" Start auto-selects a port; pass --port to pin one.")
|
||||
print(" Examples: nap service start, nap service logs --lines 200")
|
||||
print(" agent Agent commands: list, workspaces, create, show, edit.")
|
||||
print(" list shows registered project agents; workspaces shows /agents folders.")
|
||||
print(" feedback Feedback commands: list, global, set, resolve.")
|
||||
print(" Examples: nap feedback list, nap feedback set <id> --status open, nap feedback resolve <id> --notes TEXT")
|
||||
print(" status Show cwd Napseer configuration and next setup commands.")
|
||||
print(" update Refresh the globally installed nap command and wrapper.")
|
||||
print(" where Show installed nap paths and cwd auth location.")
|
||||
print(" install Reinstall/repair nap from the hosted script directory.")
|
||||
return 0
|
||||
raise RuntimeError(f"unknown command: {command}")
|
||||
|
||||
|
||||
|
||||
@@ -35,33 +35,31 @@ def test_initial_enrollment_persists_refresh_credential():
|
||||
mod.AUTH = anonymous_auth(mod)
|
||||
mod.BASE_URL = mod.AUTH["base_url"]
|
||||
mod.DEFAULT_PROJECT_ID = None
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
key_path = pathlib.Path(directory) / "test-key"
|
||||
pathlib.Path(str(key_path) + ".pub").write_text(
|
||||
"ssh-ed25519 test-public-key", encoding="utf-8"
|
||||
)
|
||||
mod.ensure_local_files = lambda: key_path
|
||||
mod.sign_text = lambda *args: "test-signature"
|
||||
saved = []
|
||||
mod.save_auth = lambda payload: saved.append(dict(payload))
|
||||
responses = iter([
|
||||
{"challenge_id": "challenge", "challenge_text": "sign-me"},
|
||||
{
|
||||
"token": {
|
||||
"access_token": "access",
|
||||
"refresh_token": "refresh",
|
||||
"expires_at": "2099-01-01T00:00:00Z",
|
||||
"refresh_expires_at": "",
|
||||
},
|
||||
"worker": {"id": "worker", "agent_id": "agent-id"},
|
||||
},
|
||||
])
|
||||
mod.request_json = lambda *args, **kwargs: next(responses)
|
||||
saved = []
|
||||
requests = []
|
||||
mod.save_auth = lambda payload: saved.append(dict(payload))
|
||||
|
||||
mod.ensure_enrolled({"slug": "example"})
|
||||
def request(method, path, payload=None, token_required=False):
|
||||
requests.append((method, path, payload, token_required))
|
||||
return {
|
||||
"token": {
|
||||
"access_token": "access",
|
||||
"refresh_token": "refresh",
|
||||
"expires_at": "2099-01-01T00:00:00Z",
|
||||
"refresh_expires_at": "",
|
||||
},
|
||||
"worker": {"id": "worker", "agent_id": "agent-id"},
|
||||
}
|
||||
|
||||
mod.request_json = request
|
||||
mod.ensure_enrolled({"slug": "example"})
|
||||
|
||||
assert saved[0]["refresh_token"] == "refresh"
|
||||
assert "refresh_expires_at" in saved[0]
|
||||
assert [(method, path) for method, path, _, _ in requests] == [
|
||||
("POST", "/v1/enrollment/token")
|
||||
]
|
||||
assert requests[0][3] is False
|
||||
|
||||
|
||||
def test_failed_anonymous_refresh_uses_ssh_recovery():
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import importlib.util
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
|
||||
SCRIPT_PATH = (
|
||||
pathlib.Path(__file__).resolve().parents[1]
|
||||
/ "resources"
|
||||
/ "scripts"
|
||||
/ "nap_install.py"
|
||||
)
|
||||
|
||||
|
||||
def load_installer():
|
||||
spec = importlib.util.spec_from_file_location("nap_install_command_system_test", SCRIPT_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def fail_if_called(*_args, **_kwargs):
|
||||
raise AssertionError("help dispatched a side-effecting handler")
|
||||
|
||||
|
||||
def test_root_help_exposes_only_canonical_commands(capsys):
|
||||
module = load_installer()
|
||||
|
||||
assert module.main(["nap"]) == 0
|
||||
|
||||
output = capsys.readouterr().out
|
||||
visible = [name for name, metadata in module.COMMAND_METADATA.items() if metadata["visible"]]
|
||||
assert visible == [name for name, _summary in module.CANONICAL_COMMANDS]
|
||||
assert len(visible) == 10
|
||||
for name in visible:
|
||||
assert f" {name}" in output
|
||||
for hidden in ("chat", "plan", "lineage", "agent", "feedback", "where", "install"):
|
||||
assert f" {hidden}" not in output
|
||||
|
||||
|
||||
def test_help_is_resolved_before_mutating_dispatch(monkeypatch, capsys):
|
||||
module = load_installer()
|
||||
monkeypatch.setattr(module, "install_assets", fail_if_called)
|
||||
monkeypatch.setattr(module, "run_wrapper", fail_if_called)
|
||||
monkeypatch.setattr(module, "handle_gateway", fail_if_called)
|
||||
|
||||
assert module.main(["nap", "update", "help"]) == 0
|
||||
assert module.main(["nap", "install", "--help"]) == 0
|
||||
assert module.main(["nap", "auth", "--help"]) == 0
|
||||
assert module.main(["nap", "project", "encryption", "--help"]) == 0
|
||||
assert module.main(["nap", "gateway", "vault", "help"]) == 0
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Help never performs an update" in output
|
||||
assert "Use `nap mcp install`" in output
|
||||
assert "Usage: nap auth" in output
|
||||
assert "Usage: nap project" in output
|
||||
assert "Usage: nap gateway" in output
|
||||
|
||||
|
||||
def test_help_topic_uses_same_canonical_text(capsys):
|
||||
module = load_installer()
|
||||
|
||||
assert module.main(["nap", "help", "mcp"]) == 0
|
||||
|
||||
assert "Usage: nap mcp [status|install|update|serve]" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_init_and_auth_repair_normalize_to_existing_handlers(monkeypatch):
|
||||
module = load_installer()
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"run_wrapper",
|
||||
lambda command, args: calls.append((command, args)) or 0,
|
||||
)
|
||||
|
||||
assert module.main(["nap", "init", "--slug", "demo"]) == 0
|
||||
assert module.main(["nap", "auth", "repair"]) == 0
|
||||
|
||||
assert calls == [
|
||||
("project", ["init", "--slug", "demo"]),
|
||||
("auth", ["refresh"]),
|
||||
]
|
||||
|
||||
|
||||
def test_update_does_not_overwrite_repository_source(monkeypatch, capsys):
|
||||
module = load_installer()
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"install_assets",
|
||||
lambda: calls.append("update") or {"status": "installed"},
|
||||
)
|
||||
|
||||
assert module.main(["nap", "update"]) == 0
|
||||
|
||||
assert calls == ["update"]
|
||||
assert json.loads(capsys.readouterr().out)["status"] == "updated"
|
||||
|
||||
|
||||
def test_doctor_reports_presence_without_exposing_values(tmp_path, monkeypatch):
|
||||
module = load_installer()
|
||||
state_dir = tmp_path / ".napseer"
|
||||
state_dir.mkdir()
|
||||
auth_path = state_dir / "auth.json"
|
||||
auth_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"account_mode": "anonymous",
|
||||
"project_id": "test-project",
|
||||
"token": "test-access-value",
|
||||
"refresh_token": "test-refresh-value",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
install_dir = tmp_path / "install"
|
||||
install_dir.mkdir()
|
||||
(install_dir / "napseer_mcp_server.py").write_text("# worker\n", encoding="utf-8")
|
||||
(install_dir / "napseer_mcp_supervisor.py").write_text("# supervisor\n", encoding="utf-8")
|
||||
monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
|
||||
monkeypatch.setattr(module, "runtime_assets_missing", lambda: [])
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"state_dir_status",
|
||||
lambda: {
|
||||
"active_auth_path": str(auth_path),
|
||||
"state_dir_exists": True,
|
||||
},
|
||||
)
|
||||
|
||||
result = module.doctor_status()
|
||||
serialized = json.dumps(result)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["checks"]["token_present"] is True
|
||||
assert result["checks"]["refresh_present"] is True
|
||||
assert "test-access-value" not in serialized
|
||||
assert "test-refresh-value" not in serialized
|
||||
|
||||
|
||||
def test_doctor_does_not_create_state_directory(tmp_path, monkeypatch):
|
||||
module = load_installer()
|
||||
state_dir = tmp_path / ".napseer"
|
||||
install_dir = tmp_path / "install"
|
||||
install_dir.mkdir()
|
||||
(install_dir / "napseer_mcp_server.py").write_text("# worker\n", encoding="utf-8")
|
||||
(install_dir / "napseer_mcp_supervisor.py").write_text("# supervisor\n", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
|
||||
monkeypatch.setattr(module, "runtime_assets_missing", lambda: [])
|
||||
|
||||
result = module.doctor_status()
|
||||
|
||||
assert result["status"] == "repair_required"
|
||||
assert not state_dir.exists()
|
||||
|
||||
|
||||
def test_wrapper_dispatch_never_installs_missing_runtime(monkeypatch):
|
||||
module = load_installer()
|
||||
monkeypatch.setattr(module, "runtime_assets_missing", lambda: ["napseer_mcp_server.py"])
|
||||
monkeypatch.setattr(module, "install_assets", fail_if_called)
|
||||
|
||||
try:
|
||||
module.run_wrapper("configure", [])
|
||||
except RuntimeError as exc:
|
||||
assert "nap mcp install" in str(exc)
|
||||
else:
|
||||
raise AssertionError("missing runtime should require an explicit repair command")
|
||||
|
||||
|
||||
def fake_bundle(module, revision="a" * 40, broken_name=None):
|
||||
contents = {
|
||||
name: f"#!/usr/bin/env python3\nVALUE = {index!r}\n"
|
||||
for index, name in enumerate(module.SCRIPT_NAMES)
|
||||
}
|
||||
items = []
|
||||
payloads = {}
|
||||
for name in module.SCRIPT_NAMES:
|
||||
content = contents[name]
|
||||
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
item = {
|
||||
"name": name,
|
||||
"version": module.CLI_RELEASE_VERSION,
|
||||
"bytes": len(content.encode("utf-8")),
|
||||
"sha256": digest,
|
||||
"source_repo": "https://github.com/napseer/nap-cli",
|
||||
"source_revision": revision,
|
||||
"source_revision_status": "resolved",
|
||||
"contract_version": module.CLI_DISTRIBUTION_CONTRACT_VERSION,
|
||||
"minimum_contract_version": module.CLI_MINIMUM_CONTRACT_VERSION,
|
||||
"install_path": module.INSTALL_PATHS[name],
|
||||
"mode": "0755",
|
||||
}
|
||||
items.append(item)
|
||||
payloads[name] = ({**item, "content": content}, content)
|
||||
if broken_name:
|
||||
payload, content = payloads[broken_name]
|
||||
payloads[broken_name] = (payload, content + "not valid python !!!")
|
||||
manifest = {
|
||||
"schema_version": module.CLI_BUNDLE_SCHEMA_VERSION,
|
||||
"bundle_id": f"nap-cli:{module.CLI_RELEASE_VERSION}:{revision}",
|
||||
"release_version": module.CLI_RELEASE_VERSION,
|
||||
"published_at": "2026-07-29T00:00:00Z",
|
||||
"contract": {
|
||||
"current": module.CLI_DISTRIBUTION_CONTRACT_VERSION,
|
||||
"minimum_supported": module.CLI_MINIMUM_CONTRACT_VERSION,
|
||||
},
|
||||
"source": {
|
||||
"repo": "https://github.com/napseer/nap-cli",
|
||||
"revision": revision,
|
||||
"revision_status": "resolved",
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
return manifest, payloads
|
||||
|
||||
|
||||
def test_bundle_install_stages_validates_and_activates_atomically(tmp_path, monkeypatch):
|
||||
module = load_installer()
|
||||
install_dir = tmp_path / "install"
|
||||
bin_dir = tmp_path / "bin"
|
||||
monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
|
||||
monkeypatch.setattr(module, "BIN_DIR", bin_dir)
|
||||
manifest, payloads = fake_bundle(module)
|
||||
monkeypatch.setattr(module, "bundle_manifest", lambda: manifest)
|
||||
monkeypatch.setattr(module, "fetch_script", lambda name: payloads[name])
|
||||
|
||||
result = module.install_assets()
|
||||
|
||||
assert result["cli_distribution"]["release_version"] == "0.2.0"
|
||||
assert (install_dir / "current").is_symlink()
|
||||
assert (install_dir / "current" / "bundle-manifest.json").is_file()
|
||||
assert (install_dir / "current" / "napseer_mcp_server.py").is_file()
|
||||
assert (install_dir / "current" / "terminal" / "protocol.py").is_file()
|
||||
assert (install_dir / "napseer_mcp_supervisor.py").is_symlink()
|
||||
assert (bin_dir / "nap").is_symlink()
|
||||
assert module.version_status()["source_revision"] == "a" * 40
|
||||
|
||||
|
||||
def test_failed_bundle_validation_keeps_previous_release_active(tmp_path, monkeypatch):
|
||||
module = load_installer()
|
||||
monkeypatch.setattr(module, "INSTALL_DIR", tmp_path / "install")
|
||||
monkeypatch.setattr(module, "BIN_DIR", tmp_path / "bin")
|
||||
first_manifest, first_payloads = fake_bundle(module, revision="a" * 40)
|
||||
monkeypatch.setattr(module, "bundle_manifest", lambda: first_manifest)
|
||||
monkeypatch.setattr(module, "fetch_script", lambda name: first_payloads[name])
|
||||
module.install_assets()
|
||||
active_before = (module.INSTALL_DIR / "current").resolve()
|
||||
|
||||
broken_manifest, broken_payloads = fake_bundle(
|
||||
module,
|
||||
revision="b" * 40,
|
||||
broken_name="napseer_mcp_server.py",
|
||||
)
|
||||
monkeypatch.setattr(module, "bundle_manifest", lambda: broken_manifest)
|
||||
monkeypatch.setattr(module, "fetch_script", lambda name: broken_payloads[name])
|
||||
|
||||
try:
|
||||
module.install_assets()
|
||||
except RuntimeError as exc:
|
||||
assert "does not match bundle manifest" in str(exc)
|
||||
else:
|
||||
raise AssertionError("invalid bundle must not activate")
|
||||
|
||||
assert (module.INSTALL_DIR / "current").resolve() == active_before
|
||||
assert not list((module.INSTALL_DIR / "releases").glob(".staging-*"))
|
||||
|
||||
|
||||
def test_manifest_rejects_path_traversal():
|
||||
module = load_installer()
|
||||
manifest, _payloads = fake_bundle(module)
|
||||
target = next(item for item in manifest["items"] if item["name"] == "nap_install.py")
|
||||
target["install_path"] = "../nap_install.py"
|
||||
|
||||
try:
|
||||
module.validated_manifest_items(manifest)
|
||||
except RuntimeError as exc:
|
||||
assert "unsafe install_path" in str(exc)
|
||||
else:
|
||||
raise AssertionError("unsafe manifest path must be rejected")
|
||||
@@ -6,11 +6,12 @@ Verifies that:
|
||||
are accepted and route to the right function.
|
||||
- dropped aliases (ls, new, send, rm, set-status, admin, global-list, etc.)
|
||||
now raise RuntimeError with a clear pointer to the canonical verb.
|
||||
- `nap project encryption set` requires --state; positional-state shortcut is gone.
|
||||
- `nap project encryption plaintext` (action-as-state) now errors.
|
||||
- project encryption compatibility forms normalize to one transition handler.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import contextlib
|
||||
import io
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
@@ -155,35 +156,48 @@ def test_gateway_rotate_passphrase_aliases_rejected():
|
||||
expect_unknown(mod, args, "rotate-passphrase")
|
||||
|
||||
|
||||
def test_project_encryption_state_flag_required():
|
||||
"""nap project encryption set requires --state; positional state is rejected."""
|
||||
def dispatch_encryption(mod, argv):
|
||||
calls = []
|
||||
mod.project_encryption_transition = lambda args: (
|
||||
calls.append(dict(args)) or {"status": "updated", "state": args["state"]}
|
||||
)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
mod.cli_main(argv)
|
||||
return calls
|
||||
|
||||
|
||||
def test_project_encryption_set_compatibility_form():
|
||||
"""The explicit set form routes to the canonical transition handler."""
|
||||
mod = load_module()
|
||||
# Without --state, the dispatch should raise
|
||||
expect_unknown(
|
||||
calls = dispatch_encryption(
|
||||
mod,
|
||||
["napseer_mcp_server.py", "project", "encryption", "set", "plaintext"],
|
||||
"--state plaintext|encrypted",
|
||||
)
|
||||
assert calls == [{"state": "plaintext", "passphrase": None}]
|
||||
|
||||
|
||||
def test_project_encryption_action_as_state_rejected():
|
||||
"""`nap project encryption plaintext` (the old shortcut) must error."""
|
||||
def test_project_encryption_action_as_state_is_hidden_compatibility():
|
||||
mod = load_module()
|
||||
expect_unknown(
|
||||
calls = dispatch_encryption(
|
||||
mod,
|
||||
["napseer_mcp_server.py", "project", "encryption", "plaintext"],
|
||||
"project encryption",
|
||||
)
|
||||
assert calls == [{"state": "plaintext", "passphrase": None}]
|
||||
|
||||
|
||||
def test_project_encryption_show_alias_rejected():
|
||||
def test_project_encryption_status_aliases_are_read_only_compatibility():
|
||||
mod = load_module()
|
||||
for args in [
|
||||
calls = []
|
||||
mod.project_encryption_status = lambda args: (
|
||||
calls.append(dict(args)) or {"status": "ok", "state": "plaintext"}
|
||||
)
|
||||
for argv in [
|
||||
["napseer_mcp_server.py", "project", "encryption", "state"],
|
||||
["napseer_mcp_server.py", "project", "encryption", "show"],
|
||||
["napseer_mcp_server.py", "project", "encryption", "encrypted"],
|
||||
]:
|
||||
expect_unknown(mod, args, "project encryption")
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
mod.cli_main(argv)
|
||||
assert calls == [{}, {}]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -198,7 +212,7 @@ if __name__ == "__main__":
|
||||
test_gateway_service_run_rejected()
|
||||
test_gateway_vault_aliases_rejected()
|
||||
test_gateway_rotate_passphrase_aliases_rejected()
|
||||
test_project_encryption_state_flag_required()
|
||||
test_project_encryption_action_as_state_rejected()
|
||||
test_project_encryption_show_alias_rejected()
|
||||
test_project_encryption_set_compatibility_form()
|
||||
test_project_encryption_action_as_state_is_hidden_compatibility()
|
||||
test_project_encryption_status_aliases_are_read_only_compatibility()
|
||||
print("ok: cli dispatch consolidation tests passed")
|
||||
|
||||
@@ -246,141 +246,62 @@ def test_node_get_rejects_path_reads(mod):
|
||||
|
||||
|
||||
def test_tools_include_node_by_path_descriptor(mod):
|
||||
tool_by_name = {item["name"]: item for item in mod.tools()}
|
||||
assert len(tool_by_name) == 56
|
||||
assert len(tool_by_name) == len(mod.tools())
|
||||
|
||||
get_tool = next(item for item in mod.tools() if item["name"] == "nap_node_get")
|
||||
assert get_tool["inputSchema"].get("required") is None
|
||||
assert "node_id" in get_tool["inputSchema"]["properties"]
|
||||
assert "uri" in get_tool["inputSchema"]["properties"]
|
||||
assert "path" not in get_tool["inputSchema"]["properties"]
|
||||
|
||||
by_id = next(item for item in mod.tools() if item["name"] == "nap_node_by_id")
|
||||
assert by_id["inputSchema"]["required"] == ["node_id"]
|
||||
assert "nap:// URI" in by_id["description"]
|
||||
|
||||
by_uri = next(item for item in mod.tools() if item["name"] == "nap_node_by_uri")
|
||||
assert by_uri["inputSchema"]["required"] == ["uri"]
|
||||
assert "nap://" in by_uri["description"]
|
||||
|
||||
tool = next(item for item in mod.tools() if item["name"] == "nap_node_by_path")
|
||||
assert tool["inputSchema"]["required"] == ["path"]
|
||||
assert "preview_chars" not in tool["inputSchema"]["properties"]
|
||||
assert "does not read full node content" in tool["description"]
|
||||
|
||||
assert all(item["name"] != "nap_cat" for item in mod.tools())
|
||||
for hidden_name in [
|
||||
"nap_cat",
|
||||
"nap_node_by_id",
|
||||
"nap_node_by_uri",
|
||||
"nap_tee",
|
||||
"nap_patch",
|
||||
"nap_find",
|
||||
"nap_index_sync",
|
||||
]:
|
||||
assert hidden_name not in tool_by_name
|
||||
|
||||
for name in ["nap_discover", "nap_find", "nap_context", "nap_find_related", "nap_recent"]:
|
||||
for name in ["nap_discover", "nap_context", "nap_find_related", "nap_recent"]:
|
||||
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"]
|
||||
view = schema["properties"].get("view")
|
||||
if view:
|
||||
assert "full" not in view["enum"]
|
||||
|
||||
for name in ["nap_patch", "nap_node_patch", "nap_rm"]:
|
||||
for name in ["nap_node_patch", "nap_rm"]:
|
||||
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"]
|
||||
assert "node_id" in schema["properties"]
|
||||
assert "uri" in schema["properties"]
|
||||
assert "path" not in schema["properties"]
|
||||
|
||||
tee_schema = next(item for item in mod.tools() if item["name"] == "nap_tee")["inputSchema"]
|
||||
assert tee_schema.get("required") is None
|
||||
assert "path" in tee_schema["properties"]
|
||||
assert "node_id" in tee_schema["properties"]
|
||||
assert "uri" in tee_schema["properties"]
|
||||
|
||||
bulk_node_schema = next(item for item in mod.tools() if item["name"] == "nap_bulk")["inputSchema"]["properties"]["nodes"]["items"]
|
||||
assert bulk_node_schema.get("required") is None
|
||||
assert "path" in bulk_node_schema["properties"]
|
||||
assert "node_id" in bulk_node_schema["properties"]
|
||||
assert "uri" in bulk_node_schema["properties"]
|
||||
|
||||
for name in [
|
||||
"nap_kanban_start",
|
||||
"nap_kanban_send_review",
|
||||
"nap_kanban_complete",
|
||||
"nap_kanban_block",
|
||||
"nap_kanban_unblock",
|
||||
"nap_kanban_update",
|
||||
"nap_kanban_archive",
|
||||
for required_name in [
|
||||
"nap_library_ls",
|
||||
"nap_library_attach",
|
||||
"nap_library_detach",
|
||||
"nap_library_nodes",
|
||||
"nap_library_node_get",
|
||||
"nap_auth_refresh",
|
||||
"nap_node_restore",
|
||||
"nap_plan_transition",
|
||||
"nap_index_status",
|
||||
]:
|
||||
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"]
|
||||
assert "node_id" in schema["properties"]
|
||||
assert "uri" in schema["properties"]
|
||||
assert "path" not in schema["properties"]
|
||||
|
||||
mv_schema = next(item for item in mod.tools() if item["name"] == "nap_mv")["inputSchema"]
|
||||
assert mv_schema["required"] == ["new_path"]
|
||||
assert "node_id" in mv_schema["properties"]
|
||||
assert "uri" in mv_schema["properties"]
|
||||
assert "path" not in mv_schema["properties"]
|
||||
|
||||
ln_schema = next(item for item in mod.tools() if item["name"] == "nap_ln")["inputSchema"]
|
||||
assert "source_node_id" in ln_schema["properties"]
|
||||
assert "source_uri" in ln_schema["properties"]
|
||||
assert "target_node_id" in ln_schema["properties"]
|
||||
assert "target_uri" in ln_schema["properties"]
|
||||
assert "source_path" not in ln_schema["properties"]
|
||||
assert "target_path" not in ln_schema["properties"]
|
||||
|
||||
backlinks_schema = next(item for item in mod.tools() if item["name"] == "nap_backlinks")["inputSchema"]
|
||||
assert "node_id" in backlinks_schema["properties"]
|
||||
assert "uri" in backlinks_schema["properties"]
|
||||
assert "path" not in backlinks_schema["properties"]
|
||||
|
||||
context_schema = next(item for item in mod.tools() if item["name"] == "nap_context")["inputSchema"]
|
||||
assert "node_id" in context_schema["properties"]
|
||||
assert "node_ids" in context_schema["properties"]
|
||||
assert "uri" in context_schema["properties"]
|
||||
assert "uris" in context_schema["properties"]
|
||||
assert "path" in context_schema["properties"]
|
||||
assert "paths" in context_schema["properties"]
|
||||
|
||||
related_schema = next(item for item in mod.tools() if item["name"] == "nap_find_related")["inputSchema"]
|
||||
assert "node_id" in related_schema["properties"]
|
||||
assert "uri" in related_schema["properties"]
|
||||
assert "path" not in related_schema["properties"]
|
||||
|
||||
complete_schema = next(item for item in mod.tools() if item["name"] == "nap_plan_complete")["inputSchema"]
|
||||
assert "plan_node_id" in complete_schema["properties"]
|
||||
assert "plan_uri" in complete_schema["properties"]
|
||||
assert "outcome_node_id" in complete_schema["properties"]
|
||||
assert "outcome_uri" in complete_schema["properties"]
|
||||
assert "path" not in complete_schema["properties"]
|
||||
assert "outcome_path" not in complete_schema["properties"]
|
||||
|
||||
supersede_schema = next(item for item in mod.tools() if item["name"] == "nap_plan_supersede")["inputSchema"]
|
||||
assert "required" not in supersede_schema
|
||||
assert "plan_node_id" in supersede_schema["properties"]
|
||||
assert "replacement_node_id" in supersede_schema["properties"]
|
||||
assert "path" not in supersede_schema["properties"]
|
||||
assert "replacement_path" not in supersede_schema["properties"]
|
||||
|
||||
cancel_schema = next(item for item in mod.tools() if item["name"] == "nap_plan_cancel")["inputSchema"]
|
||||
assert "required" not in cancel_schema
|
||||
assert "plan_node_id" in cancel_schema["properties"]
|
||||
assert "path" not in cancel_schema["properties"]
|
||||
|
||||
plan_to_kanban_schema = next(item for item in mod.tools() if item["name"] == "nap_plan_to_kanban")["inputSchema"]
|
||||
assert "plan_node_id" in plan_to_kanban_schema["properties"]
|
||||
assert "plan_uri" in plan_to_kanban_schema["properties"]
|
||||
assert "plan_path" not in plan_to_kanban_schema["properties"]
|
||||
|
||||
for name in ["nap_agent_cat", "nap_agent_patch", "nap_agent_rm"]:
|
||||
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"]
|
||||
assert schema["required"] == ["agent_slug"]
|
||||
assert "node_id" in schema["properties"]
|
||||
assert "uri" in schema["properties"]
|
||||
assert "path" not in schema["properties"]
|
||||
|
||||
agent_tee_schema = next(item for item in mod.tools() if item["name"] == "nap_agent_tee")["inputSchema"]
|
||||
assert agent_tee_schema["required"] == ["agent_slug"]
|
||||
assert "path" in agent_tee_schema["properties"]
|
||||
assert "node_id" in agent_tee_schema["properties"]
|
||||
assert "uri" in agent_tee_schema["properties"]
|
||||
|
||||
agent_ln_schema = next(item for item in mod.tools() if item["name"] == "nap_agent_ln")["inputSchema"]
|
||||
assert agent_ln_schema["required"] == ["agent_slug"]
|
||||
assert "source_node_id" in agent_ln_schema["properties"]
|
||||
assert "target_node_id" in agent_ln_schema["properties"]
|
||||
assert "source_path" not in agent_ln_schema["properties"]
|
||||
assert "target_path" not in agent_ln_schema["properties"]
|
||||
assert required_name in tool_by_name
|
||||
|
||||
|
||||
def test_mutation_tools_reject_path_and_patch_by_node_id(mod):
|
||||
@@ -508,15 +429,21 @@ def test_tee_and_bulk_create_by_path_update_by_identity(mod):
|
||||
assert writes[-1][0] == "PATCH"
|
||||
assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123"
|
||||
|
||||
with pytest.raises(RuntimeError, match="path already resolves"):
|
||||
mod.upsert_node({"path": "/notes/current", "content_text": "bad"})
|
||||
compatibility_update = mod.upsert_node(
|
||||
{"path": "/notes/current", "content_text": "compatibility update"}
|
||||
)
|
||||
assert compatibility_update["updated"] is True
|
||||
assert writes[-1][0] == "PATCH"
|
||||
|
||||
bulk = mod.bulk_upsert_nodes({"nodes": [{"node_id": "node-123", "content_text": "bulk update"}]})
|
||||
assert bulk["items"][0]["created"] is False
|
||||
assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123"
|
||||
|
||||
with pytest.raises(RuntimeError, match="path already resolves"):
|
||||
mod.bulk_upsert_nodes({"nodes": [{"path": "/notes/current", "content_text": "bad"}]})
|
||||
compatibility_bulk_update = mod.bulk_upsert_nodes(
|
||||
{"nodes": [{"path": "/notes/current", "content_text": "compatibility update"}]}
|
||||
)
|
||||
assert compatibility_bulk_update["items"][0]["created"] is False
|
||||
assert writes[-1][0] == "PATCH"
|
||||
|
||||
|
||||
def test_agent_tools_create_by_route_and_update_by_identity(mod):
|
||||
@@ -867,12 +794,12 @@ def test_index_sync_fetches_incremental_changes_and_removes_archived_nodes(mod):
|
||||
assert mod.search_local_index({"q": "existing"})["items"] == []
|
||||
|
||||
|
||||
def test_tools_include_index_sync_descriptor(mod):
|
||||
tool = next(item for item in mod.tools() if item["name"] == "nap_index_sync")
|
||||
assert "Incrementally synchronize" in tool["description"]
|
||||
assert tool["inputSchema"]["properties"]["lookback_seconds"]["default"] == 5
|
||||
contract = mod.tool_contract_metadata("nap_index_sync")
|
||||
assert contract["side_effect"] == "local-file"
|
||||
def test_tools_include_read_only_index_status_descriptor(mod):
|
||||
tool = next(item for item in mod.tools() if item["name"] == "nap_index_status")
|
||||
assert "local search index" in tool["description"]
|
||||
assert tool["inputSchema"]["properties"] == {}
|
||||
contract = mod.tool_contract_metadata("nap_index_status")
|
||||
assert contract["side_effect"] == "none"
|
||||
assert contract["auth_mode"] == "local-only"
|
||||
|
||||
|
||||
|
||||
@@ -266,7 +266,11 @@ def run():
|
||||
assert compact["view"] == "paths"
|
||||
assert "content_text" not in compact["items"][0]
|
||||
assert compact["items"][0]["full_path"] == "/renamed/alpha"
|
||||
full = mod.list_project_nodes({"limit": 10, "view": "full"})
|
||||
full = mod.list_project_nodes({
|
||||
"limit": 10,
|
||||
"view": "full",
|
||||
"_allow_full": True,
|
||||
})
|
||||
assert full["items"][0]["content_text"] == "secret needle"
|
||||
|
||||
plan_base = {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import select
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
SUPERVISOR = ROOT / "resources" / "scripts" / "napseer_mcp_supervisor.py"
|
||||
|
||||
|
||||
def write_worker(path: pathlib.Path, version: int) -> None:
|
||||
path.write_text(
|
||||
f"""\
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
VERSION = {version}
|
||||
|
||||
for line in sys.stdin:
|
||||
message = json.loads(line)
|
||||
if message.get("method") == "notifications/initialized":
|
||||
continue
|
||||
if message.get("method") == "tools/call":
|
||||
name = (message.get("params") or {{}}).get("name")
|
||||
if name == "crash":
|
||||
os._exit(17)
|
||||
result = {{"pid": os.getpid(), "version": VERSION, "name": name}}
|
||||
else:
|
||||
result = {{"version": VERSION}}
|
||||
print(json.dumps({{"jsonrpc": "2.0", "id": message.get("id"), "result": result}}), flush=True)
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
class SupervisorClient:
|
||||
def __init__(self, worker_path: pathlib.Path):
|
||||
environment = os.environ.copy()
|
||||
environment["NAPSEER_MCP_WORKER_PATH"] = str(worker_path)
|
||||
environment["NAPSEER_MCP_RESPONSE_TIMEOUT_SECONDS"] = "5"
|
||||
self.process = subprocess.Popen(
|
||||
[sys.executable, str(SUPERVISOR)],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=environment,
|
||||
)
|
||||
|
||||
def request(self, request_id: int, method: str, params=None):
|
||||
assert self.process.stdin is not None
|
||||
assert self.process.stdout is not None
|
||||
message = {"jsonrpc": "2.0", "id": request_id, "method": method}
|
||||
if params is not None:
|
||||
message["params"] = params
|
||||
self.process.stdin.write(json.dumps(message).encode("utf-8") + b"\n")
|
||||
self.process.stdin.flush()
|
||||
readable, _, _ = select.select([self.process.stdout], [], [], 8)
|
||||
assert readable, "supervisor did not return a response"
|
||||
return json.loads(self.process.stdout.readline())
|
||||
|
||||
def close(self):
|
||||
if self.process.poll() is None:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=5)
|
||||
|
||||
|
||||
def test_restarts_worker_killed_between_requests(tmp_path):
|
||||
worker_path = tmp_path / "worker.py"
|
||||
write_worker(worker_path, 1)
|
||||
client = SupervisorClient(worker_path)
|
||||
try:
|
||||
first = client.request(1, "tools/call", {"name": "pid", "arguments": {}})
|
||||
os.kill(first["result"]["pid"], signal.SIGTERM)
|
||||
time.sleep(0.1)
|
||||
|
||||
second = client.request(2, "tools/list")
|
||||
|
||||
assert second["result"]["version"] == 1
|
||||
assert client.process.poll() is None
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_reloads_replaced_worker_between_requests(tmp_path):
|
||||
worker_path = tmp_path / "worker.py"
|
||||
write_worker(worker_path, 1)
|
||||
client = SupervisorClient(worker_path)
|
||||
try:
|
||||
assert client.request(1, "initialize")["result"]["version"] == 1
|
||||
time.sleep(0.01)
|
||||
write_worker(worker_path, 2)
|
||||
os.utime(worker_path, None)
|
||||
|
||||
assert client.request(2, "tools/list")["result"]["version"] == 2
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_worker_crash_returns_error_without_closing_transport(tmp_path):
|
||||
worker_path = tmp_path / "worker.py"
|
||||
write_worker(worker_path, 1)
|
||||
client = SupervisorClient(worker_path)
|
||||
try:
|
||||
failed = client.request(1, "tools/call", {"name": "crash", "arguments": {}})
|
||||
recovered = client.request(2, "tools/list")
|
||||
|
||||
assert failed["error"]["code"] == -32098
|
||||
assert recovered["result"]["version"] == 1
|
||||
assert client.process.poll() is None
|
||||
finally:
|
||||
client.close()
|
||||
@@ -163,13 +163,36 @@ def test_cli_main_dispatches_init():
|
||||
print("ok: cli_main routes `project init` to cli_project_init passed")
|
||||
|
||||
|
||||
def test_cli_main_rejects_old_aliases():
|
||||
"""Verify `auth login` / `project bootstrap-existing` are no longer accepted."""
|
||||
mod = load_module()
|
||||
def test_cli_main_accepts_canonical_login_and_hidden_compatibility_alias():
|
||||
"""Login aliases must route to one handler without performing browser or network I/O."""
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
mod = load_module()
|
||||
calls = []
|
||||
mod.operator_account_login = lambda args: (
|
||||
calls.append(dict(args)) or {"status": "authenticated"}
|
||||
)
|
||||
|
||||
for subcommand in ["login", "login-local", "operator-login"]:
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
mod.cli_main([
|
||||
"napseer_mcp_server.py",
|
||||
"auth",
|
||||
subcommand,
|
||||
"--no-browser",
|
||||
"--timeout",
|
||||
"1",
|
||||
])
|
||||
|
||||
assert len(calls) == 3
|
||||
assert all(call["open_browser"] is False for call in calls)
|
||||
assert all(call["timeout_seconds"] == 1 for call in calls)
|
||||
|
||||
|
||||
def test_cli_main_rejects_project_login_aliases():
|
||||
mod = load_module()
|
||||
cases = [
|
||||
(["napseer_mcp_server.py", "auth", "login"], "unknown auth subcommand: login"),
|
||||
(["napseer_mcp_server.py", "auth", "operator-login"], "unknown auth subcommand: operator-login"),
|
||||
(["napseer_mcp_server.py", "project", "login"], "unknown project subcommand: login"),
|
||||
(["napseer_mcp_server.py", "project", "bootstrap-existing"], "unknown project subcommand: bootstrap-existing"),
|
||||
]
|
||||
@@ -180,7 +203,7 @@ def test_cli_main_rejects_old_aliases():
|
||||
assert expected_substring in str(exc), (args, str(exc))
|
||||
else:
|
||||
raise AssertionError(f"expected RuntimeError for {args}")
|
||||
print("ok: cli_main rejects old auth/project aliases with clear errors passed")
|
||||
print("ok: cli_main rejects old project aliases with clear errors passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -188,5 +211,6 @@ if __name__ == "__main__":
|
||||
test_init_already_initialized()
|
||||
test_init_token_only_creates_project()
|
||||
test_cli_main_dispatches_init()
|
||||
test_cli_main_rejects_old_aliases()
|
||||
test_cli_main_accepts_canonical_login_and_hidden_compatibility_alias()
|
||||
test_cli_main_rejects_project_login_aliases()
|
||||
print("ok: project init wizard smoke tests passed")
|
||||
|
||||
Reference in New Issue
Block a user