diff --git a/README.md b/README.md index 77bd2df..64ad2b2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ This repository is intended to become the canonical source for: - `nap` installer and update UX. - Project bootstrap and status commands. - Local authenticated MCP wrapper launch/update behavior. -- Gateway command facade commands such as `nap gateway start`, `nap gateway logs`, and `nap gateway update`. +- Gateway lifecycle commands such as `nap gateway setup`, `repair`, `start`, + `status`, `logs`, `terminal`, `schedule`, and `vault`. Current state: @@ -20,6 +21,16 @@ Current state: the generated `napseer_mcp_server.py` worker between requests. - Gateway service compatibility remains in the worker while the standalone gateway image is built from the public gateway repository. +- `nap mcp status` and `nap doctor` run a fresh stdio initialization, + `tools/list`, and authenticated read probe. They deliberately report an + existing client connection as `not_observable`; a successful fresh probe + does not claim that a previously opened Codex transport is connected. +- Operator OAuth credentials remain in `.napseer/auth.json`. A repaired local + gateway uses a separate `.napseer/gateway-auth.json` worker identity, so + relay token renewal cannot rotate or overwrite the operator/MCP session. +- CLI terminal and schedule operations call the running gateway's + loopback-only, CSRF-protected control API. PTYs therefore live in the daemon + instead of disappearing when a CLI subprocess exits. Backend discovery publishes the release manifest and exact public source revision used for each bundle. @@ -30,5 +41,20 @@ Configure Codex and other long-lived stdio clients to launch: python3 ~/.local/share/napseer/napseer_mcp_supervisor.py ``` +For repositories that may be resumed after Codex opened a different working +directory, add a trusted project `.codex/config.toml` override with both +`cwd` and `NAPSEER_PROJECT_ROOT`. MCP processes inherit their launch directory; +they cannot infer a later client workspace switch over stdio. + +The public operator surface is intentionally small: + +```text +nap init | status | doctor | auth | project | mcp | gateway | update | version | help +``` + +Authentication has one recovery verb: `nap auth repair`. Normal refresh is +automatic. `nap gateway repair` creates the separate worker identity once; +`--replace` is required to replace an existing identity. + The direct wrapper remains the `nap` CLI implementation and an isolated protocol-debugging entrypoint. diff --git a/resources/scripts/nap_install.py b/resources/scripts/nap_install.py index bf9c969..42ac4aa 100644 --- a/resources/scripts/nap_install.py +++ b/resources/scripts/nap_install.py @@ -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}") diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index a7480ad..03278aa 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -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( diff --git a/tests/test_cli_command_system.py b/tests/test_cli_command_system.py index 5e076cd..50352f1 100644 --- a/tests/test_cli_command_system.py +++ b/tests/test_cli_command_system.py @@ -80,7 +80,7 @@ def test_init_and_auth_repair_normalize_to_existing_handlers(monkeypatch): assert calls == [ ("project", ["init", "--slug", "demo"]), - ("auth", ["refresh"]), + ("auth", ["repair"]), ] @@ -121,6 +121,7 @@ def test_doctor_reports_presence_without_exposing_values(tmp_path, monkeypatch): (install_dir / "napseer_mcp_supervisor.py").write_text("# supervisor\n", encoding="utf-8") monkeypatch.setattr(module, "INSTALL_DIR", install_dir) monkeypatch.setattr(module, "runtime_assets_missing", lambda: []) + monkeypatch.setattr(module, "mcp_runtime_probe", lambda: {"status": "ok", "transport": True, "read": True, "tool_count": 56}) monkeypatch.setattr( module, "state_dir_status", @@ -150,6 +151,7 @@ def test_doctor_does_not_create_state_directory(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setattr(module, "INSTALL_DIR", install_dir) monkeypatch.setattr(module, "runtime_assets_missing", lambda: []) + monkeypatch.setattr(module, "mcp_runtime_probe", lambda: {"status": "failed", "transport": False, "read": False}) result = module.doctor_status() @@ -229,7 +231,7 @@ def test_bundle_install_stages_validates_and_activates_atomically(tmp_path, monk result = module.install_assets() - assert result["cli_distribution"]["release_version"] == "0.2.0" + assert result["cli_distribution"]["release_version"] == "0.2.1" assert (install_dir / "current").is_symlink() assert (install_dir / "current" / "bundle-manifest.json").is_file() assert (install_dir / "current" / "napseer_mcp_server.py").is_file() diff --git a/tests/test_cli_dispatch_consolidation.py b/tests/test_cli_dispatch_consolidation.py index 8eaaba9..edcf5a1 100755 --- a/tests/test_cli_dispatch_consolidation.py +++ b/tests/test_cli_dispatch_consolidation.py @@ -126,6 +126,19 @@ def test_gateway_schedule_aliases_rejected(): expect_unknown(mod, args, "unknown gateway") +def test_gateway_schedule_list_uses_canonical_route(): + mod = load_module() + calls = [] + mod.cli_gateway_require_unlocked = lambda args, operation: None + mod.cli_gateway_service_call = lambda route, args: calls.append((route, dict(args))) or { + "status": "listed", + "schedules": [], + } + with contextlib.redirect_stdout(io.StringIO()): + mod.cli_main(["napseer_mcp_server.py", "gateway", "schedule", "list"]) + assert calls == [("/local-api/gateway/schedule/list", {})] + + def test_gateway_service_run_rejected(): """Lifecycle verbs (run, start, etc.) moved to `nap service`.""" mod = load_module() @@ -209,6 +222,7 @@ if __name__ == "__main__": test_gateway_status_aliases_rejected() test_gateway_terminal_aliases_rejected() test_gateway_schedule_aliases_rejected() + test_gateway_schedule_list_uses_canonical_route() test_gateway_service_run_rejected() test_gateway_vault_aliases_rejected() test_gateway_rotate_passphrase_aliases_rejected() diff --git a/tests/test_gateway_local_auth_refresh.py b/tests/test_gateway_local_auth_refresh.py index 9b0ea84..00fe34a 100644 --- a/tests/test_gateway_local_auth_refresh.py +++ b/tests/test_gateway_local_auth_refresh.py @@ -82,6 +82,7 @@ def run(): }) mod.REFRESH_TOKEN = "old-oauth-refresh" mod.REFRESH_EXPIRES_AT = "old-refresh-expiry" + mod.refresh_public_auth_state = lambda: mod.AUTH mod.request_form_json = lambda method, path, payload, token_required=False: { "access_token": "fresh-oauth-access", "refresh_token": "fresh-oauth-refresh", diff --git a/tests/test_gateway_relay_auth.py b/tests/test_gateway_relay_auth.py new file mode 100644 index 0000000..bfc6b9d --- /dev/null +++ b/tests/test_gateway_relay_auth.py @@ -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 == [] diff --git a/tests/test_gateway_terminal_pty_capture.py b/tests/test_gateway_terminal_pty_capture.py new file mode 100644 index 0000000..c9dbd4b --- /dev/null +++ b/tests/test_gateway_terminal_pty_capture.py @@ -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 diff --git a/tests/test_gateway_vault_setup_payload.py b/tests/test_gateway_vault_setup_payload.py index 67a8f47..0eed9f1 100644 --- a/tests/test_gateway_vault_setup_payload.py +++ b/tests/test_gateway_vault_setup_payload.py @@ -5,6 +5,7 @@ import base64 import importlib.util import pathlib import sys +import tempfile PROJECT_ID = "11111111-1111-1111-1111-111111111111" @@ -46,6 +47,8 @@ def assert_wrapped_record(mod, record, secret_kind, data_key_epoch=1): def run(): mod = load_module() + test_state = tempfile.TemporaryDirectory() + mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json" writes = [] mod.AUTH = {"agent_id": "gateway-agent-1", "account_id": ACCOUNT_ID} diff --git a/tests/test_gateway_worker_repair.py b/tests/test_gateway_worker_repair.py new file mode 100644 index 0000000..fa0d421 --- /dev/null +++ b/tests/test_gateway_worker_repair.py @@ -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" diff --git a/tests/test_mcp_memory_ergonomics_helpers.py b/tests/test_mcp_memory_ergonomics_helpers.py index 1154416..98642fa 100644 --- a/tests/test_mcp_memory_ergonomics_helpers.py +++ b/tests/test_mcp_memory_ergonomics_helpers.py @@ -16,6 +16,11 @@ def load_module(): module = importlib.util.module_from_spec(spec) assert spec and spec.loader spec.loader.exec_module(module) + # Keep project resolution independent from any real .napseer/auth.json in + # the checkout. Store the handle on the module so the directory remains + # alive for the complete test invocation. + module._test_state = tempfile.TemporaryDirectory() + module.AUTH_PATH = pathlib.Path(module._test_state.name) / "auth.json" module.AUTH = {"account_id": "acct-1", "token": "token", "token_expires_at": "later"} module.DEFAULT_PROJECT_ID = "project-1" module.project_memory_encryption_active = lambda project_id: False diff --git a/tests/test_mcp_memory_node_encryption.py b/tests/test_mcp_memory_node_encryption.py index 5a8c5f4..ef66241 100644 --- a/tests/test_mcp_memory_node_encryption.py +++ b/tests/test_mcp_memory_node_encryption.py @@ -114,6 +114,8 @@ def encrypted_node(mod, full_path="/notes/alpha", content="secret needle", metad def run(): mod = load_module() + test_state = tempfile.TemporaryDirectory() + mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json" mod.AUTH = {"account_id": ACCOUNT_ID, "token": "token", "token_expires_at": "later"} mod.PROJECT_VAULT_PASSPHRASE = PASSPHRASE mod.DEFAULT_PROJECT_ID = "project-1" @@ -121,6 +123,7 @@ def run(): mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear() cache_secret(mod) real_active_memory_secret = mod.active_memory_secret + real_get_node_by_id = mod.get_node_by_id envelope = mod.encrypt_memory_bytes( "project-1", @@ -247,8 +250,9 @@ def run(): mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear() cache_secret(mod) node = encrypted_node(mod, metadata={"private": "secret metadata"}) + mod.get_node_by_id = real_get_node_by_id mod.request_json = lambda method, path, payload=None, **kwargs: node - read = mod.get_node_by_path({"path": "/notes/alpha"}) + read = mod.get_node_by_id({"node_id": "node-1"}) assert read["content_text"] == "secret needle" assert read["metadata"] == {"private": "secret metadata"} @@ -257,8 +261,8 @@ def run(): moved["folder_path"] = "/renamed" mod.request_json = lambda method, path, payload=None, **kwargs: {"items": [moved]} listed = mod.list_project_nodes({"limit": 10, "include_content": True}) - assert listed["items"][0]["content_text"] == "secret needle" - assert listed["items"][0]["metadata"] == {"private": "secret metadata"} + assert "content_text" not in listed["items"][0] + assert listed["items"][0]["full_path"] == "/renamed/alpha" mod.request_json = lambda method, path, payload=None, **kwargs: {"items": [moved]} compact = mod.list_project_nodes({"limit": 10}) @@ -311,7 +315,7 @@ def run(): legacy_moved["full_path"] = "/renamed/legacy" mod.request_json = lambda method, path, payload=None, **kwargs: legacy_moved try: - mod.get_node_by_path({"path": "/renamed/legacy"}) + mod.get_node_by_id({"node_id": "node-1"}) except RuntimeError: pass else: diff --git a/tests/test_nap_install_gateway_routes.py b/tests/test_nap_install_gateway_routes.py index 86e2aeb..5fdebd3 100644 --- a/tests/test_nap_install_gateway_routes.py +++ b/tests/test_nap_install_gateway_routes.py @@ -7,7 +7,7 @@ import sys def load_module(): - script_path = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "nap_install.py" + script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "nap_install.py" spec = importlib.util.spec_from_file_location("nap_install", script_path) module = importlib.util.module_from_spec(spec) assert spec and spec.loader @@ -25,10 +25,14 @@ def run(): assert calls == [] assert mod.handle_gateway(["vault", "--help"]) == 0 - assert calls[-1] == ("gateway", ["vault", "--help"]) + assert calls == [] - assert mod.handle_gateway(["vault", "list"]) == 0 - assert calls[-1] == ("gateway", ["vault", "list"]) + try: + mod.handle_gateway(["vault", "list"]) + except RuntimeError as exc: + assert "gateway vault status" in str(exc) + else: + raise AssertionError("removed gateway vault list alias must not route") assert mod.handle_gateway(["vault", "rotate-secret", "--kind", "memory"]) == 0 assert calls[-1] == ("gateway", ["vault", "rotate-secret", "--kind", "memory"]) @@ -39,18 +43,22 @@ def run(): assert "nap gateway vault process" in str(exc), str(exc) else: raise AssertionError("deprecated gateway process shortcut must not route") - assert mod.main(["nap", "chat", "secret", "setup"]) == 0 - assert calls[-1] == ("chat", ["secret", "setup"]) + try: + mod.main(["nap", "chat", "secret", "setup"]) + except RuntimeError as exc: + assert "MCP chat-secret tools" in str(exc) + else: + raise AssertionError("removed chat compatibility command must not route") try: mod.handle_gateway(["config"]) except RuntimeError as exc: - assert "unknown gateway subcommand" in str(exc) + assert "unknown gateway command" in str(exc) else: raise AssertionError("gateway config alias must not route") try: mod.handle_gateway(["vault-setup", "list"]) except RuntimeError as exc: - assert "unknown gateway subcommand" in str(exc) + assert "unknown gateway command" in str(exc) else: raise AssertionError("deprecated gateway vault-setup alias must not route") @@ -75,12 +83,12 @@ def run(): assert "unknown" in str(exc), (argv, str(exc)) else: raise AssertionError(f"deprecated route must not be accepted at install level: {argv}") - # nap project bootstrap is now a deprecated alias handled by the MCP server - # (the install side forwards it; the MCP side raises with a clear error message). - forwarded = [] - mod.run_wrapper = lambda command, args: forwarded.append((command, args)) or 0 - mod.main(["nap", "project", "bootstrap"]) - assert forwarded == [("project", ["bootstrap"])], forwarded + try: + mod.main(["nap", "project", "bootstrap"]) + 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") diff --git a/tests/test_project_attach_oauth.py b/tests/test_project_attach_oauth.py index 80f3ad3..057ef1b 100644 --- a/tests/test_project_attach_oauth.py +++ b/tests/test_project_attach_oauth.py @@ -4,6 +4,7 @@ import importlib.util import pathlib import sys +import tempfile PROJECT_ID = "11111111-1111-1111-1111-111111111111" @@ -21,6 +22,8 @@ def load_module(): def run(): mod = load_module() + test_state = tempfile.TemporaryDirectory() + mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json" saves = [] def save_auth(updates): @@ -37,6 +40,7 @@ def run(): } mod.save_auth = save_auth mod.save_auth_file_credentials = save_auth + mod.replace_public_auth_state = lambda updates, clear_keys=(): save_auth(updates) mod.request_json = lambda method, path: { "id": PROJECT_ID, "slug": "existing-project", diff --git a/tests/test_project_encryption_enable_payload.py b/tests/test_project_encryption_enable_payload.py index ad4384a..9ae5551 100644 --- a/tests/test_project_encryption_enable_payload.py +++ b/tests/test_project_encryption_enable_payload.py @@ -4,6 +4,7 @@ import importlib.util import pathlib import sys +import tempfile def load_module(): @@ -30,6 +31,8 @@ def assert_raises_hashicorp_closed(fn): def run(): mod = load_module() + test_state = tempfile.TemporaryDirectory() + mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json" calls = [] mod.DEFAULT_PROJECT_ID = "project-1" diff --git a/tests/test_standalone_smokes.py b/tests/test_standalone_smokes.py new file mode 100644 index 0000000..84d5315 --- /dev/null +++ b/tests/test_standalone_smokes.py @@ -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:]}" + )