Initial CLI source import
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
venv/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
.napseer/
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
Executable
+9526
File diff suppressed because one or more lines are too long
@@ -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()
|
||||
Executable
+254
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user