Initial CLI source import

This commit is contained in:
HumbleTurtle
2026-05-03 11:27:56 -03:00
commit 078faaf143
13 changed files with 12388 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
dist/
build/
*.egg-info/
.napseer/
+12
View File
@@ -0,0 +1,12 @@
{
"name": "nap-cli",
"source": "backend-split-import",
"contract_version": "2026-05-03",
"owned_surface": [
"nap installer",
"project commands",
"local MCP wrapper",
"gateway command facade"
],
"migration_status": "initial source import from backend resources/scripts"
}
+19
View File
@@ -0,0 +1,19 @@
# Napseer CLI
Source import for the `nap` operator CLI.
This repository is intended to become the canonical source for:
- `nap` installer and update UX.
- Project bootstrap and status commands.
- Local authenticated MCP wrapper launch/update behavior.
- Gateway command facade commands such as `nap gateway start`, `nap gateway logs`, and `nap gateway update`.
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`.
- `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.
Backend discovery remains the compatibility source of truth during migration.
+684
View File
@@ -0,0 +1,684 @@
#!/usr/bin/env python3
"""Install the Napseer local command.
Usage:
python3 nap_install.py
nap project create
nap project status
nap gateway start
nap gateway stop
nap gateway configure
nap gateway logs
nap status
nap agent list
nap update
"""
import json
import os
import pathlib
import shutil
import signal
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BASE_URL = os.environ.get("NAPSEER_BASE_URL", "https://api.napseer.com").rstrip("/")
INSTALL_DIR = pathlib.Path(os.environ.get("NAPSEER_HOME", pathlib.Path.home() / ".local" / "share" / "napseer"))
BIN_DIR = pathlib.Path(os.environ.get("NAPSEER_BIN_DIR", pathlib.Path.home() / ".local" / "bin"))
SCRIPT_NAMES = (
"napseer_mcp_server.py",
"napseer_spake2.py",
"terminal_init.py",
"terminal_protocol.py",
"terminal_pty_manager.py",
"nap_install.py",
)
HTTP_TIMEOUT_SECONDS = int(os.environ.get("NAPSEER_HTTP_TIMEOUT_SECONDS", "30"))
LOCAL_HOST = "127.0.0.1"
DEFAULT_GATEWAY_PORT = os.environ.get("NAPSEER_GATEWAY_PORT")
RUNTIME_SCRIPT_NAMES = (
"napseer_spake2.py",
"terminal_init.py",
"terminal_protocol.py",
"terminal_pty_manager.py",
"napseer_mcp_server.py",
)
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-05-03"
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")
def chmod_best_effort(path, mode):
try:
path.chmod(mode)
except OSError:
pass
def normalize_proxy_url(value):
if not value:
return None
proxy_url = str(value).strip()
if not proxy_url:
return None
if "://" not in proxy_url:
proxy_url = f"http://{proxy_url}"
return urllib.parse.urlparse(proxy_url)
def api_proxy_url(parsed):
host = parsed.hostname or ""
if host and urllib.request.proxy_bypass(host):
return None
explicit = (
os.environ.get("NAPSEER_API_PROXY_URL")
or os.environ.get("NAPSEER_HTTP_PROXY_URL")
or os.environ.get("NAPSEER_PROXY_URL")
or os.environ.get("NAPSEER_RELAY_PROXY_URL")
or os.environ.get("NAPSEER_GATEWAY_PROXY_URL")
)
if explicit:
return explicit
proxies = urllib.request.getproxies_environment()
return proxies.get(parsed.scheme) or proxies.get("all")
def api_urlopen(request, timeout):
parsed = urllib.parse.urlparse(request.full_url)
proxy = normalize_proxy_url(api_proxy_url(parsed))
if not proxy:
return urllib.request.urlopen(request, timeout=timeout)
proxy_url = urllib.parse.urlunparse(proxy)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({parsed.scheme: proxy_url}))
return opener.open(request, timeout=timeout)
def fetch_script(name):
request = urllib.request.Request(
f"{BASE_URL}/v1/scripts/{name}",
headers={"User-Agent": "nap-install-python/0.1"},
method="GET",
)
try:
with api_urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as response:
payload = json.loads(response.read().decode("utf-8"))
except (TimeoutError, socket.timeout) as exc:
raise RuntimeError(f"GET /v1/scripts/{name} timed out after {HTTP_TIMEOUT_SECONDS}s") from exc
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8")
raise RuntimeError(f"GET /v1/scripts/{name} failed: HTTP {exc.code}: {body}") from exc
content = payload.get("content")
if not isinstance(content, str) or not content.strip():
raise RuntimeError(f"{name} response did not include script content")
return payload, content
def write_file(path, content, executable=False):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
chmod_best_effort(path, 0o755 if executable else 0o644)
def path_warning():
path_entries = os.environ.get("PATH", "").split(os.pathsep)
if str(BIN_DIR) not in path_entries:
return f"Add {BIN_DIR} to PATH to run nap from any directory."
return None
def source_revision_status_for(value):
if not isinstance(value, str):
return "unresolved"
revision = value.strip()
if not revision or revision in ("unresolved", "unknown", "null"):
return "unresolved"
return "resolved"
def install_assets(update_project=False):
scripts = {}
fetched = {}
for name in SCRIPT_NAMES:
payload, content = fetch_script(name)
fetched[name] = (payload, content)
install_order = []
for name in (*RUNTIME_SCRIPT_NAMES, "nap_install.py", *SCRIPT_NAMES):
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)
if os.name == "nt":
write_file(BIN_DIR / "nap.cmd", f'@echo off\r\n"{sys.executable}" "{nap_path}" %*\r\n', executable=False)
updated_project_paths = []
if update_project:
for script_name in ("napseer_mcp_server.py", "napseer_spake2.py"):
content = (INSTALL_DIR / script_name).read_text(encoding="utf-8")
for candidate in (
pathlib.Path.cwd() / script_name,
pathlib.Path.cwd() / "resources" / "scripts" / script_name,
):
if candidate.exists():
write_file(candidate, content, executable=script_name.endswith(".py"))
updated_project_paths.append(str(candidate))
result = {
"status": "installed",
"message": "Napseer CLI and runtime scripts are installed.",
"bin": str(nap_path),
"install_dir": str(INSTALL_DIR),
"scripts": scripts,
"cli_distribution": {
"contract_version": CLI_DISTRIBUTION_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)
if CLI_GENERATED_SOURCE_REVISION_STATUS == "unresolved"
else CLI_GENERATED_SOURCE_REVISION_STATUS,
},
"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"],
}
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)
if not path.exists() or path.stat().st_size == 0:
missing.append(name)
return missing
def ensure_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)}")
def run_wrapper(command_name, args):
wrapper = INSTALL_DIR / "napseer_mcp_server.py"
ensure_runtime_assets()
return subprocess.call([sys.executable, str(wrapper), command_name, *args])
def preferred_state_dir():
return pathlib.Path.cwd() / ".napseer"
def legacy_state_dir():
return pathlib.Path.cwd() / "napseer"
def cwd_state_dir():
preferred = preferred_state_dir()
legacy = legacy_state_dir()
path = preferred if preferred.exists() or not legacy.exists() else legacy
path.mkdir(exist_ok=True)
chmod_best_effort(path, 0o700)
return path
def state_dir_status():
preferred = preferred_state_dir()
legacy = legacy_state_dir()
active = cwd_state_dir()
if preferred.exists() and legacy.exists():
message = "Both .napseer and legacy napseer directories exist; .napseer is active."
elif active == legacy:
message = "Legacy napseer directory is active because .napseer does not exist."
else:
message = ".napseer directory is active."
return {
"active_state_dir": str(active),
"active_auth_path": str(active / "auth.json"),
"preferred_state_dir": str(preferred),
"preferred_auth_path": str(preferred / "auth.json"),
"legacy_state_dir": str(legacy),
"legacy_auth_path": str(legacy / "auth.json"),
"preferred_state_dir_exists": preferred.exists(),
"legacy_state_dir_exists": legacy.exists(),
"legacy_state_dir_active": active == legacy,
"state_dir_message": message,
}
def service_state_path(kind):
return cwd_state_dir() / f"{kind}.json"
def service_log_path(kind):
return cwd_state_dir() / f"{kind}.log"
def read_service_state(kind):
path = service_state_path(kind)
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def write_service_state(kind, state):
path = service_state_path(kind)
write_file(path, json.dumps(state, indent=2), executable=False)
chmod_best_effort(path, 0o600)
def pid_running(pid):
if not pid:
return False
proc_stat = pathlib.Path("/proc") / str(pid) / "stat"
try:
fields = proc_stat.read_text(encoding="utf-8").split()
if len(fields) > 2 and fields[2] == "Z":
return False
except Exception:
pass
try:
os.kill(int(pid), 0)
return True
except OSError:
return False
def find_free_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((LOCAL_HOST, 0))
return int(sock.getsockname()[1])
def port_accepts_connections(port):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.2)
return sock.connect_ex((LOCAL_HOST, int(port))) == 0
def port_can_bind(port):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((LOCAL_HOST, int(port)))
return True
except OSError:
return False
def local_gateway_status(port):
request = urllib.request.Request(
f"http://{LOCAL_HOST}:{int(port)}/gateway/status",
headers={"User-Agent": "nap-cli-python/0.1"},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=1) as response:
return json.loads(response.read().decode("utf-8"))
except Exception:
return None
def log_tail(path, max_chars=2000):
try:
text = pathlib.Path(path).read_text(encoding="utf-8", errors="replace")
except Exception:
return ""
return text[-max_chars:]
def log_tail_lines(path, max_lines=120):
try:
lines = pathlib.Path(path).read_text(encoding="utf-8", errors="replace").splitlines()
except FileNotFoundError:
return []
except Exception as exc:
return [f"failed to read log: {exc}"]
return lines[-max(1, max_lines):]
def compact_log_file(path, max_bytes=MAX_SERVICE_LOG_BYTES):
if max_bytes <= 0:
return
file_path = pathlib.Path(path)
try:
size = file_path.stat().st_size
except FileNotFoundError:
return
except OSError:
return
if size <= max_bytes:
return
try:
with file_path.open("rb") as handle:
handle.seek(-max_bytes, os.SEEK_END)
chunk = handle.read(max_bytes)
except OSError:
return
# Try to start on a newline boundary so the log stays line-oriented.
newline_index = chunk.find(b"\n")
if newline_index >= 0 and newline_index + 1 < len(chunk):
chunk = chunk[newline_index + 1 :]
tmp_path = file_path.with_suffix(file_path.suffix + ".tmp")
try:
tmp_path.write_bytes(chunk)
tmp_path.replace(file_path)
except OSError:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
def ensure_port_available(kind, port):
if not port_accepts_connections(port) and port_can_bind(port):
return
status = local_gateway_status(port)
if status:
cwd = status.get("cwd") or "unknown cwd"
project_id = status.get("project_id") or "no project"
raise RuntimeError(
f"{LOCAL_HOST}:{port} is already serving a Napseer gateway for {cwd} "
f"(project {project_id}). Stop that gateway before starting this one."
)
raise RuntimeError(f"{LOCAL_HOST}:{port} is already in use or still being released")
def choose_service_port(kind, args, existing=None):
requested = cli_option(args, "--port", default=None)
if requested is not None:
return int(requested)
if kind == "gateway":
if DEFAULT_GATEWAY_PORT:
return int(DEFAULT_GATEWAY_PORT)
existing_port = (existing or {}).get("port")
if existing_port and port_can_bind(existing_port):
return int(existing_port)
return find_free_port()
return find_free_port()
def wait_for_service(kind, process, port, log_path):
deadline = time.time() + 3
while time.time() < deadline:
if process.poll() is not None:
tail = log_tail(log_path)
detail = f": {tail.strip()}" if tail.strip() else ""
raise RuntimeError(f"{kind} service exited during startup{detail}")
if kind == "gateway" and local_gateway_status(port):
return
if kind != "gateway" and port_accepts_connections(port):
return
time.sleep(0.1)
raise RuntimeError(f"{kind} service did not start listening on {LOCAL_HOST}:{port}")
def cli_option(args, *names, default=None):
for name in names:
if name in args:
index = args.index(name)
if index + 1 >= len(args):
raise RuntimeError(f"{name} requires a value")
return args[index + 1]
return default
def start_service(kind, args):
wrapper = INSTALL_DIR / "napseer_mcp_server.py"
ensure_runtime_assets()
existing = read_service_state(kind)
if existing and pid_running(existing.get("pid")):
return {
**existing,
"status": "running",
"message": f"{kind} service is already running.",
}
port = choose_service_port(kind, args, existing)
ensure_port_available(kind, port)
log_path = service_log_path(kind)
compact_log_file(log_path)
command = [sys.executable, str(wrapper), "open-ui", "--port", str(port), "--no-browser"]
if kind == "ui" and "--no-browser" not in args:
command.remove("--no-browser")
with log_path.open("ab") as log:
kwargs = {"stdout": log, "stderr": subprocess.STDOUT, "cwd": str(pathlib.Path.cwd())}
if os.name != "nt":
kwargs["start_new_session"] = True
else:
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
process = subprocess.Popen(command, **kwargs)
wait_for_service(kind, process, port, log_path)
state = {
"status": "running",
"kind": kind,
"pid": process.pid,
"host": LOCAL_HOST,
"port": port,
"url": f"http://{LOCAL_HOST}:{port}/",
"cwd": str(pathlib.Path.cwd()),
"log_path": str(log_path),
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
write_service_state(kind, state)
return {**state, "message": f"{kind} service started."}
def stop_service(kind):
state = read_service_state(kind)
if not state:
return {"status": "stopped", "kind": kind}
pid = state.get("pid")
if pid_running(pid):
if os.name == "nt":
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
try:
os.killpg(int(pid), signal.SIGTERM)
except OSError:
try:
os.kill(int(pid), signal.SIGTERM)
except OSError:
pass
deadline = time.time() + 5
while time.time() < deadline and pid_running(pid):
time.sleep(0.1)
if pid_running(pid):
if os.name == "nt":
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
try:
os.killpg(int(pid), signal.SIGKILL)
except OSError:
try:
os.kill(int(pid), signal.SIGKILL)
except OSError:
pass
deadline = time.time() + 2
while time.time() < deadline and pid_running(pid):
time.sleep(0.1)
port = state.get("port")
if port:
deadline = time.time() + 2
while time.time() < deadline and not port_can_bind(port):
time.sleep(0.1)
stopped = not pid_running(pid) and (not port or port_can_bind(port))
state = {**state, "status": "stopped" if stopped else "stopping"}
write_service_state(kind, state)
if not stopped:
state["warning"] = f"{kind} process or port {port} is still being released"
state["message"] = state["warning"]
else:
state["message"] = f"{kind} service stopped."
return state
def list_service(kind):
state = read_service_state(kind) or {
"kind": kind,
"host": LOCAL_HOST,
"port": None,
"url": None,
"cwd": str(pathlib.Path.cwd()),
}
state["status"] = "running" if pid_running(state.get("pid")) else "stopped"
state["message"] = f"{kind} service is {state['status']}."
return state
def service_logs(kind, args):
max_lines = int(cli_option(args, "--lines", "--tail", default="120"))
path = service_log_path(kind)
compact_log_file(path)
lines = log_tail_lines(path, max_lines=max_lines)
status = "ok" if path.exists() else "missing"
return {
"status": status,
"message": f"{kind} log {'read' if path.exists() else 'does not exist yet'}.",
"kind": kind,
"log_path": str(path),
"line_count": len(lines),
"lines": lines,
"text": "\n".join(lines),
}
def handle_gateway(args):
subcommand = args[0] if args else "ls"
rest = args[1:] if args else []
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 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 {"configure", "config"}:
return run_wrapper("gateway", ["configure", *rest])
if subcommand in {"setup", "unlock", "lock", "restart", "kill"}:
return run_wrapper("gateway", [subcommand, *rest])
raise RuntimeError("unknown gateway command; use nap gateway start|stop|configure|setup|unlock|lock|restart|kill|logs|status")
def print_json(value):
print(json.dumps(value, indent=2))
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 == "install":
print_json(install_assets(update_project=False))
return 0
if command == "update":
result = install_assets(update_project=True)
print_json({**result, "status": "updated", "message": "Napseer CLI and runtime scripts are updated."})
return 0
if command == "bootstrap":
raise RuntimeError("nap bootstrap was removed; use nap project create")
if command == "ui":
raise RuntimeError("top-level nap ui was removed; use project and gateway commands instead")
if command == "gateway":
return handle_gateway(args)
if command in {"configure", "config", "status"}:
return run_wrapper("configure", args)
if command == "project":
if not args:
return run_wrapper("project", ["status"])
if args and args[0] == "bootstrap":
raise RuntimeError("nap project bootstrap was removed; use nap project create")
return run_wrapper("project", args)
if command == "create":
raise RuntimeError("top-level nap create was removed; use nap project create")
if command == "agent":
return run_wrapper("agent", args)
if command == "auth":
return run_wrapper("auth", args)
if command == "feedback":
return run_wrapper("feedback", 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"),
"cwd": str(pathlib.Path.cwd()),
"cwd_auth_path": state["active_auth_path"],
"cwd_auth_configured": pathlib.Path(state["active_auth_path"]).exists(),
**state,
"path_warning": path_warning(),
"status": "ok",
"message": f"Napseer install and cwd paths resolved. {state['state_dir_message']}",
}
)
return 0
if command in {"help", "-h", "--help"}:
print("Usage: nap [auth|project|gateway|agent|feedback|status|update|where|install]")
print(" auth Auth commands: login-local, status.")
print(" Example: nap auth login-local")
print(" project Project commands: create, status, encryption, plaintext, encrypted.")
print(" Examples: nap project create, nap project status, nap project encryption set plaintext")
print(" gateway Gateway commands: start, stop, configure, setup, unlock, lock, restart, kill, logs, status.")
print(" Start auto-selects a local port; use --port to pin one.")
print(" Examples: nap gateway start, nap gateway start --port 8767, nap gateway logs")
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}")
if __name__ == "__main__":
try:
raise SystemExit(main(sys.argv))
except Exception as exc:
print(json.dumps({"error": str(exc)}, indent=2), file=sys.stderr)
raise SystemExit(1)
File diff suppressed because one or more lines are too long
+409
View File
@@ -0,0 +1,409 @@
#!/usr/bin/env python3
"""One-command Napseer project setup.
Run from a project root:
python3 napseer_setup.py
What it does:
1. Creates or reuses the active local state directory
2. Creates .gitignore inside it so local secrets are not committed
3. Generates id_ed25519 there if missing
4. Enrolls this project copy in Napseer
5. Creates or reuses a Napseer project
6. Writes ./.napseer/auth.json for local MCP wrappers
Optional environment:
NAPSEER_BASE_URL=https://api.napseer.com
NAPSEER_PROJECT_SLUG=<slug>
NAPSEER_PROJECT_NAME=<name>
NAPSEER_WORKER_NAME=<worker name>
"""
import json
import getpass
import base64
import os
import pathlib
import queue
import runpy
import secrets
import socket
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROOT = pathlib.Path.cwd()
def local_state_dir():
preferred = ROOT / ".napseer"
legacy = ROOT / "napseer"
if preferred.exists() or not legacy.exists():
return preferred
return legacy
NAPSEER_DIR = local_state_dir()
KEY_PATH = NAPSEER_DIR / "id_ed25519"
AUTH_PATH = NAPSEER_DIR / "auth.json"
VAULT_PATH = NAPSEER_DIR / "vault.json"
BASE_URL = os.environ.get("NAPSEER_BASE_URL", "https://api.napseer.com").rstrip("/")
def post_json(path, payload, token=None, idempotency_key=None):
data = json.dumps(payload).encode("utf-8")
headers = {"Content-Type": "application/json", "User-Agent": "napseer-setup-python/0.1"}
if token:
headers["Authorization"] = f"Bearer {token}"
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
request = urllib.request.Request(f"{BASE_URL}{path}", data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(request) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8")
raise RuntimeError(f"POST {path} failed: HTTP {exc.code}: {body}") from exc
def put_json(path, payload, token=None):
data = json.dumps(payload).encode("utf-8")
headers = {"Content-Type": "application/json", "User-Agent": "napseer-setup-python/0.1"}
if token:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(f"{BASE_URL}{path}", data=data, headers=headers, method="PUT")
try:
with urllib.request.urlopen(request) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8")
raise RuntimeError(f"PUT {path} failed: HTTP {exc.code}: {body}") from exc
def get_json(path):
request = urllib.request.Request(
f"{BASE_URL}{path}",
headers={"User-Agent": "napseer-setup-python/0.1"},
method="GET",
)
try:
with urllib.request.urlopen(request) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8")
raise RuntimeError(f"GET {path} failed: HTTP {exc.code}: {body}") from exc
def ensure_local_files():
NAPSEER_DIR.mkdir(exist_ok=True)
chmod_best_effort(NAPSEER_DIR, 0o700)
gitignore = NAPSEER_DIR / ".gitignore"
gitignore.write_text("*\n!.gitignore\n", encoding="utf-8")
if not KEY_PATH.exists():
subprocess.run(
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(KEY_PATH)],
check=True,
)
chmod_best_effort(KEY_PATH, 0o600)
chmod_best_effort(pathlib.Path(str(KEY_PATH) + ".pub"), 0o644)
def read_master_passphrase():
value = os.environ.get("NAPSEER_MASTER_PASSPHRASE")
if value:
return value
if not sys.stdin.isatty():
raise RuntimeError("NAPSEER_MASTER_PASSPHRASE is required in non-interactive mode")
first = getpass.getpass("Create gateway master passphrase: ")
second = getpass.getpass("Confirm gateway master passphrase: ")
if first != second:
raise RuntimeError("gateway master passphrases did not match")
return first
def load_gateway_vault_helpers():
local_wrapper = pathlib.Path(__file__).resolve().with_name("napseer_mcp_server.py")
local_spake2 = pathlib.Path(__file__).resolve().with_name("napseer_spake2.py")
namespace = None
if not local_spake2.exists():
remote_spake2 = get_json("/v1/scripts/napseer_spake2.py")
spake2_content = remote_spake2.get("content")
if isinstance(spake2_content, str) and spake2_content.strip():
local_spake2.write_text(spake2_content, encoding="utf-8")
if local_wrapper.exists():
namespace = runpy.run_path(str(local_wrapper))
else:
remote = get_json("/v1/scripts/napseer_mcp_server.py")
content = remote.get("content")
if not isinstance(content, str) or not content.strip():
raise RuntimeError("could not load gateway vault helper")
namespace = {"__file__": str(ROOT / "napseer_mcp_server.py"), "__name__": "napseer_mcp_server_setup_helper"}
exec(compile(content, "napseer_mcp_server.py", "exec"), namespace)
for name in ("write_vault", "write_public_auth"):
if name not in namespace:
raise RuntimeError(f"gateway vault helper is missing {name}")
return namespace
def write_gateway_vault(passphrase, public_auth, secret_auth):
helpers = load_gateway_vault_helpers()
helpers["write_vault"](
passphrase,
{
"version": 1,
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"secrets": {key: value for key, value in secret_auth.items() if value is not None},
},
)
helpers["write_public_auth"]({**public_auth, "gateway_auth_required": True, "vault_path": str(VAULT_PATH)})
def remove_plain_private_key():
try:
if KEY_PATH.exists():
KEY_PATH.unlink()
except OSError as exc:
raise RuntimeError(f"failed to remove plaintext private key at {KEY_PATH}: {exc}") from exc
def chmod_best_effort(path, mode):
try:
path.chmod(mode)
except OSError:
# Windows and some mounted filesystems do not support POSIX modes.
pass
def sign_text(namespace, key_path, text):
with tempfile.TemporaryDirectory() as tmp:
data_path = pathlib.Path(tmp) / "data.txt"
data_path.write_text(text, encoding="utf-8")
subprocess.run(
["ssh-keygen", "-Y", "sign", "-q", "-n", namespace, "-f", str(key_path), str(data_path)],
check=True,
stdout=subprocess.DEVNULL,
)
return pathlib.Path(str(data_path) + ".sig").read_text(encoding="utf-8")
def default_slug():
raw = ROOT.name.lower()
slug = "".join(char if char.isalnum() else "-" for char in raw).strip("-")
return slug or "napseer-project"
def choose_account_mode():
mode = os.environ.get("NAPSEER_ACCOUNT_MODE", "").strip().lower()
if mode in {"anonymous", "login"}:
return mode
if not sys.stdin.isatty():
return "anonymous"
print("Napseer account mode:")
print(" 1. Continue anonymous. Fastest, but recovery depends on local SSH keys.")
print(" 2. Login/claim account. Lets you recover and rotate SSH keys after web authentication.")
choice = input("Choose [1/2] (default 1): ").strip()
return "login" if choice == "2" else "anonymous"
def start_claim_receiver():
result_queue = queue.Queue(maxsize=1)
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
return
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
if parsed.path != "/napseer/claim-callback":
self.send_response(404)
self.end_headers()
return
params = urllib.parse.parse_qs(parsed.query)
result = {
"status": params.get("status", [""])[0],
"account_id": params.get("account_id", [""])[0],
"claim_result_token": params.get("claim_result_token", [""])[0],
}
try:
result_queue.put_nowait(result)
except queue.Full:
pass
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(
b"<html><body><h1>Napseer account connected.</h1><p>You can return to the terminal.</p></body></html>"
)
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return {
"server": server,
"queue": result_queue,
"return_url": f"http://127.0.0.1:{port}/napseer/claim-callback",
}
def wait_for_claim_result(receiver, timeout_seconds):
try:
result = receiver["queue"].get(timeout=timeout_seconds)
except queue.Empty:
return None
finally:
receiver["server"].shutdown()
token = result.get("claim_result_token")
if not token:
return result
verified = get_json(f"/v1/account/claim-results/{urllib.parse.quote(token)}")
result["verified"] = verified
return result
def main():
ensure_local_files()
master_passphrase = read_master_passphrase()
public_key = pathlib.Path(str(KEY_PATH) + ".pub").read_text(encoding="utf-8").strip()
private_key = KEY_PATH.read_text(encoding="utf-8")
project_slug = os.environ.get("NAPSEER_PROJECT_SLUG", default_slug())
project_name = os.environ.get("NAPSEER_PROJECT_NAME", ROOT.name or "Napseer Project")
worker_name = os.environ.get("NAPSEER_WORKER_NAME", f"{project_slug}-agent")
device_fingerprint = os.environ.get("NAPSEER_DEVICE_FINGERPRINT", socket.gethostname())
challenge = post_json(
"/v1/enrollment/challenges",
{
"public_key": public_key,
"worker_name": worker_name,
"device_fingerprint": device_fingerprint,
"root_path": str(ROOT),
"worker_capabilities": {"local_mcp": True, "gateway": True, "setup_script": "napseer_setup.py"},
},
)
signature = sign_text("napseer", KEY_PATH, challenge["challenge_text"])
verified = post_json(
"/v1/enrollment/verify",
{"challenge_id": challenge["challenge_id"], "signature": signature},
)
token = verified["token"]["access_token"]
project = post_json(
"/v1/projects",
{
"slug": project_slug,
"name": project_name,
"description": f"Project initialized from {ROOT}",
},
token=token,
idempotency_key=f"napseer-setup-{project_slug}",
)
signing_key = put_json(
f"/v1/projects/{project['id']}/signing-key",
{"public_key": public_key, "label": "local-project-key"},
token=token,
)
account_mode = choose_account_mode()
account_claim = None
claim_receiver = None
if account_mode == "login":
configured_return_url = os.environ.get("NAPSEER_CLAIM_RETURN_URL")
claim_return_url = configured_return_url
if not claim_return_url and sys.stdin.isatty():
claim_receiver = start_claim_receiver()
claim_return_url = claim_receiver["return_url"]
account_claim = post_json(
"/v1/account/claim-links",
{"return_url": claim_return_url},
token=token,
)
public_auth = {
"base_url": BASE_URL,
"project_id": project["id"],
"project_slug": project["slug"],
"project_name": project["name"],
"project_signing_key_fingerprint": signing_key["fingerprint"],
"worker_id": verified["worker"]["id"],
"agent_id": verified["worker"]["agent_id"],
"worker_name": worker_name,
"device_fingerprint": device_fingerprint,
"root_path": str(ROOT),
"worker_capabilities": {"local_mcp": True, "gateway": True, "setup_script": "napseer_setup.py"},
"account_mode": "claim_pending" if account_claim else "anonymous",
}
secret_auth = {
"token": token,
"token_expires_at": verified["token"].get("expires_at"),
"ssh_private_key": private_key,
"ssh_public_key": public_key,
"gateway_encryption_key": base64.b64encode(secrets.token_bytes(32)).decode("ascii"),
}
if account_claim:
secret_auth["account_claim_url"] = account_claim["claim_url"]
secret_auth["account_claim_expires_at"] = account_claim["expires_at"]
write_gateway_vault(master_passphrase, public_auth, secret_auth)
remove_plain_private_key()
output = {
"status": "ok",
"auth_path": str(AUTH_PATH),
"vault_path": str(VAULT_PATH),
"project_id": project["id"],
"agent_id": verified["worker"]["agent_id"],
"project_signing_key_fingerprint": signing_key["fingerprint"],
"next": [
"Run the local MCP wrapper from this project root.",
"python3 napseer_mcp_server.py",
],
}
if account_claim:
output["account_claim"] = {
"claim_url": account_claim["claim_url"],
"expires_at": account_claim["expires_at"],
"next": "Open this URL in a browser to login and attach this anonymous account.",
}
print(json.dumps(output, indent=2))
if account_claim and claim_receiver:
print("Opening browser for Napseer account claim...", file=sys.stderr)
webbrowser.open(account_claim["claim_url"])
timeout = int(os.environ.get("NAPSEER_CLAIM_WAIT_SECONDS", "180"))
claim_result = wait_for_claim_result(claim_receiver, timeout)
if claim_result:
public_auth["account_mode"] = "claimed"
public_auth["claimed_account_id"] = claim_result.get("account_id")
public_auth["claimed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
secret_auth["claim_result"] = claim_result.get("verified", claim_result)
write_gateway_vault(master_passphrase, public_auth, secret_auth)
print(json.dumps({"status": "claimed", "account_id": public_auth["claimed_account_id"]}, indent=2))
else:
print(
json.dumps(
{
"status": "claim_wait_timeout",
"claim_url": account_claim["claim_url"],
"next": "Open the claim URL manually; the local receiver has stopped.",
},
indent=2,
)
)
if __name__ == "__main__":
main()
+254
View File
@@ -0,0 +1,254 @@
import base64
import hashlib
import hmac
import json
import secrets
P256_P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
P256_N = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551
P256_A = P256_P - 3
P256_B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
P256_G = (
0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296,
0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5,
)
P256_M_COMPRESSED = "02886e2f97ace46e55ba9dd7242579f2993b64e16ef3dcab95afd497333d8fa12f"
P256_N_COMPRESSED = "03d8bbd6c639c62937b04d997f38c3770719c629d7014d49a24b4f98baa1292b49"
SPAKE2_CLIENT_LABEL = "client"
SPAKE2_GATEWAY_LABEL = "gateway"
def canonical_json(value):
return json.dumps(value, sort_keys=True, separators=(",", ":"))
def hkdf_sha256(secret, salt, info, length=32):
if isinstance(salt, str):
salt = salt.encode("utf-8")
if isinstance(info, str):
info = info.encode("utf-8")
prk = hmac.new(salt, secret, hashlib.sha256).digest()
output = b""
previous = b""
counter = 1
while len(output) < length:
previous = hmac.new(prk, previous + info + bytes([counter]), hashlib.sha256).digest()
output += previous
counter += 1
return output[:length]
def int_to_bytes(value, length=32):
return int(value).to_bytes(length, "big")
def bytes_to_int(value):
return int.from_bytes(value, "big")
def modp(value):
return value % P256_P
def invp(value):
return pow(value % P256_P, P256_P - 2, P256_P)
def point_add(left, right):
if left is None:
return right
if right is None:
return left
x1, y1 = left
x2, y2 = right
if x1 == x2 and (y1 + y2) % P256_P == 0:
return None
if left == right:
slope = modp((3 * x1 * x1 + P256_A) * invp(2 * y1))
else:
slope = modp((y2 - y1) * invp(x2 - x1))
x3 = modp(slope * slope - x1 - x2)
return (x3, modp(slope * (x1 - x3) - y1))
def point_negate(point):
if point is None:
return None
return (point[0], (-point[1]) % P256_P)
def scalar_mult(scalar, point):
result = None
addend = point
cursor = scalar % P256_N
while cursor:
if cursor & 1:
result = point_add(result, addend)
addend = point_add(addend, addend)
cursor >>= 1
return result
def decompress_point(hex_value):
raw = bytes.fromhex(hex_value)
if len(raw) != 33 or raw[0] not in (2, 3):
raise RuntimeError("invalid compressed P-256 point encoding")
prefix = raw[0]
x = bytes_to_int(raw[1:])
if x >= P256_P:
raise RuntimeError("compressed P-256 x coordinate out of range")
y2 = modp(x * x * x + P256_A * x + P256_B)
y = pow(y2, (P256_P + 1) // 4, P256_P)
if modp(y * y - y2) != 0:
raise RuntimeError("compressed point is not on P-256")
if (y & 1) != (prefix & 1):
y = (-y) % P256_P
return (x, y)
def encode_point(point):
if point is None:
raise RuntimeError("invalid SPAKE2 point")
return (b"\x04" + int_to_bytes(point[0]) + int_to_bytes(point[1])).hex()
def decode_point(hex_value):
raw = bytes.fromhex(hex_value)
if len(raw) != 65 or raw[0] != 4:
raise RuntimeError("invalid SPAKE2 point encoding")
point = (bytes_to_int(raw[1:33]), bytes_to_int(raw[33:65]))
if point[0] >= P256_P or point[1] >= P256_P:
raise RuntimeError("SPAKE2 point coordinate out of range")
if modp(point[1] * point[1] - (point[0] * point[0] * point[0] + P256_A * point[0] + P256_B)) != 0:
raise RuntimeError("SPAKE2 point is not on P-256")
return point
def random_scalar():
while True:
scalar = bytes_to_int(secrets.token_bytes(32)) % P256_N
if scalar:
return scalar
def password_scalar(secret):
return bytes_to_int(secret) % (P256_N - 1) + 1
def le64(value):
return int(value).to_bytes(8, "little")
def len_prefixed(value):
return le64(len(value)) + value
def _spake2_finish_b_with_labels(secret, client_label, gateway_label, aad, browser_message, scalar_y=None):
w = password_scalar(secret)
y = scalar_y or random_scalar()
p_a = decode_point(browser_message)
p_b = point_add(scalar_mult(y, P256_G), scalar_mult(w, decompress_point(P256_N_COMPRESSED)))
k = scalar_mult(y, point_add(p_a, point_negate(scalar_mult(w, decompress_point(P256_M_COMPRESSED)))))
if k is None:
raise RuntimeError("invalid SPAKE2 shared point")
tt = b"".join([
len_prefixed(client_label.encode("utf-8")),
len_prefixed(gateway_label.encode("utf-8")),
len_prefixed(bytes.fromhex(browser_message)),
len_prefixed(bytes.fromhex(encode_point(p_b))),
len_prefixed(bytes.fromhex(encode_point(k))),
len_prefixed(int_to_bytes(w)),
])
digest = hashlib.sha256(tt).digest()
ke, ka = digest[:16], digest[16:]
confirmation_keys = hkdf_sha256(ka, b"", b"ConfirmationKeys" + aad, 32)
kc_a, kc_b = confirmation_keys[:16], confirmation_keys[16:]
return {
"message": encode_point(p_b),
"confirmation": base64.b64encode(hmac.new(kc_b, tt, hashlib.sha256).digest()).decode("ascii"),
"expected_client_confirmation": base64.b64encode(hmac.new(kc_a, tt, hashlib.sha256).digest()).decode("ascii"),
"secret": ke,
"transcript_hash": digest.hex(),
}
def spake2_finish_b(secret, aad, browser_message, scalar_y=None):
return _spake2_finish_b_with_labels(
secret,
SPAKE2_CLIENT_LABEL,
SPAKE2_GATEWAY_LABEL,
aad,
browser_message,
scalar_y=scalar_y,
)
def spake2_gateway_finish(secret, spake2_context, browser_message):
return spake2_finish_b(
secret,
canonical_json(spake2_context).encode("utf-8"),
browser_message,
)
def relay_key_from_secret(secret, spake2_context, direction):
context = canonical_json(spake2_context)
salt = hashlib.sha256(context.encode("utf-8")).digest()
return hkdf_sha256(secret, salt, f"napseer-relay-aes-gcm:{direction}")
def relay_context_hash(spake2_context):
return base64.b64encode(hashlib.sha256(canonical_json(spake2_context).encode("utf-8")).digest()).decode("ascii")
def relay_aad(session_id, context_hash, direction, seq):
return canonical_json({
"context_hash": context_hash,
"direction": direction,
"seq": seq,
"session_id": session_id,
}).encode("utf-8")
def _self_test():
password = int_to_bytes(
int("2ee57912099d31560b3a44b1184b9b4866e904c49d12ac5042c97dca461b1a5f", 16) - 1
)
message_a = (
"04a56fa807caaa53a4d28dbb9853b9815c61a411118a6fe516a8798434751470"
"f9010153ac33d0d5f2047ffdb1a3e42c9b4e6be662766e1eeb4116988ede5f912c"
)
scalar_y = int("dcb60106f276b02606d8ef0a328c02e4b629f84f89786af5befb0bc75b6e66be", 16)
vector = _spake2_finish_b_with_labels(
password,
"server",
"client",
b"",
message_a,
scalar_y=scalar_y,
)
assert vector["message"] == (
"0406557e482bd03097ad0cbaa5df82115460d951e3451962f1eaf4367a420676"
"d09857ccbc522686c83d1852abfa8ed6e4a1155cf8f1543ceca528afb591a1e0b7"
)
assert vector["transcript_hash"] == "0e0672dc86f8e45565d338b0540abe6915bdf72e2b35b5c9e5663168e960a91b"
assert base64.b64decode(vector["expected_client_confirmation"]).hex() == (
"58ad4aa88e0b60d5061eb6b5dd93e80d9c4f00d127c65b3b35b1b5281fee38f0"
)
assert base64.b64decode(vector["confirmation"]).hex() == (
"d3e2e547f1ae04f2dbdbf0fc4b79f8ecff2dff314b5d32fe9fcef2fb26dc459b"
)
changed_aad = _spake2_finish_b_with_labels(password, "server", "client", b"context", message_a, scalar_y=scalar_y)
assert changed_aad["transcript_hash"] == vector["transcript_hash"]
assert changed_aad["confirmation"] != vector["confirmation"]
try:
decode_point("04" + ("00" * 64))
except RuntimeError:
pass
else:
raise AssertionError("invalid point was accepted")
if __name__ == "__main__":
_self_test()
+53
View File
@@ -0,0 +1,53 @@
"""Isolated terminal primitives for the gateway remote terminal path."""
from .protocol import (
TERMINAL_CLOSE,
TERMINAL_DATA,
TERMINAL_DETACH,
TERMINAL_ERROR,
TERMINAL_OPEN,
TERMINAL_OPENED,
TERMINAL_OUTPUT,
TERMINAL_REPLAY_GAP,
TERMINAL_RESIZE,
)
from .pty_manager import (
AttachResult,
OutputChunk,
PtySessionManager,
TerminalInfo,
TerminalSession,
close,
default_manager,
list,
open,
resize,
subscribe,
write,
attach,
)
__all__ = [
"TERMINAL_CLOSE",
"TERMINAL_DATA",
"TERMINAL_DETACH",
"TERMINAL_ERROR",
"TERMINAL_OPEN",
"TERMINAL_OPENED",
"TERMINAL_OUTPUT",
"TERMINAL_REPLAY_GAP",
"TERMINAL_RESIZE",
"AttachResult",
"OutputChunk",
"PtySessionManager",
"TerminalInfo",
"TerminalSession",
"attach",
"close",
"default_manager",
"list",
"open",
"resize",
"subscribe",
"write",
]
+97
View File
@@ -0,0 +1,97 @@
"""Terminal protocol constants and small frame helpers.
The relay adapter will encrypt these payloads before sending them over the
gateway relay. Keep this module content-only: no terminal output logging and no
network side effects.
"""
from __future__ import annotations
import base64
from datetime import datetime, timezone
from typing import Any
TERMINAL_OPEN = "terminal.open"
TERMINAL_OPENED = "terminal.opened"
TERMINAL_DATA = "terminal.data"
TERMINAL_OUTPUT = "terminal.output"
TERMINAL_RESIZE = "terminal.resize"
TERMINAL_DETACH = "terminal.detach"
TERMINAL_CLOSE = "terminal.close"
TERMINAL_REPLAY_GAP = "terminal.replay_gap"
TERMINAL_ERROR = "terminal.error"
def utc_timestamp() -> str:
return datetime.now(timezone.utc).isoformat()
def encode_bytes(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def decode_bytes(value: str) -> bytes:
return base64.b64decode(value.encode("ascii"), validate=True)
def output_frame(terminal_id: str, seq: int, data: bytes) -> dict[str, Any]:
return {
"type": TERMINAL_OUTPUT,
"terminal_id": terminal_id,
"seq": seq,
"data_b64": encode_bytes(data),
"gateway_output_sent_at": utc_timestamp(),
}
def opened_frame(
terminal_id: str,
*,
rows: int,
cols: int,
output_seq: int = 0,
attached: bool = True,
pid: int | None = None,
) -> dict[str, Any]:
frame: dict[str, Any] = {
"type": TERMINAL_OPENED,
"terminal_id": terminal_id,
"rows": rows,
"cols": cols,
"output_seq": output_seq,
"attached": attached,
}
if pid is not None:
frame["pid"] = pid
return frame
def replay_gap_frame(
terminal_id: str,
*,
requested_after_seq: int | None,
oldest_seq: int | None,
newest_seq: int,
) -> dict[str, Any]:
return {
"type": TERMINAL_REPLAY_GAP,
"terminal_id": terminal_id,
"requested_after_seq": requested_after_seq,
"oldest_seq": oldest_seq,
"newest_seq": newest_seq,
}
def error_frame(
terminal_id: str | None,
code: str,
message: str,
) -> dict[str, Any]:
frame: dict[str, Any] = {
"type": TERMINAL_ERROR,
"code": code,
"message": message,
}
if terminal_id:
frame["terminal_id"] = terminal_id
return frame
+426
View File
@@ -0,0 +1,426 @@
"""Stdlib PTY session manager for the gateway terminal vertical slice."""
from __future__ import annotations
import errno
import fcntl
import os
import select
import shlex
import signal
import struct
import subprocess
import termios
import threading
import time
import uuid
from collections import deque
from dataclasses import dataclass
from typing import Callable, Deque, Iterable
OutputCallback = Callable[[str, int, bytes], None]
UnsubscribeCallback = Callable[[], None]
@dataclass(frozen=True)
class OutputChunk:
seq: int
data: bytes
ts: float
@dataclass(frozen=True)
class AttachResult:
terminal_id: str
chunks: tuple[OutputChunk, ...]
replay_gap: bool
requested_after_seq: int | None
oldest_seq: int | None
newest_seq: int
@dataclass(frozen=True)
class TerminalInfo:
terminal_id: str
pid: int
command: tuple[str, ...]
cwd: str
rows: int
cols: int
output_seq: int
oldest_output_seq: int | None
created_at: float
last_activity: float
closed: bool
exit_code: int | None
class TerminalSession:
def __init__(
self,
*,
terminal_id: str,
process: subprocess.Popen[bytes],
master_fd: int,
command: tuple[str, ...],
cwd: str,
rows: int,
cols: int,
ring_chunks: int,
output_callback: OutputCallback | None,
) -> None:
self.terminal_id = terminal_id
self.process = process
self.master_fd = master_fd
self.command = command
self.cwd = cwd
self.rows = rows
self.cols = cols
self.created_at = time.time()
self.last_activity = self.created_at
self.output_seq = 0
self.last_input_seq: int | None = None
self.closed = False
self.exit_code: int | None = None
self._ring: Deque[OutputChunk] = deque(maxlen=ring_chunks)
self._callbacks: dict[str, OutputCallback] = {}
self._lock = threading.RLock()
self._stop = threading.Event()
self._output_callback = output_callback
self._reader = threading.Thread(
target=self._read_loop,
name=f"napseer-terminal-{terminal_id}",
daemon=True,
)
self._reader.start()
def write(self, data: bytes, input_seq: int | None = None) -> bool:
if not isinstance(data, bytes):
raise TypeError("terminal input data must be bytes")
if not data:
return False
with self._lock:
self._ensure_open()
if input_seq is not None:
if self.last_input_seq is not None and input_seq <= self.last_input_seq:
return False
self.last_input_seq = input_seq
self.last_activity = time.time()
fd = self.master_fd
self._write_all(fd, data)
return True
def resize(self, rows: int, cols: int) -> None:
rows, cols = _normalize_size(rows, cols)
with self._lock:
self._ensure_open()
self.rows = rows
self.cols = cols
self.last_activity = time.time()
fd = self.master_fd
_set_winsize(fd, rows, cols)
try:
os.killpg(os.getpgid(self.process.pid), signal.SIGWINCH)
except ProcessLookupError:
pass
def attach(self, last_output_seq: int | None = None) -> AttachResult:
with self._lock:
chunks = tuple(self._ring)
newest_seq = self.output_seq
oldest_seq = chunks[0].seq if chunks else None
replay_gap = False
if last_output_seq is None:
selected = chunks
elif oldest_seq is not None and last_output_seq < oldest_seq - 1:
selected = chunks
replay_gap = True
else:
selected = tuple(chunk for chunk in chunks if chunk.seq > last_output_seq)
self.last_activity = time.time()
return AttachResult(
terminal_id=self.terminal_id,
chunks=selected,
replay_gap=replay_gap,
requested_after_seq=last_output_seq,
oldest_seq=oldest_seq,
newest_seq=newest_seq,
)
def close(self) -> None:
process: subprocess.Popen[bytes]
fd: int
with self._lock:
if self.closed:
return
self.closed = True
self.last_activity = time.time()
process = self.process
fd = self.master_fd
self._stop.set()
try:
os.close(fd)
except OSError:
pass
if process.poll() is None:
try:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
except ProcessLookupError:
pass
try:
process.wait(timeout=2.0)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except ProcessLookupError:
pass
process.wait(timeout=2.0)
with self._lock:
self.exit_code = process.poll()
def info(self) -> TerminalInfo:
with self._lock:
oldest_seq = self._ring[0].seq if self._ring else None
exit_code = self.process.poll()
if exit_code is not None:
self.exit_code = exit_code
return TerminalInfo(
terminal_id=self.terminal_id,
pid=self.process.pid,
command=self.command,
cwd=self.cwd,
rows=self.rows,
cols=self.cols,
output_seq=self.output_seq,
oldest_output_seq=oldest_seq,
created_at=self.created_at,
last_activity=self.last_activity,
closed=self.closed,
exit_code=self.exit_code,
)
def _read_loop(self) -> None:
while not self._stop.is_set():
try:
ready, _, _ = select.select([self.master_fd], [], [], 0.05)
except (OSError, ValueError):
break
if not ready:
if self.process.poll() is not None:
break
continue
try:
data = os.read(self.master_fd, 8192)
except OSError as exc:
if exc.errno in (errno.EIO, errno.EBADF):
break
continue
if not data:
break
with self._lock:
self.output_seq += 1
seq = self.output_seq
chunk = OutputChunk(seq=seq, data=data, ts=time.time())
self._ring.append(chunk)
self.last_activity = chunk.ts
callback = self._output_callback
callbacks = tuple(self._callbacks.values())
if callback is not None:
callback(self.terminal_id, seq, data)
for listener in callbacks:
listener(self.terminal_id, seq, data)
with self._lock:
self.exit_code = self.process.poll()
self.closed = True
self._stop.set()
def _ensure_open(self) -> None:
if self.closed or self.process.poll() is not None:
self.closed = True
self.exit_code = self.process.poll()
raise RuntimeError(f"terminal {self.terminal_id} is closed")
def subscribe(self, callback: OutputCallback) -> UnsubscribeCallback:
subscription_id = uuid.uuid4().hex
with self._lock:
self._callbacks[subscription_id] = callback
def unsubscribe() -> None:
with self._lock:
self._callbacks.pop(subscription_id, None)
return unsubscribe
@staticmethod
def _write_all(fd: int, data: bytes) -> None:
view = memoryview(data)
while view:
try:
written = os.write(fd, view)
except BlockingIOError:
select.select([], [fd], [], 1.0)
continue
view = view[written:]
class PtySessionManager:
def __init__(
self,
*,
ring_chunks: int = 512,
output_callback: OutputCallback | None = None,
) -> None:
if ring_chunks < 1:
raise ValueError("ring_chunks must be positive")
self._ring_chunks = ring_chunks
self._output_callback = output_callback
self._sessions: dict[str, TerminalSession] = {}
self._lock = threading.RLock()
def open(
self,
command: str | Iterable[str] | None = None,
cwd: str | None = None,
rows: int = 24,
cols: int = 80,
) -> TerminalInfo:
rows, cols = _normalize_size(rows, cols)
command_tuple = _normalize_command(command)
cwd_path = os.path.abspath(cwd or os.getcwd())
if not os.path.isdir(cwd_path):
raise FileNotFoundError(f"terminal cwd does not exist: {cwd_path}")
master_fd, slave_fd = os.openpty()
try:
_set_winsize(slave_fd, rows, cols)
process = subprocess.Popen(
command_tuple,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
cwd=cwd_path,
close_fds=True,
start_new_session=True,
)
finally:
os.close(slave_fd)
_set_nonblocking(master_fd)
terminal_id = f"term_{uuid.uuid4().hex}"
session = TerminalSession(
terminal_id=terminal_id,
process=process,
master_fd=master_fd,
command=command_tuple,
cwd=cwd_path,
rows=rows,
cols=cols,
ring_chunks=self._ring_chunks,
output_callback=self._output_callback,
)
with self._lock:
self._sessions[terminal_id] = session
return session.info()
def write(self, terminal_id: str, data: bytes, input_seq: int | None = None) -> bool:
return self._get(terminal_id).write(data, input_seq=input_seq)
def resize(self, terminal_id: str, rows: int, cols: int) -> TerminalInfo:
session = self._get(terminal_id)
session.resize(rows, cols)
return session.info()
def attach(
self,
terminal_id: str,
last_output_seq: int | None = None,
) -> AttachResult:
return self._get(terminal_id).attach(last_output_seq=last_output_seq)
def subscribe(self, terminal_id: str, callback: OutputCallback) -> UnsubscribeCallback:
return self._get(terminal_id).subscribe(callback)
def close(self, terminal_id: str) -> None:
session = self._get(terminal_id)
session.close()
with self._lock:
self._sessions.pop(terminal_id, None)
def list(self) -> list[TerminalInfo]:
with self._lock:
sessions = tuple(self._sessions.values())
return [session.info() for session in sessions]
def _get(self, terminal_id: str) -> TerminalSession:
with self._lock:
session = self._sessions.get(terminal_id)
if session is None:
raise KeyError(f"unknown terminal: {terminal_id}")
return session
def _normalize_command(command: str | Iterable[str] | None) -> tuple[str, ...]:
if command is None:
shell = os.environ.get("SHELL") or "/bin/sh"
return (shell,)
if isinstance(command, str):
return tuple(shlex.split(command))
command_tuple = tuple(command)
if not command_tuple:
raise ValueError("terminal command cannot be empty")
return command_tuple
def _normalize_size(rows: int, cols: int) -> tuple[int, int]:
rows = int(rows)
cols = int(cols)
if rows < 1 or cols < 1:
raise ValueError("terminal rows and cols must be positive")
return rows, cols
def _set_winsize(fd: int, rows: int, cols: int) -> None:
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
def _set_nonblocking(fd: int) -> None:
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
default_manager = PtySessionManager()
def open(
command: str | Iterable[str] | None = None,
cwd: str | None = None,
rows: int = 24,
cols: int = 80,
) -> TerminalInfo:
return default_manager.open(command=command, cwd=cwd, rows=rows, cols=cols)
def write(terminal_id: str, data: bytes, input_seq: int | None = None) -> bool:
return default_manager.write(terminal_id, data, input_seq=input_seq)
def resize(terminal_id: str, rows: int, cols: int) -> TerminalInfo:
return default_manager.resize(terminal_id, rows, cols)
def attach(terminal_id: str, last_output_seq: int | None = None) -> AttachResult:
return default_manager.attach(terminal_id, last_output_seq=last_output_seq)
def subscribe(terminal_id: str, callback: OutputCallback) -> UnsubscribeCallback:
return default_manager.subscribe(terminal_id, callback)
def close(terminal_id: str) -> None:
default_manager.close(terminal_id)
def list() -> list[TerminalInfo]:
return default_manager.list()
+684
View File
@@ -0,0 +1,684 @@
#!/usr/bin/env python3
"""Install the Napseer local command.
Usage:
python3 nap_install.py
nap project create
nap project status
nap gateway start
nap gateway stop
nap gateway configure
nap gateway logs
nap status
nap agent list
nap update
"""
import json
import os
import pathlib
import shutil
import signal
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BASE_URL = os.environ.get("NAPSEER_BASE_URL", "https://api.napseer.com").rstrip("/")
INSTALL_DIR = pathlib.Path(os.environ.get("NAPSEER_HOME", pathlib.Path.home() / ".local" / "share" / "napseer"))
BIN_DIR = pathlib.Path(os.environ.get("NAPSEER_BIN_DIR", pathlib.Path.home() / ".local" / "bin"))
SCRIPT_NAMES = (
"napseer_mcp_server.py",
"napseer_spake2.py",
"terminal_init.py",
"terminal_protocol.py",
"terminal_pty_manager.py",
"nap_install.py",
)
HTTP_TIMEOUT_SECONDS = int(os.environ.get("NAPSEER_HTTP_TIMEOUT_SECONDS", "30"))
LOCAL_HOST = "127.0.0.1"
DEFAULT_GATEWAY_PORT = os.environ.get("NAPSEER_GATEWAY_PORT")
RUNTIME_SCRIPT_NAMES = (
"napseer_spake2.py",
"terminal_init.py",
"terminal_protocol.py",
"terminal_pty_manager.py",
"napseer_mcp_server.py",
)
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-05-03"
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")
def chmod_best_effort(path, mode):
try:
path.chmod(mode)
except OSError:
pass
def normalize_proxy_url(value):
if not value:
return None
proxy_url = str(value).strip()
if not proxy_url:
return None
if "://" not in proxy_url:
proxy_url = f"http://{proxy_url}"
return urllib.parse.urlparse(proxy_url)
def api_proxy_url(parsed):
host = parsed.hostname or ""
if host and urllib.request.proxy_bypass(host):
return None
explicit = (
os.environ.get("NAPSEER_API_PROXY_URL")
or os.environ.get("NAPSEER_HTTP_PROXY_URL")
or os.environ.get("NAPSEER_PROXY_URL")
or os.environ.get("NAPSEER_RELAY_PROXY_URL")
or os.environ.get("NAPSEER_GATEWAY_PROXY_URL")
)
if explicit:
return explicit
proxies = urllib.request.getproxies_environment()
return proxies.get(parsed.scheme) or proxies.get("all")
def api_urlopen(request, timeout):
parsed = urllib.parse.urlparse(request.full_url)
proxy = normalize_proxy_url(api_proxy_url(parsed))
if not proxy:
return urllib.request.urlopen(request, timeout=timeout)
proxy_url = urllib.parse.urlunparse(proxy)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({parsed.scheme: proxy_url}))
return opener.open(request, timeout=timeout)
def fetch_script(name):
request = urllib.request.Request(
f"{BASE_URL}/v1/scripts/{name}",
headers={"User-Agent": "nap-install-python/0.1"},
method="GET",
)
try:
with api_urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as response:
payload = json.loads(response.read().decode("utf-8"))
except (TimeoutError, socket.timeout) as exc:
raise RuntimeError(f"GET /v1/scripts/{name} timed out after {HTTP_TIMEOUT_SECONDS}s") from exc
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8")
raise RuntimeError(f"GET /v1/scripts/{name} failed: HTTP {exc.code}: {body}") from exc
content = payload.get("content")
if not isinstance(content, str) or not content.strip():
raise RuntimeError(f"{name} response did not include script content")
return payload, content
def write_file(path, content, executable=False):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
chmod_best_effort(path, 0o755 if executable else 0o644)
def path_warning():
path_entries = os.environ.get("PATH", "").split(os.pathsep)
if str(BIN_DIR) not in path_entries:
return f"Add {BIN_DIR} to PATH to run nap from any directory."
return None
def source_revision_status_for(value):
if not isinstance(value, str):
return "unresolved"
revision = value.strip()
if not revision or revision in ("unresolved", "unknown", "null"):
return "unresolved"
return "resolved"
def install_assets(update_project=False):
scripts = {}
fetched = {}
for name in SCRIPT_NAMES:
payload, content = fetch_script(name)
fetched[name] = (payload, content)
install_order = []
for name in (*RUNTIME_SCRIPT_NAMES, "nap_install.py", *SCRIPT_NAMES):
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)
if os.name == "nt":
write_file(BIN_DIR / "nap.cmd", f'@echo off\r\n"{sys.executable}" "{nap_path}" %*\r\n', executable=False)
updated_project_paths = []
if update_project:
for script_name in ("napseer_mcp_server.py", "napseer_spake2.py"):
content = (INSTALL_DIR / script_name).read_text(encoding="utf-8")
for candidate in (
pathlib.Path.cwd() / script_name,
pathlib.Path.cwd() / "resources" / "scripts" / script_name,
):
if candidate.exists():
write_file(candidate, content, executable=script_name.endswith(".py"))
updated_project_paths.append(str(candidate))
result = {
"status": "installed",
"message": "Napseer CLI and runtime scripts are installed.",
"bin": str(nap_path),
"install_dir": str(INSTALL_DIR),
"scripts": scripts,
"cli_distribution": {
"contract_version": CLI_DISTRIBUTION_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)
if CLI_GENERATED_SOURCE_REVISION_STATUS == "unresolved"
else CLI_GENERATED_SOURCE_REVISION_STATUS,
},
"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"],
}
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)
if not path.exists() or path.stat().st_size == 0:
missing.append(name)
return missing
def ensure_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)}")
def run_wrapper(command_name, args):
wrapper = INSTALL_DIR / "napseer_mcp_server.py"
ensure_runtime_assets()
return subprocess.call([sys.executable, str(wrapper), command_name, *args])
def preferred_state_dir():
return pathlib.Path.cwd() / ".napseer"
def legacy_state_dir():
return pathlib.Path.cwd() / "napseer"
def cwd_state_dir():
preferred = preferred_state_dir()
legacy = legacy_state_dir()
path = preferred if preferred.exists() or not legacy.exists() else legacy
path.mkdir(exist_ok=True)
chmod_best_effort(path, 0o700)
return path
def state_dir_status():
preferred = preferred_state_dir()
legacy = legacy_state_dir()
active = cwd_state_dir()
if preferred.exists() and legacy.exists():
message = "Both .napseer and legacy napseer directories exist; .napseer is active."
elif active == legacy:
message = "Legacy napseer directory is active because .napseer does not exist."
else:
message = ".napseer directory is active."
return {
"active_state_dir": str(active),
"active_auth_path": str(active / "auth.json"),
"preferred_state_dir": str(preferred),
"preferred_auth_path": str(preferred / "auth.json"),
"legacy_state_dir": str(legacy),
"legacy_auth_path": str(legacy / "auth.json"),
"preferred_state_dir_exists": preferred.exists(),
"legacy_state_dir_exists": legacy.exists(),
"legacy_state_dir_active": active == legacy,
"state_dir_message": message,
}
def service_state_path(kind):
return cwd_state_dir() / f"{kind}.json"
def service_log_path(kind):
return cwd_state_dir() / f"{kind}.log"
def read_service_state(kind):
path = service_state_path(kind)
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def write_service_state(kind, state):
path = service_state_path(kind)
write_file(path, json.dumps(state, indent=2), executable=False)
chmod_best_effort(path, 0o600)
def pid_running(pid):
if not pid:
return False
proc_stat = pathlib.Path("/proc") / str(pid) / "stat"
try:
fields = proc_stat.read_text(encoding="utf-8").split()
if len(fields) > 2 and fields[2] == "Z":
return False
except Exception:
pass
try:
os.kill(int(pid), 0)
return True
except OSError:
return False
def find_free_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((LOCAL_HOST, 0))
return int(sock.getsockname()[1])
def port_accepts_connections(port):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.2)
return sock.connect_ex((LOCAL_HOST, int(port))) == 0
def port_can_bind(port):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((LOCAL_HOST, int(port)))
return True
except OSError:
return False
def local_gateway_status(port):
request = urllib.request.Request(
f"http://{LOCAL_HOST}:{int(port)}/gateway/status",
headers={"User-Agent": "nap-cli-python/0.1"},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=1) as response:
return json.loads(response.read().decode("utf-8"))
except Exception:
return None
def log_tail(path, max_chars=2000):
try:
text = pathlib.Path(path).read_text(encoding="utf-8", errors="replace")
except Exception:
return ""
return text[-max_chars:]
def log_tail_lines(path, max_lines=120):
try:
lines = pathlib.Path(path).read_text(encoding="utf-8", errors="replace").splitlines()
except FileNotFoundError:
return []
except Exception as exc:
return [f"failed to read log: {exc}"]
return lines[-max(1, max_lines):]
def compact_log_file(path, max_bytes=MAX_SERVICE_LOG_BYTES):
if max_bytes <= 0:
return
file_path = pathlib.Path(path)
try:
size = file_path.stat().st_size
except FileNotFoundError:
return
except OSError:
return
if size <= max_bytes:
return
try:
with file_path.open("rb") as handle:
handle.seek(-max_bytes, os.SEEK_END)
chunk = handle.read(max_bytes)
except OSError:
return
# Try to start on a newline boundary so the log stays line-oriented.
newline_index = chunk.find(b"\n")
if newline_index >= 0 and newline_index + 1 < len(chunk):
chunk = chunk[newline_index + 1 :]
tmp_path = file_path.with_suffix(file_path.suffix + ".tmp")
try:
tmp_path.write_bytes(chunk)
tmp_path.replace(file_path)
except OSError:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
def ensure_port_available(kind, port):
if not port_accepts_connections(port) and port_can_bind(port):
return
status = local_gateway_status(port)
if status:
cwd = status.get("cwd") or "unknown cwd"
project_id = status.get("project_id") or "no project"
raise RuntimeError(
f"{LOCAL_HOST}:{port} is already serving a Napseer gateway for {cwd} "
f"(project {project_id}). Stop that gateway before starting this one."
)
raise RuntimeError(f"{LOCAL_HOST}:{port} is already in use or still being released")
def choose_service_port(kind, args, existing=None):
requested = cli_option(args, "--port", default=None)
if requested is not None:
return int(requested)
if kind == "gateway":
if DEFAULT_GATEWAY_PORT:
return int(DEFAULT_GATEWAY_PORT)
existing_port = (existing or {}).get("port")
if existing_port and port_can_bind(existing_port):
return int(existing_port)
return find_free_port()
return find_free_port()
def wait_for_service(kind, process, port, log_path):
deadline = time.time() + 3
while time.time() < deadline:
if process.poll() is not None:
tail = log_tail(log_path)
detail = f": {tail.strip()}" if tail.strip() else ""
raise RuntimeError(f"{kind} service exited during startup{detail}")
if kind == "gateway" and local_gateway_status(port):
return
if kind != "gateway" and port_accepts_connections(port):
return
time.sleep(0.1)
raise RuntimeError(f"{kind} service did not start listening on {LOCAL_HOST}:{port}")
def cli_option(args, *names, default=None):
for name in names:
if name in args:
index = args.index(name)
if index + 1 >= len(args):
raise RuntimeError(f"{name} requires a value")
return args[index + 1]
return default
def start_service(kind, args):
wrapper = INSTALL_DIR / "napseer_mcp_server.py"
ensure_runtime_assets()
existing = read_service_state(kind)
if existing and pid_running(existing.get("pid")):
return {
**existing,
"status": "running",
"message": f"{kind} service is already running.",
}
port = choose_service_port(kind, args, existing)
ensure_port_available(kind, port)
log_path = service_log_path(kind)
compact_log_file(log_path)
command = [sys.executable, str(wrapper), "open-ui", "--port", str(port), "--no-browser"]
if kind == "ui" and "--no-browser" not in args:
command.remove("--no-browser")
with log_path.open("ab") as log:
kwargs = {"stdout": log, "stderr": subprocess.STDOUT, "cwd": str(pathlib.Path.cwd())}
if os.name != "nt":
kwargs["start_new_session"] = True
else:
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
process = subprocess.Popen(command, **kwargs)
wait_for_service(kind, process, port, log_path)
state = {
"status": "running",
"kind": kind,
"pid": process.pid,
"host": LOCAL_HOST,
"port": port,
"url": f"http://{LOCAL_HOST}:{port}/",
"cwd": str(pathlib.Path.cwd()),
"log_path": str(log_path),
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
write_service_state(kind, state)
return {**state, "message": f"{kind} service started."}
def stop_service(kind):
state = read_service_state(kind)
if not state:
return {"status": "stopped", "kind": kind}
pid = state.get("pid")
if pid_running(pid):
if os.name == "nt":
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
try:
os.killpg(int(pid), signal.SIGTERM)
except OSError:
try:
os.kill(int(pid), signal.SIGTERM)
except OSError:
pass
deadline = time.time() + 5
while time.time() < deadline and pid_running(pid):
time.sleep(0.1)
if pid_running(pid):
if os.name == "nt":
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
try:
os.killpg(int(pid), signal.SIGKILL)
except OSError:
try:
os.kill(int(pid), signal.SIGKILL)
except OSError:
pass
deadline = time.time() + 2
while time.time() < deadline and pid_running(pid):
time.sleep(0.1)
port = state.get("port")
if port:
deadline = time.time() + 2
while time.time() < deadline and not port_can_bind(port):
time.sleep(0.1)
stopped = not pid_running(pid) and (not port or port_can_bind(port))
state = {**state, "status": "stopped" if stopped else "stopping"}
write_service_state(kind, state)
if not stopped:
state["warning"] = f"{kind} process or port {port} is still being released"
state["message"] = state["warning"]
else:
state["message"] = f"{kind} service stopped."
return state
def list_service(kind):
state = read_service_state(kind) or {
"kind": kind,
"host": LOCAL_HOST,
"port": None,
"url": None,
"cwd": str(pathlib.Path.cwd()),
}
state["status"] = "running" if pid_running(state.get("pid")) else "stopped"
state["message"] = f"{kind} service is {state['status']}."
return state
def service_logs(kind, args):
max_lines = int(cli_option(args, "--lines", "--tail", default="120"))
path = service_log_path(kind)
compact_log_file(path)
lines = log_tail_lines(path, max_lines=max_lines)
status = "ok" if path.exists() else "missing"
return {
"status": status,
"message": f"{kind} log {'read' if path.exists() else 'does not exist yet'}.",
"kind": kind,
"log_path": str(path),
"line_count": len(lines),
"lines": lines,
"text": "\n".join(lines),
}
def handle_gateway(args):
subcommand = args[0] if args else "ls"
rest = args[1:] if args else []
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 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 {"configure", "config"}:
return run_wrapper("gateway", ["configure", *rest])
if subcommand in {"setup", "unlock", "lock", "restart", "kill"}:
return run_wrapper("gateway", [subcommand, *rest])
raise RuntimeError("unknown gateway command; use nap gateway start|stop|configure|setup|unlock|lock|restart|kill|logs|status")
def print_json(value):
print(json.dumps(value, indent=2))
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 == "install":
print_json(install_assets(update_project=False))
return 0
if command == "update":
result = install_assets(update_project=True)
print_json({**result, "status": "updated", "message": "Napseer CLI and runtime scripts are updated."})
return 0
if command == "bootstrap":
raise RuntimeError("nap bootstrap was removed; use nap project create")
if command == "ui":
raise RuntimeError("top-level nap ui was removed; use project and gateway commands instead")
if command == "gateway":
return handle_gateway(args)
if command in {"configure", "config", "status"}:
return run_wrapper("configure", args)
if command == "project":
if not args:
return run_wrapper("project", ["status"])
if args and args[0] == "bootstrap":
raise RuntimeError("nap project bootstrap was removed; use nap project create")
return run_wrapper("project", args)
if command == "create":
raise RuntimeError("top-level nap create was removed; use nap project create")
if command == "agent":
return run_wrapper("agent", args)
if command == "auth":
return run_wrapper("auth", args)
if command == "feedback":
return run_wrapper("feedback", 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"),
"cwd": str(pathlib.Path.cwd()),
"cwd_auth_path": state["active_auth_path"],
"cwd_auth_configured": pathlib.Path(state["active_auth_path"]).exists(),
**state,
"path_warning": path_warning(),
"status": "ok",
"message": f"Napseer install and cwd paths resolved. {state['state_dir_message']}",
}
)
return 0
if command in {"help", "-h", "--help"}:
print("Usage: nap [auth|project|gateway|agent|feedback|status|update|where|install]")
print(" auth Auth commands: login-local, status.")
print(" Example: nap auth login-local")
print(" project Project commands: create, status, encryption, plaintext, encrypted.")
print(" Examples: nap project create, nap project status, nap project encryption set plaintext")
print(" gateway Gateway commands: start, stop, configure, setup, unlock, lock, restart, kill, logs, status.")
print(" Start auto-selects a local port; use --port to pin one.")
print(" Examples: nap gateway start, nap gateway start --port 8767, nap gateway logs")
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}")
if __name__ == "__main__":
try:
raise SystemExit(main(sys.argv))
except Exception as exc:
print(json.dumps({"error": str(exc)}, indent=2), file=sys.stderr)
raise SystemExit(1)
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Smoke-test parity between CLI and MCP gateway configure bootstrap flows."""
import importlib.util
import os
import pathlib
import sys
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
sys.path.insert(0, str(script_path.parent))
spec = importlib.util.spec_from_file_location("napseer_mcp_server", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def assert_equal(actual, expected, message):
if actual != expected:
raise AssertionError(f"{message}: expected={expected!r} actual={actual!r}")
def run():
mod = load_module()
calls = {"preregister": [], "setup": [], "unlock": [], "tmux": []}
mod.AUTH = {}
mod.TOKEN = None
mod.load_public_auth_file = lambda: {}
mod.vault_exists = lambda: False
mod.gateway_is_unlocked = lambda: False
mod.gateway_setup = lambda payload: calls["setup"].append(payload) or {"status": "configured"}
mod.gateway_unlock = lambda passphrase: calls["unlock"].append(passphrase) or {"status": "unlocked"}
mod.gateway_tmux_configure = lambda payload: calls["tmux"].append(payload) or {"status": "configured"}
mod.gateway_status = lambda: {"status": "ok"}
mod.gateway_log = lambda *args, **kwargs: None
mod.ensure_container_uuid = lambda: "2a164e32-9d98-4b4e-9ad8-eed7307a5b08"
mod.normalize_gateway_command = lambda: "bash"
mod.gateway_bootstrap_token_invalid_result = lambda: {"status": "bootstrap_token_invalid"}
def preregister(payload):
calls["preregister"].append(payload)
return {"status": "pending_review", "registration": {"id": "reg_1"}}
mod.gateway_service_preregister = preregister
os.environ["NAPSEER_GATEWAY_BOOTSTRAP_TOKEN"] = "npb_env"
os.environ["NAPSEER_GATEWAY_PASSPHRASE"] = "pw_env"
mcp_result = mod.mcp_gateway_configure({"default_command": "bash -l", "display_name": "gw-mcp"})
assert_equal(mcp_result["status"], "pending_review", "mcp preregister status")
cli_result = mod.cli_gateway_configure(["--command", "bash -l", "--name", "gw-cli"])
assert_equal(cli_result["status"], "pending_review", "cli preregister status")
positional_result = mod.cli_gateway_configure(["npb_positional", "--passphrase", "pw_positional", "--command", "bash"])
assert_equal(positional_result["status"], "pending_review", "cli positional preregister status")
assert_equal(len(calls["preregister"]), 3, "preregister call count")
assert_equal(calls["preregister"][0]["bootstrap_token"], "npb_env", "mcp bootstrap token source")
assert_equal(calls["preregister"][1]["bootstrap_token"], "npb_env", "cli bootstrap token source")
assert_equal(calls["preregister"][2]["bootstrap_token"], "npb_positional", "cli positional bootstrap token source")
assert_equal(calls["preregister"][0]["passphrase"], "pw_env", "mcp passphrase source")
assert_equal(calls["preregister"][1]["passphrase"], "pw_env", "cli passphrase source")
assert_equal(calls["preregister"][2]["passphrase"], "pw_positional", "cli positional passphrase source")
assert_equal(calls["preregister"][0]["default_command"], "bash -l", "mcp default command")
assert_equal(calls["preregister"][1]["default_command"], "bash -l", "cli default command")
mod.AUTH = {"service_registration_id": "stale_reg"}
mod.load_public_auth_file = lambda: {"service_registration_id": "stale_reg"}
stale_result = mod.mcp_gateway_configure({"bootstrap_token": "npb_fresh", "passphrase": "pw", "default_command": "bash"})
assert_equal(stale_result["status"], "pending_review", "stale registration should not block fresh bootstrap")
assert_equal(calls["preregister"][-1]["bootstrap_token"], "npb_fresh", "fresh bootstrap token source")
def preregister_invalid(_payload):
raise RuntimeError("service bootstrap token is invalid or expired")
mod.gateway_service_preregister = preregister_invalid
invalid_mcp = mod.mcp_gateway_configure({"bootstrap_token": "npb_invalid", "passphrase": "pw", "default_command": "bash"})
invalid_cli = mod.cli_gateway_configure(["--bootstrap-token", "npb_invalid", "--passphrase", "pw", "--command", "bash"])
assert_equal(invalid_mcp["status"], "bootstrap_token_invalid", "mcp invalid token fallback")
assert_equal(invalid_cli["status"], "bootstrap_token_invalid", "cli invalid token fallback")
calls["preregister"].clear()
calls["unlock"].clear()
calls["tmux"].clear()
mod.gateway_service_preregister = preregister
mod.TOKEN = None
mod.vault_exists = lambda: True
mod.gateway_is_unlocked = lambda: False
def unlock_existing(passphrase):
calls["unlock"].append(passphrase)
mod.TOKEN = "np_existing_worker"
return {"status": "unlocked"}
mod.gateway_unlock = unlock_existing
preserved_mcp = mod.mcp_gateway_configure({
"bootstrap_token": "npb_existing",
"passphrase": "pw_existing",
"default_command": "bash",
})
preserved_cli = mod.cli_gateway_configure([
"--bootstrap-token", "npb_existing",
"--passphrase", "pw_existing",
"--command", "bash",
])
assert_equal(preserved_mcp["status"], "configured", "mcp preserved gateway should skip preregister")
assert_equal(preserved_cli["status"], "configured", "cli preserved gateway should skip preregister")
assert_equal(calls["preregister"], [], "preserved gateway preregister calls")
assert_equal(calls["unlock"], ["pw_existing", "pw_existing"], "preserved gateway unlocks before preregister")
assert_equal(len(calls["tmux"]), 2, "preserved gateway command updates")
calls["preregister"].clear()
calls["unlock"].clear()
mod.TOKEN = None
mod.vault_exists = lambda: True
service_unlocked = {"value": False}
mod.gateway_is_unlocked = lambda: service_unlocked["value"]
os.environ["NAPSEER_GATEWAY_PASSPHRASE"] = "pw_service"
os.environ["NAPSEER_GATEWAY_BOOTSTRAP_TOKEN"] = "npb_service"
mod.start_local_ui = lambda payload: {"status": "started", "payload": payload}
def service_unlock(passphrase):
calls["unlock"].append(passphrase)
mod.TOKEN = "np_existing_worker"
service_unlocked["value"] = True
return {"status": "unlocked"}
mod.gateway_unlock = service_unlock
mod.threading.Event = lambda: type("StopEvent", (), {"wait": lambda self: (_ for _ in ()).throw(KeyboardInterrupt())})()
service_result = mod.gateway_service_run({"timeout_seconds": 0})
assert_equal(service_result["status"], "started", "service run with preserved token")
assert_equal(calls["preregister"], [], "service run preserved gateway preregister calls")
assert_equal(calls["unlock"], ["pw_service"], "service run unlocks before preregister")
print("ok: gateway configure CLI/MCP parity smoke passed")
if __name__ == "__main__":
run()
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Smoke-test auth refresh while the gateway vault is locked."""
import importlib.util
import pathlib
import sys
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
sys.path.insert(0, str(script_path.parent))
spec = importlib.util.spec_from_file_location("napseer_mcp_server", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def run():
mod = load_module()
writes = []
mod.VAULT_SECRETS = {}
mod.VAULT_KEY = None
mod.AUTH = {
"base_url": "https://api.example.test",
"project_id": "project_1",
"worker_name": "agent",
"device_fingerprint": "device",
"root_path": "/repo",
"worker_capabilities": {"local_mcp": True},
}
mod.BASE_URL = mod.AUTH["base_url"]
mod.DEFAULT_PROJECT_ID = mod.AUTH["project_id"]
mod.TOKEN = "expired"
mod.TOKEN_EXPIRES_AT = "old"
mod.vault_exists = lambda: True
mod.gateway_is_unlocked = lambda: False
mod.load_public_auth_file = lambda: {
"base_url": "https://api.example.test",
"project_id": "project_1",
"worker_name": "agent",
"device_fingerprint": "device",
"root_path": "/repo",
"worker_capabilities": {"local_mcp": True},
}
mod.write_public_auth = lambda payload: writes.append(dict(payload))
def locked(operation="operation"):
raise RuntimeError(f"gateway is locked; unlock with the master passphrase before {operation}")
mod.require_unlocked = locked
mod.save_auth({"token": "fresh", "token_expires_at": "new", "worker_id": "worker_1"})
assert writes[-1]["token"] == "fresh"
assert writes[-1]["token_expires_at"] == "new"
assert mod.TOKEN == "fresh"
assert mod.TOKEN_EXPIRES_AT == "new"
try:
mod.save_auth({"gateway_default_command": "bash -l"})
except RuntimeError as exc:
assert "updating gateway vault" in str(exc)
else:
raise AssertionError("gateway vault updates must still require unlock")
print("ok: locked gateway auth refresh smoke passed")
if __name__ == "__main__":
run()