Repair MCP diagnostics and gateway lifecycle
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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")),
|
|
||||||
"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']}",
|
|
||||||
}
|
}
|
||||||
)
|
if command in removed:
|
||||||
return 0
|
raise RuntimeError(f"unknown command: {command}; {removed[command]}")
|
||||||
raise RuntimeError(f"unknown command: {command}")
|
raise RuntimeError(f"unknown command: {command}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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).
|
|
||||||
forwarded = []
|
|
||||||
mod.run_wrapper = lambda command, args: forwarded.append((command, args)) or 0
|
|
||||||
mod.main(["nap", "project", "bootstrap"])
|
mod.main(["nap", "project", "bootstrap"])
|
||||||
assert forwarded == [("project", ["bootstrap"])], forwarded
|
except RuntimeError as exc:
|
||||||
|
assert "unknown project command" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("deprecated project bootstrap alias must not route")
|
||||||
|
|
||||||
print("ok: nap installer gateway route smoke passed")
|
print("ok: nap installer gateway route smoke passed")
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:]}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user