Repair MCP diagnostics and gateway lifecycle
This commit is contained in:
@@ -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']}",
|
||||
}
|
||||
)
|
||||
return 0
|
||||
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",
|
||||
}
|
||||
if command in removed:
|
||||
raise RuntimeError(f"unknown command: {command}; {removed[command]}")
|
||||
raise RuntimeError(f"unknown command: {command}")
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user