Repair MCP diagnostics and gateway lifecycle

This commit is contained in:
HumbleTurtle
2026-08-01 03:50:28 -03:00
parent ffd31cf568
commit bfda22f59b
16 changed files with 663 additions and 110 deletions
+27 -1
View File
@@ -7,7 +7,8 @@ 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`.
- Gateway lifecycle commands such as `nap gateway setup`, `repair`, `start`,
`status`, `logs`, `terminal`, `schedule`, and `vault`.
Current state:
@@ -20,6 +21,16 @@ Current state:
the generated `napseer_mcp_server.py` worker between requests.
- Gateway service compatibility remains in the worker while the standalone
gateway image is built from the public gateway repository.
- `nap mcp status` and `nap doctor` run a fresh stdio initialization,
`tools/list`, and authenticated read probe. They deliberately report an
existing client connection as `not_observable`; a successful fresh probe
does not claim that a previously opened Codex transport is connected.
- Operator OAuth credentials remain in `.napseer/auth.json`. A repaired local
gateway uses a separate `.napseer/gateway-auth.json` worker identity, so
relay token renewal cannot rotate or overwrite the operator/MCP session.
- CLI terminal and schedule operations call the running gateway's
loopback-only, CSRF-protected control API. PTYs therefore live in the daemon
instead of disappearing when a CLI subprocess exits.
Backend discovery publishes the release manifest and exact public source
revision used for each bundle.
@@ -30,5 +41,20 @@ Configure Codex and other long-lived stdio clients to launch:
python3 ~/.local/share/napseer/napseer_mcp_supervisor.py
```
For repositories that may be resumed after Codex opened a different working
directory, add a trusted project `.codex/config.toml` override with both
`cwd` and `NAPSEER_PROJECT_ROOT`. MCP processes inherit their launch directory;
they cannot infer a later client workspace switch over stdio.
The public operator surface is intentionally small:
```text
nap init | status | doctor | auth | project | mcp | gateway | update | version | help
```
Authentication has one recovery verb: `nap auth repair`. Normal refresh is
automatic. `nap gateway repair` creates the separate worker identity once;
`--replace` is required to replace an existing identity.
The direct wrapper remains the `nap` CLI implementation and an isolated
protocol-debugging entrypoint.
+126 -60
View File
@@ -20,6 +20,7 @@ import json
import hashlib
import os
import pathlib
import select
import shutil
import signal
import socket
@@ -55,8 +56,8 @@ RUNTIME_SCRIPT_NAMES = (
"napseer_mcp_supervisor.py",
)
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
CLI_RELEASE_VERSION = "0.2.0"
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-07-29"
CLI_RELEASE_VERSION = "0.2.1"
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-08-01"
CLI_MINIMUM_CONTRACT_VERSION = "2026-05-19"
CLI_BUNDLE_SCHEMA_VERSION = "napseer.cli.bundle.v1"
INSTALL_PATHS = {
@@ -95,13 +96,6 @@ COMMAND_METADATA = {
"update": {"mutates": True, "visible": True},
"version": {"mutates": False, "visible": True},
"help": {"mutates": False, "visible": True},
"chat": {"mutates": True, "visible": False},
"plan": {"mutates": True, "visible": False},
"lineage": {"mutates": False, "visible": False},
"agent": {"mutates": True, "visible": False},
"feedback": {"mutates": True, "visible": False},
"where": {"mutates": False, "visible": False},
"install": {"mutates": True, "visible": False},
}
@@ -748,7 +742,17 @@ def start_service(kind, args):
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())}
environment = os.environ.copy()
gateway_auth_path = preferred_state_dir() / "gateway-auth.json"
if kind == "gateway" and gateway_auth_path.is_file():
environment["NAPSEER_AUTH_FILE"] = str(gateway_auth_path)
environment["NAPSEER_PROJECT_ROOT"] = str(pathlib.Path.cwd())
kwargs = {
"stdout": log,
"stderr": subprocess.STDOUT,
"cwd": str(pathlib.Path.cwd()),
"env": environment,
}
if os.name != "nt":
kwargs["start_new_session"] = True
else:
@@ -850,41 +854,45 @@ def service_logs(kind, args):
def handle_gateway(args):
subcommand = args[0] if args else "ls"
subcommand = args[0] if args else "status"
rest = args[1:] if args else []
if subcommand in {"help", "-h", "--help"}:
print("Usage: nap gateway [start|stop|status|logs|configure|setup|restart|kill|vault|terminal|schedule|service]")
print("Usage: nap gateway [start|stop|status|logs|setup|repair|terminal|schedule|vault]")
print(" nap gateway start [--port PORT] Start the local gateway service.")
print(" nap gateway status Show the managed gateway process.")
print(" nap gateway configure [--command CMD] Configure the local gateway command.")
print(" nap gateway setup Create or migrate local gateway storage.")
print(" nap gateway setup [--command CMD] Create local storage and set the default command.")
print(" nap gateway repair Create dedicated gateway worker credentials.")
print(" nap gateway vault Show gateway vault status and pending setup requests.")
print(" nap gateway vault process Complete pending setup requests with the vault/content passphrase.")
print(" nap gateway vault rotate-secret --kind memory")
print(" nap chat secret setup Configure chat secret state with the vault/content passphrase.")
return 0
if subcommand == "vault" and rest and rest[0] in {"help", "-h", "--help"}:
print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind memory] [--all] [--project-id ID] [--vault-passphrase TEXT]")
print("Usage: nap gateway vault [status|process|rotate-secret] [--kind memory] [--all] [--project-id ID] [--vault-passphrase TEXT]")
print(" status Show local gateway state and pending setup requests.")
print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.")
print(" rotate-secret Rotate the memory project secret from the gateway worker path.")
print(" Chat secrets use: nap chat secret setup|status|rotate; setup uses the same vault/content passphrase.")
return 0
if subcommand == "vault" and rest and rest[0] in {"list", "ls"}:
raise RuntimeError("unknown gateway vault command: list; use `nap gateway vault status`")
if subcommand == "process":
return run_wrapper("gateway", ["vault", "process", *rest])
raise RuntimeError("unknown gateway command: process; use `nap gateway vault process`")
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"}:
if subcommand == "status":
return print_json(list_service("gateway")) or 0
if subcommand in {"logs", "log"}:
if subcommand == "logs":
return print_json(service_logs("gateway", rest)) or 0
if subcommand == "configure":
return run_wrapper("gateway", ["configure", *rest])
if subcommand in {"setup", "restart", "kill", "vault", "terminal", "schedule", "service"}:
raise RuntimeError("unknown gateway command: configure; use `nap gateway setup --command CMD`")
if subcommand in {"restart", "kill"}:
raise RuntimeError(f"unknown gateway command: {subcommand}; use start, stop, or terminal operations")
if subcommand == "service":
raise RuntimeError("unknown gateway command: service; use `nap gateway repair` then `nap gateway start`")
if subcommand in {"setup", "repair", "vault", "terminal", "schedule"}:
return run_wrapper("gateway", [subcommand, *rest])
raise RuntimeError("unknown gateway command; use nap gateway start|stop|configure|setup|restart|kill|logs|status|vault|terminal|schedule|service")
raise RuntimeError("unknown gateway command; use nap gateway start|stop|status|logs|setup|repair|terminal|schedule|vault")
def print_json(value):
@@ -938,9 +946,9 @@ def print_command_help(command):
" serve Run the stable stdio supervisor."
),
"gateway": (
"Usage: nap gateway [setup|start|stop|restart|status|logs|vault|terminal|schedule|service]\n"
"Common lifecycle commands are setup, start, stop, restart, status, and logs.\n"
"Vault, terminal, schedule, and service are advanced capability groups."
"Usage: nap gateway [start|stop|status|logs|setup|repair|terminal|schedule|vault]\n"
"Use setup once, repair only when worker credentials are missing or revoked,\n"
"then operate the daemon through terminal, schedule, and vault."
),
"update": (
"Usage: nap update\n"
@@ -972,16 +980,86 @@ def mcp_status():
worker = runtime_asset_path("napseer_mcp_server.py")
supervisor = runtime_asset_path("napseer_mcp_supervisor.py")
missing = runtime_assets_missing()
probe = mcp_runtime_probe() if not missing else {"status": "not_run", "transport": False, "read": False}
return {
"status": "ok" if not missing else "repair_required",
"status": "probe_ok" if not missing and probe.get("status") == "ok" else "repair_required",
"supervisor_installed": supervisor.is_file(),
"worker_installed": worker.is_file(),
"runtime_assets_missing": missing,
"fresh_process_probe": probe,
"client_connection": "not_observable",
"message": (
"A fresh MCP supervisor transport and authenticated read succeeded. Existing client connections are separate; restart the client if it reports Transport closed."
if probe.get("status") == "ok"
else "The installed MCP runtime did not pass a fresh-process probe."
),
"serve_command": f"{sys.executable} {supervisor}",
"next": None if not missing else "nap mcp install",
}
def mcp_runtime_probe(timeout_seconds=12):
supervisor = runtime_asset_path("napseer_mcp_supervisor.py")
worker = runtime_asset_path("napseer_mcp_server.py")
if not supervisor.is_file() or not worker.is_file():
return {"status": "not_installed", "transport": False, "read": False}
environment = os.environ.copy()
environment["NAPSEER_PROJECT_ROOT"] = str(pathlib.Path.cwd())
process = subprocess.Popen(
[sys.executable, str(supervisor)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
env=environment,
)
def request(request_id, method, params=None):
message = {"jsonrpc": "2.0", "id": request_id, "method": method}
if params is not None:
message["params"] = params
process.stdin.write(json.dumps(message) + "\n")
process.stdin.flush()
ready, _, _ = select.select([process.stdout], [], [], timeout_seconds)
if not ready:
raise TimeoutError("MCP probe timed out")
return json.loads(process.stdout.readline())
try:
initialized = request(1, "initialize", {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "nap-doctor", "version": CLI_RELEASE_VERSION},
})
listed = request(2, "tools/list")
read = request(3, "tools/call", {"name": "nap_library_ls", "arguments": {}})
tools = ((listed.get("result") or {}).get("tools") or [])
read_result = read.get("result") or {}
read_ok = not bool(read_result.get("isError"))
initialized_ok = bool(initialized.get("result"))
return {
"status": "ok" if initialized_ok and tools and read_ok else "failed",
"transport": bool(initialized_ok and tools),
"read": read_ok,
"tool_count": len(tools),
}
except Exception as exc:
return {
"status": "failed",
"transport": False,
"read": False,
"error_class": exc.__class__.__name__,
}
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=3)
def handle_mcp(args):
subcommand = args[0] if args else "status"
rest = args[1:]
@@ -1026,6 +1104,13 @@ def doctor_status():
issues.append({"code": "runtime_assets_missing", "message": "local MCP runtime is incomplete", "next": "nap mcp install"})
if not runtime_asset_path("napseer_mcp_supervisor.py").is_file():
issues.append({"code": "supervisor_missing", "message": "stable MCP supervisor is not installed", "next": "nap mcp install"})
probe = mcp_runtime_probe() if not missing else {"status": "not_run", "transport": False, "read": False}
if not missing and probe.get("status") != "ok":
issues.append({
"code": "mcp_fresh_probe_failed",
"message": "fresh MCP transport or authenticated read failed",
"next": "nap mcp update; then restart the MCP client",
})
return {
"status": "ok" if not issues else "repair_required",
"checks": {
@@ -1037,6 +1122,8 @@ def doctor_status():
"project_attached": bool(auth.get("project_id")),
"mcp_supervisor": runtime_asset_path("napseer_mcp_supervisor.py").is_file(),
"mcp_worker": runtime_asset_path("napseer_mcp_server.py").is_file(),
"mcp_fresh_probe": probe,
"client_connection": "not_observable",
},
"issues": issues,
}
@@ -1090,18 +1177,12 @@ def main(argv):
if command == "version":
print_json(version_status())
return 0
if command == "install":
print("`nap install` is deprecated; use `nap mcp install`.", file=sys.stderr)
print_json(install_assets())
return 0
if command == "update":
result = install_assets()
print_json({**result, "status": "updated", "message": "Napseer CLI and runtime scripts are updated."})
return 0
if command == "gateway":
return handle_gateway(args)
if command == "chat":
return run_wrapper("chat", args or ["secret", "status"])
if command == "status":
return run_wrapper("configure", args)
if command == "project":
@@ -1110,38 +1191,23 @@ def main(argv):
if args[0] == "bootstrap":
raise RuntimeError("unknown project command: bootstrap")
return run_wrapper("project", args)
if command == "lineage":
return run_wrapper("lineage", args or ["status"])
if command == "plan":
return run_wrapper("plan", args or ["list"])
if command == "agent":
return run_wrapper("agent", args)
if command == "auth":
if args and args[0] == "repair":
return run_wrapper("auth", ["refresh", *args[1:]])
return run_wrapper("auth", ["repair", *args[1:]])
if args and args[0] == "refresh":
print("`nap auth refresh` is deprecated; use `nap auth repair`.", file=sys.stderr)
raise RuntimeError("unknown auth command: refresh; use `nap auth repair`")
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(runtime_asset_path("napseer_mcp_server.py")),
"supervisor": str(runtime_asset_path("napseer_mcp_supervisor.py")),
"cwd": str(pathlib.Path.cwd()),
"cwd_auth_path": state["active_auth_path"],
"cwd_auth_configured": pathlib.Path(state["active_auth_path"]).exists(),
**state,
"path_warning": path_warning(),
"status": "ok",
"message": f"Napseer install and cwd paths resolved. {state['state_dir_message']}",
removed = {
"install": "use `nap mcp install`",
"where": "use `nap doctor` or `nap mcp status`",
"chat": "use the authenticated MCP chat-secret tools",
"plan": "use the authenticated MCP planning tools",
"lineage": "use the authenticated MCP lineage tools",
"agent": "use the authenticated MCP agent tools",
"feedback": "use the authenticated MCP feedback tools",
}
)
return 0
if command in removed:
raise RuntimeError(f"unknown command: {command}; {removed[command]}")
raise RuntimeError(f"unknown command: {command}")
+249 -28
View File
@@ -191,6 +191,9 @@ class RelaySocketClosed(RuntimeError):
def local_state_dir():
project_root = os.environ.get("NAPSEER_PROJECT_ROOT")
if project_root:
return pathlib.Path(project_root).expanduser().resolve() / ".napseer"
return pathlib.Path.cwd() / ".napseer"
@@ -312,7 +315,8 @@ def send_telemetry_event_async(event, component, outcome="success", **fields):
AUTH_DIR = local_state_dir()
AUTH_PATH = AUTH_DIR / "auth.json"
AUTH_PATH = pathlib.Path(os.environ.get("NAPSEER_AUTH_FILE", AUTH_DIR / "auth.json")).expanduser().resolve()
GATEWAY_AUTH_PATH = AUTH_DIR / "gateway-auth.json"
VAULT_PATH = AUTH_DIR / "vault.json"
LOCAL_VAULT_KEY_PATH = AUTH_DIR / "vault.local-key"
GATEWAY_RELAY_SECRET_PATH = AUTH_DIR / "gateway-relay-secret.json"
@@ -570,6 +574,7 @@ GATEWAY_LISTENER_LAST_ERROR_AT = None
GATEWAY_LISTENER_ACTIVE_LANES = set()
GATEWAY_LISTENER_UNSTABLE_UNTIL = 0.0
GATEWAY_LISTENER_STABILITY_LOCK = threading.RLock()
AUTH_RENEW_LOCK = threading.Lock()
GATEWAY_VAULT_SETUP_THREAD_STARTED = False
GATEWAY_PERIODIC_LOG_STATE = {}
WS_CLOSE_DETAILS = {}
@@ -2246,6 +2251,26 @@ def gateway_terminal_restart(args=None):
def gateway_terminal_capture(args=None):
args = args or {}
if args.get("terminal_id"):
backend, terminal = gateway_terminal_backend_from_id(
args.get("terminal_id"),
remote_authenticated=bool(args.get("remote_authenticated")),
)
if backend == "pty":
attach = gateway_pty_manager().attach(str(terminal["id"]))
output = b"".join(chunk.data for chunk in attach.chunks)
max_bytes = max(2_000, min(int(os.environ.get("NAPSEER_GATEWAY_CAPTURE_MAX_BYTES", "48000")), 64 * 1024))
output = output[-max_bytes:]
return {
"status": "captured",
"message": "Gateway PTY terminal output captured.",
"terminal_id": terminal["id"],
"terminal_backend": "pty",
"output": output.decode("utf-8", errors="replace"),
"output_seq": attach.newest_seq,
"replay_gap": attach.replay_gap,
"truncated": len(output) >= max_bytes,
}
if args.get("_internal_target") and (args.get("tmux_target") or args.get("target")):
target = str(args.get("tmux_target") or args.get("target"))
elif args.get("terminal_id"):
@@ -2840,7 +2865,7 @@ def mark_gateway_listener_unstable(reason, lane_id=None):
with GATEWAY_LISTENER_STABILITY_LOCK:
GATEWAY_LISTENER_UNSTABLE_UNTIL = max(GATEWAY_LISTENER_UNSTABLE_UNTIL, until)
gateway_periodic_log(
"relay_listener_path_marked_unstable",
f"relay_listener_path_marked_unstable:{lane_id or 'all'}",
30,
"relay_listener_path_marked_unstable",
level="warn",
@@ -5090,6 +5115,7 @@ def handle_gateway_relay_session(connect_request, listener_sock=None):
def gateway_relay_loop(lane_id="lane-1"):
global GATEWAY_LISTENER_CONNECTED, GATEWAY_LISTENER_LAST_CONNECTED_AT, GATEWAY_LISTENER_LAST_ERROR, GATEWAY_LISTENER_LAST_ERROR_AT
closed_by_relay_streak = 0
failure_streak = 0
gateway_log(
"relay_listener_thread_started",
project_id=current_project_id(),
@@ -5103,11 +5129,13 @@ def gateway_relay_loop(lane_id="lane-1"):
lane_index = int(str(lane_id).rsplit("-", 1)[-1]) if str(lane_id).rsplit("-", 1)[-1].isdigit() else 1
if gateway_remote_should_run() and lane_index <= gateway_listener_target_lanes():
refresh_public_auth_state()
ensure_gateway_worker_capability(refresh=True)
# A relay connection does not need to rotate OAuth credentials.
# Refresh only after the relay actually rejects the access token.
ensure_gateway_worker_capability(refresh=False)
project_id = current_project_id()
agent_id = AUTH.get("agent_id")
gateway_periodic_log(
"relay_listener_connecting",
f"relay_listener_connecting:{lane_id}",
30,
"relay_listener_connecting",
project_id=project_id,
@@ -5140,6 +5168,7 @@ def gateway_relay_loop(lane_id="lane-1"):
},
)
connected_at = time.time()
failure_streak = 0
with GATEWAY_LISTENER_STABILITY_LOCK:
GATEWAY_LISTENER_ACTIVE_LANES.add(lane_id)
GATEWAY_LISTENER_CONNECTED = bool(GATEWAY_LISTENER_ACTIVE_LANES)
@@ -5228,7 +5257,7 @@ def gateway_relay_loop(lane_id="lane-1"):
mark_gateway_listener_unstable("repeated_relay_closes", lane_id=lane_id)
delay = min(30.0, 2.0 + (closed_by_relay_streak * 2.0)) + random.uniform(0.0, 1.5)
gateway_periodic_log(
"relay_listener_backoff",
f"relay_listener_backoff:{lane_id}",
30,
"relay_listener_backoff",
level="warn",
@@ -5246,7 +5275,7 @@ def gateway_relay_loop(lane_id="lane-1"):
readiness = gateway_remote_readiness()
blockers = gateway_remote_blockers(readiness)
gateway_periodic_log(
"relay_listener_waiting",
f"relay_listener_waiting:{lane_id}",
60,
"relay_listener_waiting",
level="warn",
@@ -5261,6 +5290,7 @@ def gateway_relay_loop(lane_id="lane-1"):
reason=",".join(blockers) if blockers else "waiting",
)
except Exception as exc:
failure_streak += 1
closed_by_relay_streak = 0
with GATEWAY_LISTENER_STABILITY_LOCK:
GATEWAY_LISTENER_ACTIVE_LANES.discard(lane_id)
@@ -5269,7 +5299,7 @@ def gateway_relay_loop(lane_id="lane-1"):
GATEWAY_LISTENER_LAST_ERROR_AT = iso_now()
mark_gateway_listener_unstable(type(exc).__name__, lane_id=lane_id)
gateway_periodic_log(
"relay_listener_error",
f"relay_listener_error:{lane_id}",
30,
"relay_listener_error",
level="error",
@@ -5288,7 +5318,8 @@ def gateway_relay_loop(lane_id="lane-1"):
gateway_remote_enabled=gateway_remote_should_run(),
relay_listener_unstable=time.time() < GATEWAY_LISTENER_UNSTABLE_UNTIL,
)
time.sleep(2 + random.uniform(0.0, 1.0))
delay = min(60.0, 2.0 ** min(failure_streak, 6)) if failure_streak else 10.0
time.sleep(delay + random.uniform(0.0, 1.0))
def start_gateway_relay_thread():
@@ -6747,7 +6778,7 @@ def renew_anonymous_auth_with_ssh():
}
def renew_auth():
def _renew_auth_locked():
oauth_refresh_error = None
if AUTH.get("account_mode") in {"operator_account", "operator_project", "claimed"} and REFRESH_TOKEN:
try:
@@ -6835,6 +6866,15 @@ def renew_auth():
return renew_anonymous_auth_with_ssh()
def renew_auth():
# OAuth providers may rotate refresh tokens. Relay lanes run concurrently,
# so serialize renewal and reload the latest token written by another lane
# before attempting a refresh.
with AUTH_RENEW_LOCK:
refresh_public_auth_state()
return _renew_auth_locked()
def jwt_claims(token):
try:
parts = str(token or "").split(".")
@@ -7306,6 +7346,108 @@ def gateway_service_activate(args=None):
}
def write_gateway_worker_auth(payload):
GATEWAY_AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
chmod_best_effort(GATEWAY_AUTH_PATH.parent, 0o700)
temporary = GATEWAY_AUTH_PATH.with_suffix(".json.tmp")
temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
chmod_best_effort(temporary, 0o600)
temporary.replace(GATEWAY_AUTH_PATH)
chmod_best_effort(GATEWAY_AUTH_PATH, 0o600)
def gateway_worker_repair(args=None):
args = args or {}
if AUTH.get("account_mode") != "operator_project" or not DEFAULT_PROJECT_ID:
raise RuntimeError("gateway repair requires an attached operator project; run `nap project attach`")
if GATEWAY_AUTH_PATH.is_file() and not args.get("replace"):
return {
"status": "already_configured",
"message": "Dedicated gateway worker credentials already exist. Restart the gateway to use them; pass --replace only to replace a revoked worker identity.",
"gateway_auth_path": str(GATEWAY_AUTH_PATH),
"project_id": DEFAULT_PROJECT_ID,
}
label = str(args.get("display_name") or f"{pathlib.Path.cwd().name}-local-gateway").strip()
root_path = str(pathlib.Path.cwd())
device_fingerprint = str(AUTH.get("device_fingerprint") or socket.gethostname())
container_uuid = ensure_container_uuid()
bootstrap = request_json(
"POST",
"/v1/service-bootstrap-tokens",
{
"project_id": DEFAULT_PROJECT_ID,
"label": label,
"expires_in_hours": 1,
"max_uses": 1,
},
)
registration_result = request_json(
"POST",
"/v1/service-registrations",
{
"bootstrap_token": bootstrap["bootstrap_token"],
"display_name": label,
"device_fingerprint": device_fingerprint,
"container_uuid": container_uuid,
"root_path": root_path,
"default_command": normalize_gateway_command(args.get("default_command")),
"labels": {"source": "nap-cli-local-gateway", "managed": True},
"metadata": {"cwd": root_path, "base_url": BASE_URL},
},
token_required=False,
)
registration = registration_result.get("registration") or {}
registration_id = registration.get("id")
if not registration_id:
raise RuntimeError("gateway registration did not return an id")
accepted = request_json(
"POST",
f"/v1/service-registrations/{registration_id}/accept",
{
"project_id": DEFAULT_PROJECT_ID,
"display_name": label,
"capabilities": {"gateway": True, "local_mcp": False, "managed_by": "nap-cli"},
},
)
activation = request_json(
"POST",
f"/v1/service-registrations/{registration_id}/activate",
{"activation_token": registration_result["activation_token"]},
token_required=False,
)
worker = activation.get("worker") or accepted.get("worker") or {}
token = activation.get("token") or {}
if not token.get("access_token") or not token.get("refresh_token"):
raise RuntimeError("gateway activation did not return worker credentials")
write_gateway_worker_auth({
"base_url": BASE_URL,
"token": token["access_token"],
"refresh_token": token["refresh_token"],
"token_expires_at": token.get("expires_at"),
"refresh_expires_at": token.get("refresh_expires_at"),
"project_id": worker.get("project_id") or DEFAULT_PROJECT_ID,
"project_slug": AUTH.get("project_slug"),
"project_name": AUTH.get("project_name"),
"worker_id": worker.get("id"),
"agent_id": worker.get("agent_id"),
"worker_name": worker.get("name") or label,
"device_fingerprint": worker.get("device_fingerprint") or device_fingerprint,
"root_path": worker.get("root_path") or root_path,
"worker_capabilities": {"gateway": True, "local_mcp": False, "managed_by": "nap-cli"},
"account_mode": "gateway_worker",
"service_registration_id": registration_id,
})
return {
"status": "repaired",
"message": "Dedicated gateway worker credentials created. Restart the gateway to use them.",
"gateway_auth_path": str(GATEWAY_AUTH_PATH),
"project_id": worker.get("project_id") or DEFAULT_PROJECT_ID,
"agent_id": worker.get("agent_id"),
"worker_id": worker.get("id"),
"registration_id": registration_id,
}
def gateway_service_open_local_from_env():
if not vault_exists() or gateway_is_unlocked():
return False
@@ -7854,6 +7996,72 @@ def cli_gateway_require_unlocked(args, operation="gateway operation"):
raise RuntimeError("gateway vault is not configured; run nap gateway configure first")
if not gateway_is_unlocked():
gateway_open_local(cli_option(args, "--passphrase", default=None))
def cli_gateway_service_state():
path = AUTH_DIR / "gateway.json"
try:
state = json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
state = {}
try:
port = int(state.get("port"))
except (TypeError, ValueError):
port = 0
if not port:
raise RuntimeError("local gateway is not running; run `nap gateway start`")
return state, f"http://127.0.0.1:{port}"
def cli_gateway_service_json(method, url, payload=None):
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, headers=headers, method=method)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
try:
with opener.open(request, timeout=5) as response:
result = json.loads(response.read().decode("utf-8"))
except (TimeoutError, socket.timeout) as exc:
raise RuntimeError("local gateway did not respond; run `nap gateway status`") from exc
except urllib.error.HTTPError as exc:
try:
result = json.loads(exc.read().decode("utf-8"))
except Exception:
result = {}
error = result.get("error") if isinstance(result, dict) else None
message = error.get("message") if isinstance(error, dict) else None
raise RuntimeError(message or "local gateway request failed safely") from exc
except (urllib.error.URLError, OSError, json.JSONDecodeError) as exc:
raise RuntimeError("local gateway is unavailable; run `nap gateway status` or `nap gateway restart`") from exc
if not isinstance(result, dict):
raise RuntimeError("local gateway returned an invalid response")
error = result.get("error")
if isinstance(error, dict):
raise RuntimeError(error.get("message") or "local gateway request failed safely")
return result
def cli_gateway_service_call(route, payload=None):
_state, base_url = cli_gateway_service_state()
try:
session = cli_gateway_service_json("GET", f"{base_url}/local-api/session")
except RuntimeError:
# Compatibility with gateway runtimes published before the compact
# session endpoint existed.
session = cli_gateway_service_json("GET", f"{base_url}/local-api/state")
service_auth_path = session.get("auth_path")
allowed_auth_paths = {AUTH_PATH.resolve(), GATEWAY_AUTH_PATH.resolve()}
if service_auth_path and pathlib.Path(service_auth_path).resolve() not in allowed_auth_paths:
raise RuntimeError("running gateway belongs to a different project folder")
csrf_token = session.get("csrf_token")
if not csrf_token:
raise RuntimeError("local gateway did not provide a control-session token")
body = dict(payload or {})
body["csrf_token"] = csrf_token
return cli_gateway_service_json("POST", f"{base_url}{route}", body)
require_unlocked(operation)
@@ -11302,6 +11510,13 @@ def start_local_ui(args):
if parsed_path.path == "/gateway/status":
self.send_json(200, gateway_status())
return
if parsed_path.path == "/local-api/session":
self.send_json(200, {
"csrf_token": csrf_token,
"auth_path": str(AUTH_PATH),
"project_id": current_project_id(),
})
return
if parsed_path.path == "/local-api/state":
self.send_json(200, self.local_state_payload(urllib.parse.parse_qs(parsed_path.query)))
return
@@ -13669,7 +13884,7 @@ def safe_error_matches(exc, *, status=None, service_code=None):
def safe_auth_refresh_error():
return SafeToolError(
"auth_refresh_failed",
"Authentication refresh failed. Run `nap auth refresh`; if recovery "
"Authentication refresh failed. Run `nap auth repair`; if recovery "
"fails, run `nap auth login` and then `nap project attach`.",
status=401,
)
@@ -13760,8 +13975,6 @@ def tool_contract_metadata(name):
metadata["dry_run_supported"] = True
if name in {"nap_plan_transition", "nap_crontab_put"}:
metadata["dangerous"] = True
if name == "nap_auth_refresh":
metadata["replaces"] = "nap_login"
return metadata
@@ -14630,11 +14843,11 @@ def cli_main(argv):
print("""Usage:
nap auth status
nap auth login [--frontend-url URL] [--api-base-url URL] [--no-browser] [--timeout SECONDS]
nap auth refresh
nap auth repair
`login` signs in to the account only. It does not select a project.
Use `nap project attach` to bind this folder to an existing project.
Refresh is automatic during authenticated calls; use `refresh` only for recovery.
Refresh is automatic during authenticated calls; use `repair` only for recovery.
`login-local` remains a hidden compatibility alias for `login`.""")
return
if subcommand in {"login", "login-local", "operator-login"}:
@@ -14646,7 +14859,7 @@ Refresh is automatic during authenticated calls; use `refresh` only for recovery
"port": cli_option(args, "--port", default=0),
}), indent=2))
return
if subcommand == "refresh":
if subcommand in {"refresh", "repair"}:
print(json.dumps(renew_auth(), indent=2))
return
if subcommand in {"status", "show"}:
@@ -14764,13 +14977,20 @@ Refresh is automatic during authenticated calls; use `refresh` only for recovery
if subcommand == "configure":
print(json.dumps(cli_gateway_configure(args), indent=2))
return
if subcommand == "repair":
print(json.dumps(gateway_worker_repair({
"display_name": cli_option(args, "--name", "--display-name", default=None),
"default_command": cli_option(args, "--command", "--default-command", default=None),
"replace": cli_flag(args, "--replace"),
}), indent=2))
return
if subcommand == "restart":
cli_gateway_require_unlocked(args, "restarting gateway terminal")
print(json.dumps(gateway_terminal_restart({}), indent=2))
print(json.dumps(cli_gateway_service_call("/local-api/gateway/terminal/restart", {}), indent=2))
return
if subcommand == "kill":
cli_gateway_require_unlocked(args, "killing gateway terminal")
print(json.dumps(gateway_terminal_kill({}), indent=2))
print(json.dumps(cli_gateway_service_call("/local-api/gateway/terminal/kill", {}), indent=2))
return
if subcommand == "terminal":
action = args[0] if args else "list"
@@ -14782,23 +15002,23 @@ Refresh is automatic during authenticated calls; use `refresh` only for recovery
cli_gateway_require_unlocked(terminal_args, "managing gateway terminals")
terminal_id = cli_option(terminal_args, "--terminal", "--terminal-id", "--id", default=None)
if action == "list":
print(json.dumps(gateway_terminal_sessions({"terminal_id": terminal_id}), indent=2))
print(json.dumps(cli_gateway_service_call("/local-api/gateway/terminal/list", {"terminal_id": terminal_id}), indent=2))
return
if action == "create":
print(json.dumps(gateway_terminal_create({
print(json.dumps(cli_gateway_service_call("/local-api/gateway/terminal/create", {
"name": cli_option(terminal_args, "--name", default=None),
"command": cli_option(terminal_args, "--command", "--default-command", default=None),
}), indent=2))
return
if action == "close":
target_id = terminal_id or (terminal_args[0] if terminal_args and not terminal_args[0].startswith("-") else None)
print(json.dumps(gateway_terminal_close({"terminal_id": target_id}), indent=2))
print(json.dumps(cli_gateway_service_call("/local-api/gateway/terminal/close", {"terminal_id": target_id}), indent=2))
return
if action == "capture":
print(json.dumps(gateway_terminal_capture({"terminal_id": terminal_id, "start": cli_option(terminal_args, "--start", default="-200")}), indent=2))
print(json.dumps(cli_gateway_service_call("/local-api/gateway/terminal/capture", {"terminal_id": terminal_id, "start": cli_option(terminal_args, "--start", default="-200")}), indent=2))
return
if action == "key":
print(json.dumps(gateway_terminal_key({
print(json.dumps(cli_gateway_service_call("/local-api/gateway/terminal/key", {
"terminal_id": terminal_id,
"key": cli_option(terminal_args, "--key", default=None),
"text": cli_option(terminal_args, "--text", default=None),
@@ -14820,7 +15040,7 @@ Refresh is automatic during authenticated calls; use `refresh` only for recovery
continue
text_parts.append(value)
text = " ".join(text_parts)
print(json.dumps(gateway_terminal_input({"terminal_id": terminal_id, "input": text}), indent=2))
print(json.dumps(cli_gateway_service_call("/local-api/gateway/terminal/input", {"terminal_id": terminal_id, "input": text}), indent=2))
return
if action in {"ls", "status", "new", "rm", "remove", "send"}:
raise RuntimeError(
@@ -14835,6 +15055,7 @@ Refresh is automatic during authenticated calls; use `refresh` only for recovery
)
schedule_args = args[1:] if args else []
cli_gateway_require_unlocked(schedule_args, "managing gateway schedules")
schedule_id = cli_option(schedule_args, "--schedule", "--schedule-id", "--id", default=None)
payload = {
"schedule_id": schedule_id,
"name": cli_option(schedule_args, "--name", default=None),
@@ -14847,25 +15068,25 @@ Refresh is automatic during authenticated calls; use `refresh` only for recovery
if cli_flag(schedule_args, "--enabled", "--on"):
payload["enabled"] = True
if action == "list":
print(json.dumps(gateway_schedule_list({}), indent=2))
print(json.dumps(cli_gateway_service_call("/local-api/gateway/schedule/list", {}), indent=2))
return
if action == "create":
print(json.dumps(gateway_schedule_create(payload), indent=2))
print(json.dumps(cli_gateway_service_call("/local-api/gateway/schedule/create", payload), indent=2))
return
if action == "update":
if not payload.get("schedule_id"):
payload["schedule_id"] = schedule_args[0] if schedule_args and not schedule_args[0].startswith("-") else None
print(json.dumps(gateway_schedule_update(payload), indent=2))
print(json.dumps(cli_gateway_service_call("/local-api/gateway/schedule/update", payload), indent=2))
return
if action == "delete":
if not payload.get("schedule_id"):
payload["schedule_id"] = schedule_args[0] if schedule_args and not schedule_args[0].startswith("-") else None
print(json.dumps(gateway_schedule_delete(payload), indent=2))
print(json.dumps(cli_gateway_service_call("/local-api/gateway/schedule/delete", payload), indent=2))
return
if action == "run":
if not payload.get("schedule_id"):
payload["schedule_id"] = schedule_args[0] if schedule_args and not schedule_args[0].startswith("-") else None
print(json.dumps(gateway_schedule_run_now(payload), indent=2))
print(json.dumps(cli_gateway_service_call("/local-api/gateway/schedule/run", payload), indent=2))
return
if action in {"ls", "status", "new", "edit", "rm", "remove", "run-now", "now", "schedules", "cron", "crons"}:
raise RuntimeError(
+4 -2
View File
@@ -80,7 +80,7 @@ def test_init_and_auth_repair_normalize_to_existing_handlers(monkeypatch):
assert calls == [
("project", ["init", "--slug", "demo"]),
("auth", ["refresh"]),
("auth", ["repair"]),
]
@@ -121,6 +121,7 @@ def test_doctor_reports_presence_without_exposing_values(tmp_path, monkeypatch):
(install_dir / "napseer_mcp_supervisor.py").write_text("# supervisor\n", encoding="utf-8")
monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
monkeypatch.setattr(module, "runtime_assets_missing", lambda: [])
monkeypatch.setattr(module, "mcp_runtime_probe", lambda: {"status": "ok", "transport": True, "read": True, "tool_count": 56})
monkeypatch.setattr(
module,
"state_dir_status",
@@ -150,6 +151,7 @@ def test_doctor_does_not_create_state_directory(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
monkeypatch.setattr(module, "runtime_assets_missing", lambda: [])
monkeypatch.setattr(module, "mcp_runtime_probe", lambda: {"status": "failed", "transport": False, "read": False})
result = module.doctor_status()
@@ -229,7 +231,7 @@ def test_bundle_install_stages_validates_and_activates_atomically(tmp_path, monk
result = module.install_assets()
assert result["cli_distribution"]["release_version"] == "0.2.0"
assert result["cli_distribution"]["release_version"] == "0.2.1"
assert (install_dir / "current").is_symlink()
assert (install_dir / "current" / "bundle-manifest.json").is_file()
assert (install_dir / "current" / "napseer_mcp_server.py").is_file()
+14
View File
@@ -126,6 +126,19 @@ def test_gateway_schedule_aliases_rejected():
expect_unknown(mod, args, "unknown gateway")
def test_gateway_schedule_list_uses_canonical_route():
mod = load_module()
calls = []
mod.cli_gateway_require_unlocked = lambda args, operation: None
mod.cli_gateway_service_call = lambda route, args: calls.append((route, dict(args))) or {
"status": "listed",
"schedules": [],
}
with contextlib.redirect_stdout(io.StringIO()):
mod.cli_main(["napseer_mcp_server.py", "gateway", "schedule", "list"])
assert calls == [("/local-api/gateway/schedule/list", {})]
def test_gateway_service_run_rejected():
"""Lifecycle verbs (run, start, etc.) moved to `nap service`."""
mod = load_module()
@@ -209,6 +222,7 @@ if __name__ == "__main__":
test_gateway_status_aliases_rejected()
test_gateway_terminal_aliases_rejected()
test_gateway_schedule_aliases_rejected()
test_gateway_schedule_list_uses_canonical_route()
test_gateway_service_run_rejected()
test_gateway_vault_aliases_rejected()
test_gateway_rotate_passphrase_aliases_rejected()
+1
View File
@@ -82,6 +82,7 @@ def run():
})
mod.REFRESH_TOKEN = "old-oauth-refresh"
mod.REFRESH_EXPIRES_AT = "old-refresh-expiry"
mod.refresh_public_auth_state = lambda: mod.AUTH
mod.request_form_json = lambda method, path, payload, token_required=False: {
"access_token": "fresh-oauth-access",
"refresh_token": "fresh-oauth-refresh",
+53
View File
@@ -0,0 +1,53 @@
import importlib.util
import pathlib
import threading
import time
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
spec = importlib.util.spec_from_file_location("napseer_mcp_server_gateway_auth_test", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def test_renew_auth_serializes_rotating_refresh_tokens():
mod = load_module()
active = 0
maximum = 0
guard = threading.Lock()
def fake_refresh():
nonlocal active, maximum
with guard:
active += 1
maximum = max(maximum, active)
time.sleep(0.03)
with guard:
active -= 1
return {"status": "renewed"}
mod.refresh_public_auth_state = lambda: None
mod._renew_auth_locked = fake_refresh
threads = [threading.Thread(target=mod.renew_auth) for _ in range(3)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert maximum == 1
def test_gateway_capability_update_does_not_refresh_by_default():
mod = load_module()
mod.AUTH = {"worker_capabilities": {}}
calls = []
mod.save_auth = lambda updates: mod.AUTH.update(updates)
mod.renew_auth = lambda: calls.append("renewed")
result = mod.ensure_gateway_worker_capability(refresh=False)
assert result["status"] == "updated"
assert calls == []
@@ -0,0 +1,38 @@
import importlib.util
import pathlib
import types
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
spec = importlib.util.spec_from_file_location("napseer_mcp_server_pty_capture_test", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def test_capture_reads_pty_ring_instead_of_tmux():
mod = load_module()
attach = types.SimpleNamespace(
chunks=(
types.SimpleNamespace(data=b"first\n"),
types.SimpleNamespace(data=b"napseer-lifecycle-ok\n"),
),
newest_seq=2,
replay_gap=False,
)
manager = types.SimpleNamespace(attach=lambda terminal_id: attach)
mod.gateway_terminal_backend_from_id = lambda terminal_id, remote_authenticated=False: (
"pty",
{"id": terminal_id},
)
mod.gateway_pty_manager = lambda: manager
result = mod.gateway_terminal_capture({"terminal_id": "term_test"})
assert result["status"] == "captured"
assert result["terminal_backend"] == "pty"
assert result["terminal_id"] == "term_test"
assert "napseer-lifecycle-ok" in result["output"]
assert result["output_seq"] == 2
@@ -5,6 +5,7 @@ import base64
import importlib.util
import pathlib
import sys
import tempfile
PROJECT_ID = "11111111-1111-1111-1111-111111111111"
@@ -46,6 +47,8 @@ def assert_wrapped_record(mod, record, secret_kind, data_key_epoch=1):
def run():
mod = load_module()
test_state = tempfile.TemporaryDirectory()
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
writes = []
mod.AUTH = {"agent_id": "gateway-agent-1", "account_id": ACCOUNT_ID}
+61
View File
@@ -0,0 +1,61 @@
import importlib.util
import pathlib
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
spec = importlib.util.spec_from_file_location("napseer_mcp_server_worker_repair_test", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def test_gateway_worker_repair_separates_credentials_and_returns_no_tokens():
mod = load_module()
mod.AUTH = {
"account_mode": "operator_project",
"project_slug": "demo",
"project_name": "Demo",
}
mod.DEFAULT_PROJECT_ID = "project-1"
mod.BASE_URL = "https://api.example.test"
mod.GATEWAY_AUTH_PATH = pathlib.Path("/definitely/missing/gateway-auth.json")
mod.ensure_container_uuid = lambda: "container-1"
mod.normalize_gateway_command = lambda value=None: value or "bash -l"
written = []
mod.write_gateway_worker_auth = lambda payload: written.append(dict(payload))
def request(method, path, payload=None, token_required=True, **kwargs):
if path == "/v1/service-bootstrap-tokens":
return {"bootstrap_token": "bootstrap-secret"}
if path == "/v1/service-registrations":
assert token_required is False
return {
"registration": {"id": "registration-1"},
"activation_token": "activation-secret",
}
if path.endswith("/accept"):
return {"worker": {"id": "worker-1", "agent_id": "agent-1", "project_id": "project-1"}}
if path.endswith("/activate"):
assert token_required is False
return {
"worker": {"id": "worker-1", "agent_id": "agent-1", "project_id": "project-1"},
"token": {
"access_token": "worker-access-secret",
"refresh_token": "worker-refresh-secret",
"expires_at": "later",
"refresh_expires_at": "much-later",
},
}
raise AssertionError(path)
mod.request_json = request
result = mod.gateway_worker_repair({"display_name": "demo-local-gateway"})
assert written[0]["account_mode"] == "gateway_worker"
assert written[0]["token"] == "worker-access-secret"
assert written[0]["refresh_token"] == "worker-refresh-secret"
assert "token" not in result
assert "refresh_token" not in result
assert result["status"] == "repaired"
@@ -16,6 +16,11 @@ def load_module():
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
# Keep project resolution independent from any real .napseer/auth.json in
# the checkout. Store the handle on the module so the directory remains
# alive for the complete test invocation.
module._test_state = tempfile.TemporaryDirectory()
module.AUTH_PATH = pathlib.Path(module._test_state.name) / "auth.json"
module.AUTH = {"account_id": "acct-1", "token": "token", "token_expires_at": "later"}
module.DEFAULT_PROJECT_ID = "project-1"
module.project_memory_encryption_active = lambda project_id: False
+8 -4
View File
@@ -114,6 +114,8 @@ def encrypted_node(mod, full_path="/notes/alpha", content="secret needle", metad
def run():
mod = load_module()
test_state = tempfile.TemporaryDirectory()
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
mod.AUTH = {"account_id": ACCOUNT_ID, "token": "token", "token_expires_at": "later"}
mod.PROJECT_VAULT_PASSPHRASE = PASSPHRASE
mod.DEFAULT_PROJECT_ID = "project-1"
@@ -121,6 +123,7 @@ def run():
mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear()
cache_secret(mod)
real_active_memory_secret = mod.active_memory_secret
real_get_node_by_id = mod.get_node_by_id
envelope = mod.encrypt_memory_bytes(
"project-1",
@@ -247,8 +250,9 @@ def run():
mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear()
cache_secret(mod)
node = encrypted_node(mod, metadata={"private": "secret metadata"})
mod.get_node_by_id = real_get_node_by_id
mod.request_json = lambda method, path, payload=None, **kwargs: node
read = mod.get_node_by_path({"path": "/notes/alpha"})
read = mod.get_node_by_id({"node_id": "node-1"})
assert read["content_text"] == "secret needle"
assert read["metadata"] == {"private": "secret metadata"}
@@ -257,8 +261,8 @@ def run():
moved["folder_path"] = "/renamed"
mod.request_json = lambda method, path, payload=None, **kwargs: {"items": [moved]}
listed = mod.list_project_nodes({"limit": 10, "include_content": True})
assert listed["items"][0]["content_text"] == "secret needle"
assert listed["items"][0]["metadata"] == {"private": "secret metadata"}
assert "content_text" not in listed["items"][0]
assert listed["items"][0]["full_path"] == "/renamed/alpha"
mod.request_json = lambda method, path, payload=None, **kwargs: {"items": [moved]}
compact = mod.list_project_nodes({"limit": 10})
@@ -311,7 +315,7 @@ def run():
legacy_moved["full_path"] = "/renamed/legacy"
mod.request_json = lambda method, path, payload=None, **kwargs: legacy_moved
try:
mod.get_node_by_path({"path": "/renamed/legacy"})
mod.get_node_by_id({"node_id": "node-1"})
except RuntimeError:
pass
else:
+21 -13
View File
@@ -7,7 +7,7 @@ import sys
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "nap_install.py"
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "nap_install.py"
spec = importlib.util.spec_from_file_location("nap_install", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
@@ -25,10 +25,14 @@ def run():
assert calls == []
assert mod.handle_gateway(["vault", "--help"]) == 0
assert calls[-1] == ("gateway", ["vault", "--help"])
assert calls == []
assert mod.handle_gateway(["vault", "list"]) == 0
assert calls[-1] == ("gateway", ["vault", "list"])
try:
mod.handle_gateway(["vault", "list"])
except RuntimeError as exc:
assert "gateway vault status" in str(exc)
else:
raise AssertionError("removed gateway vault list alias must not route")
assert mod.handle_gateway(["vault", "rotate-secret", "--kind", "memory"]) == 0
assert calls[-1] == ("gateway", ["vault", "rotate-secret", "--kind", "memory"])
@@ -39,18 +43,22 @@ def run():
assert "nap gateway vault process" in str(exc), str(exc)
else:
raise AssertionError("deprecated gateway process shortcut must not route")
assert mod.main(["nap", "chat", "secret", "setup"]) == 0
assert calls[-1] == ("chat", ["secret", "setup"])
try:
mod.main(["nap", "chat", "secret", "setup"])
except RuntimeError as exc:
assert "MCP chat-secret tools" in str(exc)
else:
raise AssertionError("removed chat compatibility command must not route")
try:
mod.handle_gateway(["config"])
except RuntimeError as exc:
assert "unknown gateway subcommand" in str(exc)
assert "unknown gateway command" in str(exc)
else:
raise AssertionError("gateway config alias must not route")
try:
mod.handle_gateway(["vault-setup", "list"])
except RuntimeError as exc:
assert "unknown gateway subcommand" in str(exc)
assert "unknown gateway command" in str(exc)
else:
raise AssertionError("deprecated gateway vault-setup alias must not route")
@@ -75,12 +83,12 @@ def run():
assert "unknown" in str(exc), (argv, str(exc))
else:
raise AssertionError(f"deprecated route must not be accepted at install level: {argv}")
# nap project bootstrap is now a deprecated alias handled by the MCP server
# (the install side forwards it; the MCP side raises with a clear error message).
forwarded = []
mod.run_wrapper = lambda command, args: forwarded.append((command, args)) or 0
try:
mod.main(["nap", "project", "bootstrap"])
assert forwarded == [("project", ["bootstrap"])], forwarded
except RuntimeError as exc:
assert "unknown project command" in str(exc)
else:
raise AssertionError("deprecated project bootstrap alias must not route")
print("ok: nap installer gateway route smoke passed")
+4
View File
@@ -4,6 +4,7 @@
import importlib.util
import pathlib
import sys
import tempfile
PROJECT_ID = "11111111-1111-1111-1111-111111111111"
@@ -21,6 +22,8 @@ def load_module():
def run():
mod = load_module()
test_state = tempfile.TemporaryDirectory()
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
saves = []
def save_auth(updates):
@@ -37,6 +40,7 @@ def run():
}
mod.save_auth = save_auth
mod.save_auth_file_credentials = save_auth
mod.replace_public_auth_state = lambda updates, clear_keys=(): save_auth(updates)
mod.request_json = lambda method, path: {
"id": PROJECT_ID,
"slug": "existing-project",
@@ -4,6 +4,7 @@
import importlib.util
import pathlib
import sys
import tempfile
def load_module():
@@ -30,6 +31,8 @@ def assert_raises_hashicorp_closed(fn):
def run():
mod = load_module()
test_state = tempfile.TemporaryDirectory()
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
calls = []
mod.DEFAULT_PROJECT_ID = "project-1"
+44
View File
@@ -0,0 +1,44 @@
"""Make supported standalone smoke programs part of the pytest gate."""
import pathlib
import subprocess
import sys
import pytest
SMOKES = [
"test_anonymous_auth_recovery.py",
"test_chat_secret_cli_flow.py",
"test_cli_dispatch_consolidation.py",
"test_gateway_cloud_routes_removed.py",
"test_gateway_configure_consistency.py",
"test_gateway_local_auth_refresh.py",
"test_gateway_local_vault_key.py",
"test_gateway_vault_setup_payload.py",
"test_live_auth_state_reload.py",
"test_mcp_memory_ergonomics_helpers.py",
"test_mcp_memory_node_encryption.py",
"test_nap_install_gateway_routes.py",
"test_project_attach_oauth.py",
"test_project_encryption_enable_payload.py",
"test_project_init_wizard.py",
"test_two_passphrase_boundary.py",
]
@pytest.mark.parametrize("filename", SMOKES)
def test_standalone_smoke(filename):
test_dir = pathlib.Path(__file__).resolve().parent
result = subprocess.run(
[sys.executable, str(test_dir / filename)],
cwd=test_dir.parent,
capture_output=True,
text=True,
timeout=90,
)
assert result.returncode == 0, (
f"{filename} failed with exit {result.returncode}\n"
f"stdout:\n{result.stdout[-4000:]}\n"
f"stderr:\n{result.stderr[-4000:]}"
)