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. - `nap` installer and update UX.
- Project bootstrap and status commands. - Project bootstrap and status commands.
- Local authenticated MCP wrapper launch/update behavior. - 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: Current state:
@@ -20,6 +21,16 @@ Current state:
the generated `napseer_mcp_server.py` worker between requests. the generated `napseer_mcp_server.py` worker between requests.
- Gateway service compatibility remains in the worker while the standalone - Gateway service compatibility remains in the worker while the standalone
gateway image is built from the public gateway repository. 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 Backend discovery publishes the release manifest and exact public source
revision used for each bundle. 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 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 The direct wrapper remains the `nap` CLI implementation and an isolated
protocol-debugging entrypoint. protocol-debugging entrypoint.
+127 -61
View File
@@ -20,6 +20,7 @@ import json
import hashlib import hashlib
import os import os
import pathlib import pathlib
import select
import shutil import shutil
import signal import signal
import socket import socket
@@ -55,8 +56,8 @@ RUNTIME_SCRIPT_NAMES = (
"napseer_mcp_supervisor.py", "napseer_mcp_supervisor.py",
) )
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024))) MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
CLI_RELEASE_VERSION = "0.2.0" CLI_RELEASE_VERSION = "0.2.1"
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-07-29" CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-08-01"
CLI_MINIMUM_CONTRACT_VERSION = "2026-05-19" CLI_MINIMUM_CONTRACT_VERSION = "2026-05-19"
CLI_BUNDLE_SCHEMA_VERSION = "napseer.cli.bundle.v1" CLI_BUNDLE_SCHEMA_VERSION = "napseer.cli.bundle.v1"
INSTALL_PATHS = { INSTALL_PATHS = {
@@ -95,13 +96,6 @@ COMMAND_METADATA = {
"update": {"mutates": True, "visible": True}, "update": {"mutates": True, "visible": True},
"version": {"mutates": False, "visible": True}, "version": {"mutates": False, "visible": True},
"help": {"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: if kind == "ui" and "--no-browser" not in args:
command.remove("--no-browser") command.remove("--no-browser")
with log_path.open("ab") as log: 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": if os.name != "nt":
kwargs["start_new_session"] = True kwargs["start_new_session"] = True
else: else:
@@ -850,41 +854,45 @@ def service_logs(kind, args):
def handle_gateway(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 [] rest = args[1:] if args else []
if subcommand in {"help", "-h", "--help"}: 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 start [--port PORT] Start the local gateway service.")
print(" nap gateway status Show the managed gateway process.") print(" nap gateway status Show the managed gateway process.")
print(" nap gateway configure [--command CMD] Configure the local gateway command.") print(" nap gateway setup [--command CMD] Create local storage and set the default command.")
print(" nap gateway setup Create or migrate local gateway storage.") 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 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 process Complete pending setup requests with the vault/content passphrase.")
print(" nap gateway vault rotate-secret --kind memory") print(" nap gateway vault rotate-secret --kind memory")
print(" nap chat secret setup Configure chat secret state with the vault/content passphrase.")
return 0 return 0
if subcommand == "vault" and rest and rest[0] in {"help", "-h", "--help"}: 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(" status Show local gateway state and pending setup requests.")
print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.") 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(" 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 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": if subcommand == "process":
return run_wrapper("gateway", ["vault", "process", *rest]) raise RuntimeError("unknown gateway command: process; use `nap gateway vault process`")
if subcommand == "start": if subcommand == "start":
return print_json(start_service("gateway", ["--no-browser", *rest])) or 0 return print_json(start_service("gateway", ["--no-browser", *rest])) or 0
if subcommand == "stop": if subcommand == "stop":
return print_json(stop_service("gateway")) or 0 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 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 return print_json(service_logs("gateway", rest)) or 0
if subcommand == "configure": if subcommand == "configure":
return run_wrapper("gateway", ["configure", *rest]) raise RuntimeError("unknown gateway command: configure; use `nap gateway setup --command CMD`")
if subcommand in {"setup", "restart", "kill", "vault", "terminal", "schedule", "service"}: 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]) 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): def print_json(value):
@@ -938,9 +946,9 @@ def print_command_help(command):
" serve Run the stable stdio supervisor." " serve Run the stable stdio supervisor."
), ),
"gateway": ( "gateway": (
"Usage: nap gateway [setup|start|stop|restart|status|logs|vault|terminal|schedule|service]\n" "Usage: nap gateway [start|stop|status|logs|setup|repair|terminal|schedule|vault]\n"
"Common lifecycle commands are setup, start, stop, restart, status, and logs.\n" "Use setup once, repair only when worker credentials are missing or revoked,\n"
"Vault, terminal, schedule, and service are advanced capability groups." "then operate the daemon through terminal, schedule, and vault."
), ),
"update": ( "update": (
"Usage: nap update\n" "Usage: nap update\n"
@@ -972,16 +980,86 @@ def mcp_status():
worker = runtime_asset_path("napseer_mcp_server.py") worker = runtime_asset_path("napseer_mcp_server.py")
supervisor = runtime_asset_path("napseer_mcp_supervisor.py") supervisor = runtime_asset_path("napseer_mcp_supervisor.py")
missing = runtime_assets_missing() missing = runtime_assets_missing()
probe = mcp_runtime_probe() if not missing else {"status": "not_run", "transport": False, "read": False}
return { 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(), "supervisor_installed": supervisor.is_file(),
"worker_installed": worker.is_file(), "worker_installed": worker.is_file(),
"runtime_assets_missing": missing, "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}", "serve_command": f"{sys.executable} {supervisor}",
"next": None if not missing else "nap mcp install", "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): def handle_mcp(args):
subcommand = args[0] if args else "status" subcommand = args[0] if args else "status"
rest = args[1:] 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"}) 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(): 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"}) 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 { return {
"status": "ok" if not issues else "repair_required", "status": "ok" if not issues else "repair_required",
"checks": { "checks": {
@@ -1037,6 +1122,8 @@ def doctor_status():
"project_attached": bool(auth.get("project_id")), "project_attached": bool(auth.get("project_id")),
"mcp_supervisor": runtime_asset_path("napseer_mcp_supervisor.py").is_file(), "mcp_supervisor": runtime_asset_path("napseer_mcp_supervisor.py").is_file(),
"mcp_worker": runtime_asset_path("napseer_mcp_server.py").is_file(), "mcp_worker": runtime_asset_path("napseer_mcp_server.py").is_file(),
"mcp_fresh_probe": probe,
"client_connection": "not_observable",
}, },
"issues": issues, "issues": issues,
} }
@@ -1090,18 +1177,12 @@ def main(argv):
if command == "version": if command == "version":
print_json(version_status()) print_json(version_status())
return 0 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": if command == "update":
result = install_assets() result = install_assets()
print_json({**result, "status": "updated", "message": "Napseer CLI and runtime scripts are updated."}) print_json({**result, "status": "updated", "message": "Napseer CLI and runtime scripts are updated."})
return 0 return 0
if command == "gateway": if command == "gateway":
return handle_gateway(args) return handle_gateway(args)
if command == "chat":
return run_wrapper("chat", args or ["secret", "status"])
if command == "status": if command == "status":
return run_wrapper("configure", args) return run_wrapper("configure", args)
if command == "project": if command == "project":
@@ -1110,38 +1191,23 @@ def main(argv):
if args[0] == "bootstrap": if args[0] == "bootstrap":
raise RuntimeError("unknown project command: bootstrap") raise RuntimeError("unknown project command: bootstrap")
return run_wrapper("project", args) 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 command == "auth":
if args and args[0] == "repair": 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": 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) return run_wrapper("auth", args)
if command == "feedback": removed = {
return run_wrapper("feedback", args) "install": "use `nap mcp install`",
if command == "where": "where": "use `nap doctor` or `nap mcp status`",
state = state_dir_status() "chat": "use the authenticated MCP chat-secret tools",
print_json( "plan": "use the authenticated MCP planning tools",
{ "lineage": "use the authenticated MCP lineage tools",
"bin": shutil.which("nap") or str(BIN_DIR / "nap"), "agent": "use the authenticated MCP agent tools",
"install_dir": str(INSTALL_DIR), "feedback": "use the authenticated MCP feedback tools",
"wrapper": str(runtime_asset_path("napseer_mcp_server.py")), }
"supervisor": str(runtime_asset_path("napseer_mcp_supervisor.py")), if command in removed:
"cwd": str(pathlib.Path.cwd()), raise RuntimeError(f"unknown command: {command}; {removed[command]}")
"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
raise RuntimeError(f"unknown command: {command}") raise RuntimeError(f"unknown command: {command}")
+249 -28
View File
@@ -191,6 +191,9 @@ class RelaySocketClosed(RuntimeError):
def local_state_dir(): 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" 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_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" VAULT_PATH = AUTH_DIR / "vault.json"
LOCAL_VAULT_KEY_PATH = AUTH_DIR / "vault.local-key" LOCAL_VAULT_KEY_PATH = AUTH_DIR / "vault.local-key"
GATEWAY_RELAY_SECRET_PATH = AUTH_DIR / "gateway-relay-secret.json" 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_ACTIVE_LANES = set()
GATEWAY_LISTENER_UNSTABLE_UNTIL = 0.0 GATEWAY_LISTENER_UNSTABLE_UNTIL = 0.0
GATEWAY_LISTENER_STABILITY_LOCK = threading.RLock() GATEWAY_LISTENER_STABILITY_LOCK = threading.RLock()
AUTH_RENEW_LOCK = threading.Lock()
GATEWAY_VAULT_SETUP_THREAD_STARTED = False GATEWAY_VAULT_SETUP_THREAD_STARTED = False
GATEWAY_PERIODIC_LOG_STATE = {} GATEWAY_PERIODIC_LOG_STATE = {}
WS_CLOSE_DETAILS = {} WS_CLOSE_DETAILS = {}
@@ -2246,6 +2251,26 @@ def gateway_terminal_restart(args=None):
def gateway_terminal_capture(args=None): def gateway_terminal_capture(args=None):
args = args or {} 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")): if args.get("_internal_target") and (args.get("tmux_target") or args.get("target")):
target = str(args.get("tmux_target") or args.get("target")) target = str(args.get("tmux_target") or args.get("target"))
elif args.get("terminal_id"): elif args.get("terminal_id"):
@@ -2840,7 +2865,7 @@ def mark_gateway_listener_unstable(reason, lane_id=None):
with GATEWAY_LISTENER_STABILITY_LOCK: with GATEWAY_LISTENER_STABILITY_LOCK:
GATEWAY_LISTENER_UNSTABLE_UNTIL = max(GATEWAY_LISTENER_UNSTABLE_UNTIL, until) GATEWAY_LISTENER_UNSTABLE_UNTIL = max(GATEWAY_LISTENER_UNSTABLE_UNTIL, until)
gateway_periodic_log( gateway_periodic_log(
"relay_listener_path_marked_unstable", f"relay_listener_path_marked_unstable:{lane_id or 'all'}",
30, 30,
"relay_listener_path_marked_unstable", "relay_listener_path_marked_unstable",
level="warn", level="warn",
@@ -5090,6 +5115,7 @@ def handle_gateway_relay_session(connect_request, listener_sock=None):
def gateway_relay_loop(lane_id="lane-1"): 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 global GATEWAY_LISTENER_CONNECTED, GATEWAY_LISTENER_LAST_CONNECTED_AT, GATEWAY_LISTENER_LAST_ERROR, GATEWAY_LISTENER_LAST_ERROR_AT
closed_by_relay_streak = 0 closed_by_relay_streak = 0
failure_streak = 0
gateway_log( gateway_log(
"relay_listener_thread_started", "relay_listener_thread_started",
project_id=current_project_id(), 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 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(): if gateway_remote_should_run() and lane_index <= gateway_listener_target_lanes():
refresh_public_auth_state() 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() project_id = current_project_id()
agent_id = AUTH.get("agent_id") agent_id = AUTH.get("agent_id")
gateway_periodic_log( gateway_periodic_log(
"relay_listener_connecting", f"relay_listener_connecting:{lane_id}",
30, 30,
"relay_listener_connecting", "relay_listener_connecting",
project_id=project_id, project_id=project_id,
@@ -5140,6 +5168,7 @@ def gateway_relay_loop(lane_id="lane-1"):
}, },
) )
connected_at = time.time() connected_at = time.time()
failure_streak = 0
with GATEWAY_LISTENER_STABILITY_LOCK: with GATEWAY_LISTENER_STABILITY_LOCK:
GATEWAY_LISTENER_ACTIVE_LANES.add(lane_id) GATEWAY_LISTENER_ACTIVE_LANES.add(lane_id)
GATEWAY_LISTENER_CONNECTED = bool(GATEWAY_LISTENER_ACTIVE_LANES) 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) 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) delay = min(30.0, 2.0 + (closed_by_relay_streak * 2.0)) + random.uniform(0.0, 1.5)
gateway_periodic_log( gateway_periodic_log(
"relay_listener_backoff", f"relay_listener_backoff:{lane_id}",
30, 30,
"relay_listener_backoff", "relay_listener_backoff",
level="warn", level="warn",
@@ -5246,7 +5275,7 @@ def gateway_relay_loop(lane_id="lane-1"):
readiness = gateway_remote_readiness() readiness = gateway_remote_readiness()
blockers = gateway_remote_blockers(readiness) blockers = gateway_remote_blockers(readiness)
gateway_periodic_log( gateway_periodic_log(
"relay_listener_waiting", f"relay_listener_waiting:{lane_id}",
60, 60,
"relay_listener_waiting", "relay_listener_waiting",
level="warn", level="warn",
@@ -5261,6 +5290,7 @@ def gateway_relay_loop(lane_id="lane-1"):
reason=",".join(blockers) if blockers else "waiting", reason=",".join(blockers) if blockers else "waiting",
) )
except Exception as exc: except Exception as exc:
failure_streak += 1
closed_by_relay_streak = 0 closed_by_relay_streak = 0
with GATEWAY_LISTENER_STABILITY_LOCK: with GATEWAY_LISTENER_STABILITY_LOCK:
GATEWAY_LISTENER_ACTIVE_LANES.discard(lane_id) 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() GATEWAY_LISTENER_LAST_ERROR_AT = iso_now()
mark_gateway_listener_unstable(type(exc).__name__, lane_id=lane_id) mark_gateway_listener_unstable(type(exc).__name__, lane_id=lane_id)
gateway_periodic_log( gateway_periodic_log(
"relay_listener_error", f"relay_listener_error:{lane_id}",
30, 30,
"relay_listener_error", "relay_listener_error",
level="error", level="error",
@@ -5288,7 +5318,8 @@ def gateway_relay_loop(lane_id="lane-1"):
gateway_remote_enabled=gateway_remote_should_run(), gateway_remote_enabled=gateway_remote_should_run(),
relay_listener_unstable=time.time() < GATEWAY_LISTENER_UNSTABLE_UNTIL, 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(): 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 oauth_refresh_error = None
if AUTH.get("account_mode") in {"operator_account", "operator_project", "claimed"} and REFRESH_TOKEN: if AUTH.get("account_mode") in {"operator_account", "operator_project", "claimed"} and REFRESH_TOKEN:
try: try:
@@ -6835,6 +6866,15 @@ def renew_auth():
return renew_anonymous_auth_with_ssh() 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): def jwt_claims(token):
try: try:
parts = str(token or "").split(".") 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(): def gateway_service_open_local_from_env():
if not vault_exists() or gateway_is_unlocked(): if not vault_exists() or gateway_is_unlocked():
return False 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") raise RuntimeError("gateway vault is not configured; run nap gateway configure first")
if not gateway_is_unlocked(): if not gateway_is_unlocked():
gateway_open_local(cli_option(args, "--passphrase", default=None)) 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) require_unlocked(operation)
@@ -11302,6 +11510,13 @@ def start_local_ui(args):
if parsed_path.path == "/gateway/status": if parsed_path.path == "/gateway/status":
self.send_json(200, gateway_status()) self.send_json(200, gateway_status())
return 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": if parsed_path.path == "/local-api/state":
self.send_json(200, self.local_state_payload(urllib.parse.parse_qs(parsed_path.query))) self.send_json(200, self.local_state_payload(urllib.parse.parse_qs(parsed_path.query)))
return return
@@ -13669,7 +13884,7 @@ def safe_error_matches(exc, *, status=None, service_code=None):
def safe_auth_refresh_error(): def safe_auth_refresh_error():
return SafeToolError( return SafeToolError(
"auth_refresh_failed", "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`.", "fails, run `nap auth login` and then `nap project attach`.",
status=401, status=401,
) )
@@ -13760,8 +13975,6 @@ def tool_contract_metadata(name):
metadata["dry_run_supported"] = True metadata["dry_run_supported"] = True
if name in {"nap_plan_transition", "nap_crontab_put"}: if name in {"nap_plan_transition", "nap_crontab_put"}:
metadata["dangerous"] = True metadata["dangerous"] = True
if name == "nap_auth_refresh":
metadata["replaces"] = "nap_login"
return metadata return metadata
@@ -14630,11 +14843,11 @@ def cli_main(argv):
print("""Usage: print("""Usage:
nap auth status nap auth status
nap auth login [--frontend-url URL] [--api-base-url URL] [--no-browser] [--timeout SECONDS] 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. `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. 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`.""") `login-local` remains a hidden compatibility alias for `login`.""")
return return
if subcommand in {"login", "login-local", "operator-login"}: 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), "port": cli_option(args, "--port", default=0),
}), indent=2)) }), indent=2))
return return
if subcommand == "refresh": if subcommand in {"refresh", "repair"}:
print(json.dumps(renew_auth(), indent=2)) print(json.dumps(renew_auth(), indent=2))
return return
if subcommand in {"status", "show"}: if subcommand in {"status", "show"}:
@@ -14764,13 +14977,20 @@ Refresh is automatic during authenticated calls; use `refresh` only for recovery
if subcommand == "configure": if subcommand == "configure":
print(json.dumps(cli_gateway_configure(args), indent=2)) print(json.dumps(cli_gateway_configure(args), indent=2))
return 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": if subcommand == "restart":
cli_gateway_require_unlocked(args, "restarting gateway terminal") 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 return
if subcommand == "kill": if subcommand == "kill":
cli_gateway_require_unlocked(args, "killing gateway terminal") 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 return
if subcommand == "terminal": if subcommand == "terminal":
action = args[0] if args else "list" 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") cli_gateway_require_unlocked(terminal_args, "managing gateway terminals")
terminal_id = cli_option(terminal_args, "--terminal", "--terminal-id", "--id", default=None) terminal_id = cli_option(terminal_args, "--terminal", "--terminal-id", "--id", default=None)
if action == "list": 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 return
if action == "create": 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), "name": cli_option(terminal_args, "--name", default=None),
"command": cli_option(terminal_args, "--command", "--default-command", default=None), "command": cli_option(terminal_args, "--command", "--default-command", default=None),
}), indent=2)) }), indent=2))
return return
if action == "close": if action == "close":
target_id = terminal_id or (terminal_args[0] if terminal_args and not terminal_args[0].startswith("-") else None) 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 return
if action == "capture": 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 return
if action == "key": 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, "terminal_id": terminal_id,
"key": cli_option(terminal_args, "--key", default=None), "key": cli_option(terminal_args, "--key", default=None),
"text": cli_option(terminal_args, "--text", 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 continue
text_parts.append(value) text_parts.append(value)
text = " ".join(text_parts) 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 return
if action in {"ls", "status", "new", "rm", "remove", "send"}: if action in {"ls", "status", "new", "rm", "remove", "send"}:
raise RuntimeError( raise RuntimeError(
@@ -14835,6 +15055,7 @@ Refresh is automatic during authenticated calls; use `refresh` only for recovery
) )
schedule_args = args[1:] if args else [] schedule_args = args[1:] if args else []
cli_gateway_require_unlocked(schedule_args, "managing gateway schedules") cli_gateway_require_unlocked(schedule_args, "managing gateway schedules")
schedule_id = cli_option(schedule_args, "--schedule", "--schedule-id", "--id", default=None)
payload = { payload = {
"schedule_id": schedule_id, "schedule_id": schedule_id,
"name": cli_option(schedule_args, "--name", default=None), "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"): if cli_flag(schedule_args, "--enabled", "--on"):
payload["enabled"] = True payload["enabled"] = True
if action == "list": 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 return
if action == "create": 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 return
if action == "update": if action == "update":
if not payload.get("schedule_id"): if not payload.get("schedule_id"):
payload["schedule_id"] = schedule_args[0] if schedule_args and not schedule_args[0].startswith("-") else None 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 return
if action == "delete": if action == "delete":
if not payload.get("schedule_id"): if not payload.get("schedule_id"):
payload["schedule_id"] = schedule_args[0] if schedule_args and not schedule_args[0].startswith("-") else None 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 return
if action == "run": if action == "run":
if not payload.get("schedule_id"): if not payload.get("schedule_id"):
payload["schedule_id"] = schedule_args[0] if schedule_args and not schedule_args[0].startswith("-") else None 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 return
if action in {"ls", "status", "new", "edit", "rm", "remove", "run-now", "now", "schedules", "cron", "crons"}: if action in {"ls", "status", "new", "edit", "rm", "remove", "run-now", "now", "schedules", "cron", "crons"}:
raise RuntimeError( raise RuntimeError(
+4 -2
View File
@@ -80,7 +80,7 @@ def test_init_and_auth_repair_normalize_to_existing_handlers(monkeypatch):
assert calls == [ assert calls == [
("project", ["init", "--slug", "demo"]), ("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") (install_dir / "napseer_mcp_supervisor.py").write_text("# supervisor\n", encoding="utf-8")
monkeypatch.setattr(module, "INSTALL_DIR", install_dir) monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
monkeypatch.setattr(module, "runtime_assets_missing", lambda: []) 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( monkeypatch.setattr(
module, module,
"state_dir_status", "state_dir_status",
@@ -150,6 +151,7 @@ def test_doctor_does_not_create_state_directory(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
monkeypatch.setattr(module, "INSTALL_DIR", install_dir) monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
monkeypatch.setattr(module, "runtime_assets_missing", lambda: []) monkeypatch.setattr(module, "runtime_assets_missing", lambda: [])
monkeypatch.setattr(module, "mcp_runtime_probe", lambda: {"status": "failed", "transport": False, "read": False})
result = module.doctor_status() result = module.doctor_status()
@@ -229,7 +231,7 @@ def test_bundle_install_stages_validates_and_activates_atomically(tmp_path, monk
result = module.install_assets() 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").is_symlink()
assert (install_dir / "current" / "bundle-manifest.json").is_file() assert (install_dir / "current" / "bundle-manifest.json").is_file()
assert (install_dir / "current" / "napseer_mcp_server.py").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") 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(): def test_gateway_service_run_rejected():
"""Lifecycle verbs (run, start, etc.) moved to `nap service`.""" """Lifecycle verbs (run, start, etc.) moved to `nap service`."""
mod = load_module() mod = load_module()
@@ -209,6 +222,7 @@ if __name__ == "__main__":
test_gateway_status_aliases_rejected() test_gateway_status_aliases_rejected()
test_gateway_terminal_aliases_rejected() test_gateway_terminal_aliases_rejected()
test_gateway_schedule_aliases_rejected() test_gateway_schedule_aliases_rejected()
test_gateway_schedule_list_uses_canonical_route()
test_gateway_service_run_rejected() test_gateway_service_run_rejected()
test_gateway_vault_aliases_rejected() test_gateway_vault_aliases_rejected()
test_gateway_rotate_passphrase_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_TOKEN = "old-oauth-refresh"
mod.REFRESH_EXPIRES_AT = "old-refresh-expiry" 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: { mod.request_form_json = lambda method, path, payload, token_required=False: {
"access_token": "fresh-oauth-access", "access_token": "fresh-oauth-access",
"refresh_token": "fresh-oauth-refresh", "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 importlib.util
import pathlib import pathlib
import sys import sys
import tempfile
PROJECT_ID = "11111111-1111-1111-1111-111111111111" 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(): def run():
mod = load_module() mod = load_module()
test_state = tempfile.TemporaryDirectory()
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
writes = [] writes = []
mod.AUTH = {"agent_id": "gateway-agent-1", "account_id": ACCOUNT_ID} 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) module = importlib.util.module_from_spec(spec)
assert spec and spec.loader assert spec and spec.loader
spec.loader.exec_module(module) 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.AUTH = {"account_id": "acct-1", "token": "token", "token_expires_at": "later"}
module.DEFAULT_PROJECT_ID = "project-1" module.DEFAULT_PROJECT_ID = "project-1"
module.project_memory_encryption_active = lambda project_id: False 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(): def run():
mod = load_module() 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.AUTH = {"account_id": ACCOUNT_ID, "token": "token", "token_expires_at": "later"}
mod.PROJECT_VAULT_PASSPHRASE = PASSPHRASE mod.PROJECT_VAULT_PASSPHRASE = PASSPHRASE
mod.DEFAULT_PROJECT_ID = "project-1" mod.DEFAULT_PROJECT_ID = "project-1"
@@ -121,6 +123,7 @@ def run():
mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear() mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear()
cache_secret(mod) cache_secret(mod)
real_active_memory_secret = mod.active_memory_secret real_active_memory_secret = mod.active_memory_secret
real_get_node_by_id = mod.get_node_by_id
envelope = mod.encrypt_memory_bytes( envelope = mod.encrypt_memory_bytes(
"project-1", "project-1",
@@ -247,8 +250,9 @@ def run():
mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear() mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear()
cache_secret(mod) cache_secret(mod)
node = encrypted_node(mod, metadata={"private": "secret metadata"}) 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 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["content_text"] == "secret needle"
assert read["metadata"] == {"private": "secret metadata"} assert read["metadata"] == {"private": "secret metadata"}
@@ -257,8 +261,8 @@ def run():
moved["folder_path"] = "/renamed" moved["folder_path"] = "/renamed"
mod.request_json = lambda method, path, payload=None, **kwargs: {"items": [moved]} mod.request_json = lambda method, path, payload=None, **kwargs: {"items": [moved]}
listed = mod.list_project_nodes({"limit": 10, "include_content": True}) listed = mod.list_project_nodes({"limit": 10, "include_content": True})
assert listed["items"][0]["content_text"] == "secret needle" assert "content_text" not in listed["items"][0]
assert listed["items"][0]["metadata"] == {"private": "secret metadata"} assert listed["items"][0]["full_path"] == "/renamed/alpha"
mod.request_json = lambda method, path, payload=None, **kwargs: {"items": [moved]} mod.request_json = lambda method, path, payload=None, **kwargs: {"items": [moved]}
compact = mod.list_project_nodes({"limit": 10}) compact = mod.list_project_nodes({"limit": 10})
@@ -311,7 +315,7 @@ def run():
legacy_moved["full_path"] = "/renamed/legacy" legacy_moved["full_path"] = "/renamed/legacy"
mod.request_json = lambda method, path, payload=None, **kwargs: legacy_moved mod.request_json = lambda method, path, payload=None, **kwargs: legacy_moved
try: try:
mod.get_node_by_path({"path": "/renamed/legacy"}) mod.get_node_by_id({"node_id": "node-1"})
except RuntimeError: except RuntimeError:
pass pass
else: else:
+22 -14
View File
@@ -7,7 +7,7 @@ import sys
def load_module(): 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) spec = importlib.util.spec_from_file_location("nap_install", script_path)
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
assert spec and spec.loader assert spec and spec.loader
@@ -25,10 +25,14 @@ def run():
assert calls == [] assert calls == []
assert mod.handle_gateway(["vault", "--help"]) == 0 assert mod.handle_gateway(["vault", "--help"]) == 0
assert calls[-1] == ("gateway", ["vault", "--help"]) assert calls == []
assert mod.handle_gateway(["vault", "list"]) == 0 try:
assert calls[-1] == ("gateway", ["vault", "list"]) 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 mod.handle_gateway(["vault", "rotate-secret", "--kind", "memory"]) == 0
assert calls[-1] == ("gateway", ["vault", "rotate-secret", "--kind", "memory"]) 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) assert "nap gateway vault process" in str(exc), str(exc)
else: else:
raise AssertionError("deprecated gateway process shortcut must not route") raise AssertionError("deprecated gateway process shortcut must not route")
assert mod.main(["nap", "chat", "secret", "setup"]) == 0 try:
assert calls[-1] == ("chat", ["secret", "setup"]) 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: try:
mod.handle_gateway(["config"]) mod.handle_gateway(["config"])
except RuntimeError as exc: except RuntimeError as exc:
assert "unknown gateway subcommand" in str(exc) assert "unknown gateway command" in str(exc)
else: else:
raise AssertionError("gateway config alias must not route") raise AssertionError("gateway config alias must not route")
try: try:
mod.handle_gateway(["vault-setup", "list"]) mod.handle_gateway(["vault-setup", "list"])
except RuntimeError as exc: except RuntimeError as exc:
assert "unknown gateway subcommand" in str(exc) assert "unknown gateway command" in str(exc)
else: else:
raise AssertionError("deprecated gateway vault-setup alias must not route") raise AssertionError("deprecated gateway vault-setup alias must not route")
@@ -75,12 +83,12 @@ def run():
assert "unknown" in str(exc), (argv, str(exc)) assert "unknown" in str(exc), (argv, str(exc))
else: else:
raise AssertionError(f"deprecated route must not be accepted at install level: {argv}") 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 try:
# (the install side forwards it; the MCP side raises with a clear error message). mod.main(["nap", "project", "bootstrap"])
forwarded = [] except RuntimeError as exc:
mod.run_wrapper = lambda command, args: forwarded.append((command, args)) or 0 assert "unknown project command" in str(exc)
mod.main(["nap", "project", "bootstrap"]) else:
assert forwarded == [("project", ["bootstrap"])], forwarded raise AssertionError("deprecated project bootstrap alias must not route")
print("ok: nap installer gateway route smoke passed") print("ok: nap installer gateway route smoke passed")
+4
View File
@@ -4,6 +4,7 @@
import importlib.util import importlib.util
import pathlib import pathlib
import sys import sys
import tempfile
PROJECT_ID = "11111111-1111-1111-1111-111111111111" PROJECT_ID = "11111111-1111-1111-1111-111111111111"
@@ -21,6 +22,8 @@ def load_module():
def run(): def run():
mod = load_module() mod = load_module()
test_state = tempfile.TemporaryDirectory()
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
saves = [] saves = []
def save_auth(updates): def save_auth(updates):
@@ -37,6 +40,7 @@ def run():
} }
mod.save_auth = save_auth mod.save_auth = save_auth
mod.save_auth_file_credentials = 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: { mod.request_json = lambda method, path: {
"id": PROJECT_ID, "id": PROJECT_ID,
"slug": "existing-project", "slug": "existing-project",
@@ -4,6 +4,7 @@
import importlib.util import importlib.util
import pathlib import pathlib
import sys import sys
import tempfile
def load_module(): def load_module():
@@ -30,6 +31,8 @@ def assert_raises_hashicorp_closed(fn):
def run(): def run():
mod = load_module() mod = load_module()
test_state = tempfile.TemporaryDirectory()
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
calls = [] calls = []
mod.DEFAULT_PROJECT_ID = "project-1" 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:]}"
)