diff --git a/README.md b/README.md index 67d0ed1..d783f15 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,12 @@ Current state: `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. +- `nap auth login` stores the shared account session in the user data folder + (`~/.local/share/napseer/auth.json` on Linux; platform user data directories + on macOS/Windows). `NAPSEER_USER_DATA_DIR` overrides that location. + `nap auth login --project` selects a local override. Legacy repository + credentials remain an explicit override until `nap auth migrate` moves them. + Gateway identities remain separate in `.napseer/gateway-auth.json`. - `.napseer/project.json` is the commit-safe project locator. It contains only the schema, API origin, project UUID, and slug; it never contains tokens, account or worker identity, encryption state, keys, passphrases, or claim @@ -77,28 +80,36 @@ client reconnect after updating. The public operator surface is intentionally small: ```text -nap init | status | doctor | auth | project | mcp | gateway | update | version | help +nap init | status | doctor | auth | project | files | export | 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. -Project-scoped MCP tools are zero-login by default. In a new workspace, the -first such tool enrolls an anonymous worker and creates its first project -automatically; `nap init` remains the explicit equivalent. Anonymous access -and refresh-token or SSH-key recovery continue without operator -authentication. Authentication through `nap project claim` is required only -after the anonymous account reaches its service limit (currently one project -or 100 stored nodes), or when its durable local enrollment identity is no -longer recoverable. +Agents start only after the user configures an account login or a delegated API +key. With an account login, `nap init` creates a project owned by that account. +A committed `.napseer/project.json` always selects the existing project; it +never causes a replacement to be created. Concurrent refreshes share a bounded +credential lock, while project overrides remain independent. Network failures +preserve credentials and offer recovery through `nap auth repair`. -A cloned repository that already commits `.napseer/project.json` is not a new -workspace: `nap init` and project-scoped MCP tools fail closed instead of -creating a duplicate anonymous project. Run `nap project attach`; the OAuth -approval is constrained to the locator's project after normal server-side -access validation. If that project is still anonymous, claim it from a machine -that retains its enrollment identity, then attach from the new computer. +`nap auth keys --help` describes scoped API key issuance; +`nap auth api-key --env VARIABLE_NAME` installs a delegated key privately. +Keys can expire or be revoked and cannot approve agent decisions. Use separate +keys when agents need separate identities or permissions. + +Upgrading from 0.2.x to 0.3.0 requires the current bootstrap once because old +installers have a fixed list of runtime files. `nap update` on those versions +rejects the new bundle before changing the active installation. Run: + +```sh +curl -fsSL https://api.napseer.com/install | python3 - +``` + +The bootstrap verifies every asset and switches the runtime atomically without +changing credentials or project locators. Restart existing MCP client sessions +after this upgrade; subsequent releases use `nap update` normally. In Bash, run commands as `nap update`, `nap auth login`, and so on. A leading `!` is shell history expansion: `!nap update` replays the most recent command diff --git a/resources/scripts/nap_install.py b/resources/scripts/nap_install.py index bc29e00..e88bcba 100644 --- a/resources/scripts/nap_install.py +++ b/resources/scripts/nap_install.py @@ -43,6 +43,7 @@ SCRIPT_NAMES = ( "napseer_mcp_server.py", "napseer_mcp_supervisor.py", "napseer_spake2.py", + "napseer_credentials.py", "terminal_init.py", "terminal_protocol.py", "terminal_pty_manager.py", @@ -57,6 +58,7 @@ LOCAL_HOST = "127.0.0.1" DEFAULT_GATEWAY_PORT = os.environ.get("NAPSEER_GATEWAY_PORT") RUNTIME_SCRIPT_NAMES = ( "napseer_spake2.py", + "napseer_credentials.py", "terminal_init.py", "terminal_protocol.py", "terminal_pty_manager.py", @@ -64,15 +66,18 @@ 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.2" -CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-08-01" -CLI_MINIMUM_CONTRACT_VERSION = "2026-05-19" +CLI_RELEASE_VERSION = "0.3.0" +CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-10" +# Older installers have a fixed asset list and would omit the credentials module. +# Reject them before activation; the published bootstrap performs the upgrade. +CLI_MINIMUM_CONTRACT_VERSION = "2026-09-10" CLI_BUNDLE_SCHEMA_VERSION = "napseer.cli.bundle.v1" INSTALL_PATHS = { "nap_install.py": "nap_install.py", "napseer_mcp_server.py": "napseer_mcp_server.py", "napseer_mcp_supervisor.py": "napseer_mcp_supervisor.py", "napseer_spake2.py": "napseer_spake2.py", + "napseer_credentials.py": "napseer_credentials.py", "terminal_init.py": "terminal/__init__.py", "terminal_protocol.py": "terminal/protocol.py", "terminal_pty_manager.py": "terminal/pty_manager.py", @@ -88,6 +93,8 @@ CANONICAL_COMMANDS = ( ("reindex", "Rebuild the current project's local memory index."), ("auth", "Authenticate an account or repair credentials."), ("project", "Create, attach, claim, or inspect a project."), + ("files", "List, upload, download, or archive project files."), + ("export", "Export project documentation and files to a new ZIP archive."), ("mcp", "Install, update, inspect, or serve the local MCP runtime."), ("gateway", "Set up and operate the local gateway."), ("update", "Update the installed CLI and runtime bundle."), @@ -101,6 +108,8 @@ COMMAND_METADATA = { "reindex": {"mutates": True, "visible": True}, "auth": {"mutates": True, "visible": True}, "project": {"mutates": True, "visible": True}, + "files": {"mutates": True, "visible": True}, + "export": {"mutates": True, "visible": True}, "mcp": {"mutates": True, "visible": True}, "gateway": {"mutates": True, "visible": True}, "update": {"mutates": True, "visible": True}, @@ -503,6 +512,7 @@ def activate_release(release_dir): "napseer_mcp_server.py", "napseer_mcp_supervisor.py", "napseer_spake2.py", + "napseer_credentials.py", ): path = INSTALL_DIR / filename preserve_legacy_path(path) @@ -632,11 +642,15 @@ def cwd_state_dir(): def state_dir_status(): preferred = preferred_state_dir() + from napseer_credentials import CredentialStore + try: auth_path, credential_source = CredentialStore(preferred).selected() + except RuntimeError: auth_path, credential_source = CredentialStore(preferred).user, "invalid_selection" return { + "credential_source": credential_source, "active_state_dir": str(preferred), - "active_auth_path": str(preferred / "auth.json"), + "active_auth_path": str(auth_path), "state_dir": str(preferred), - "auth_path": str(preferred / "auth.json"), + "auth_path": str(auth_path), "state_dir_exists": preferred.exists(), "state_dir_message": ( ".napseer directory is active." @@ -1077,8 +1091,17 @@ def print_command_help(command): "Usage: nap auth [status|login|repair]\n" " login Authenticate an account; does not select a project.\n" " status Show credential state without credential values.\n" - " repair Recover failed automatic credential renewal." + " repair Recover failed automatic credential renewal.\n" + " logout [--project] Revoke the selected login.\n" + " login --project Store a separate project login in user data.\n" + " migrate Move legacy project credentials into user data.\n" + " use-user Explicitly select the general login for this folder.\n" + " api-key --env NAME Configure project access from a named environment variable.\n" + " keys list|create|revoke Manage named, expiring delegated access." + ), + "files": "Usage: nap files list|upload|download|archive [--path FILE] [--id UUID] [--node-id UUID] [--cursor CURSOR] [--limit N]", + "export": "Usage: nap export --path NEW_ARCHIVE.zip\nExports active project nodes and files; refuses changes during export or an existing destination.", "project": ( "Usage: nap project [init|create|attach|claim|status|encryption]\n" " init Initialize a fresh folder (same workflow as `nap init`).\n" @@ -1342,11 +1365,14 @@ def doctor_status(): auth_path = pathlib.Path(state["active_auth_path"]) auth = {} auth_error = None - if auth_path.exists(): - try: - auth = json.loads(auth_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - auth_error = "auth file is unreadable or invalid JSON" + try: + from napseer_credentials import CredentialStore, read_json + store=CredentialStore(preferred_state_dir()) + auth=store.read() + locator=read_json(preferred_state_dir()/"project.json") + if not auth.get("project_id"): auth["project_id"]=locator.get("project_id") + except RuntimeError: + auth_error="credential state is unreadable; run nap auth login or inspect the selected override" missing = runtime_assets_missing() issues = [] setup = [] @@ -1355,13 +1381,13 @@ def doctor_status(): elif not auth_path.exists(): setup.append({ "code": "project_not_initialized", - "message": "current folder is not initialized; anonymous setup is available without login", - "next": "nap init or invoke a project-scoped MCP tool", + "message": "current folder is not initialized; configure a user login with nap auth login", + "next": "nap auth login", }) elif not auth.get("project_id"): setup.append({ "code": "project_not_configured", - "message": "no project is configured for this folder; anonymous setup is available without login", + "message": "no project is configured for this folder; nap init uses the configured user login", "next": "nap init", }) if missing: @@ -1466,6 +1492,8 @@ def main(argv): return 0 if command == "gateway": return handle_gateway(args) + if command in {"files", "export"}: + return run_wrapper(command, args) if command == "status": return run_wrapper("configure", args) if command == "project": @@ -1478,7 +1506,7 @@ def main(argv): if args and args[0] in {"login", "login-local", "operator-login"}: validate_cli_options( args[1:], - flags={"--no-browser"}, + flags={"--no-browser", "--project"}, value_options={ "--frontend-url", "--api-base-url", diff --git a/resources/scripts/napseer_credentials.py b/resources/scripts/napseer_credentials.py new file mode 100644 index 0000000..4c67e83 --- /dev/null +++ b/resources/scripts/napseer_credentials.py @@ -0,0 +1,240 @@ +"""User-owned credentials, separate from repository identity and gateway state. + +No network, enrollment, or credential output belongs in this module. +""" +from contextlib import contextmanager +import hashlib +import json +import os +from pathlib import Path +import stat +import sys +import tempfile +import threading +import time + + +class CredentialError(RuntimeError): + pass + + +CREDENTIAL_KEYS = { + "base_url", "account_id", "token", "access_token", "refresh_token", + "token_expires_at", "refresh_expires_at", "account_mode", "oauth_client_id", + "oauth_scope", "logged_out", "credential_kind", +} +SELECTION_KEYS = { + "project_id", "project_slug", "project_name", "project_encryption_state", + "project_signing_key_fingerprint", "credential_profile", +} +_registry_lock = threading.Lock() +_thread_locks = {} +_held = threading.local() + + +def user_data_dir(): + if os.environ.get("NAPSEER_USER_DATA_DIR"): + return Path(os.environ["NAPSEER_USER_DATA_DIR"]).expanduser().absolute() + if sys.platform == "win32": + return Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "napseer" + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / "napseer" + return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "napseer" + + +def read_json(path): + try: + fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + except FileNotFoundError: + return {} + except OSError: + raise CredentialError("Credential state cannot be opened safely.") from None + try: + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise CredentialError("Credential state must be a regular file.") + with os.fdopen(fd, "r", encoding="utf-8") as stream: + fd = None + raw = stream.read(65537) + if len(raw) > 65536: + raise CredentialError("Credential state exceeds the size limit.") + result = json.loads(raw) + if not isinstance(result, dict): + raise ValueError() + return result + except (ValueError, UnicodeError, OSError): + raise CredentialError("Credential state is unreadable; run nap auth login to repair it.") from None + finally: + if fd is not None: + os.close(fd) + + +def atomic_json(path, data): + path = Path(path) + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + if path.is_symlink(): + raise CredentialError("Credential state must not be a symbolic link.") + fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + os.fchmod(stream.fileno(), 0o600) + json.dump(data, stream, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +@contextmanager +def credential_lock(path, timeout=30): + """Bounded process lock; nested calls in the same thread reuse the lock.""" + path = Path(path).absolute() + with _registry_lock: + thread_lock = _thread_locks.setdefault(str(path), threading.RLock()) + if not thread_lock.acquire(timeout=timeout): + raise CredentialError("Credential update is busy; retry after the other login or refresh finishes.") + try: + held = getattr(_held, "paths", {}) + if str(path) in held: + yield + return + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + try: + fd = os.open(str(path) + ".lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600) + except OSError: + raise CredentialError("Credential lock cannot be opened safely.") from None + try: + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise CredentialError("Credential lock must be a regular file.") + deadline = time.monotonic() + timeout + while True: + try: + if os.name == "nt": + import msvcrt + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except (BlockingIOError, OSError): + if time.monotonic() >= deadline: + raise CredentialError("Credential update is busy; retry after the other login or refresh finishes.") from None + time.sleep(0.025) + held[str(path)] = fd + _held.paths = held + try: + yield + finally: + held.pop(str(path), None) + finally: + os.close(fd) + finally: + thread_lock.release() + + +class CredentialStore: + def __init__(self, state_dir, legacy_path=None): + self.state_dir = Path(state_dir) + self.legacy = Path(legacy_path or self.state_dir / "auth.json") + self.user = user_data_dir() / "credentials" / "default.json" + project_key = hashlib.sha256(str(self.state_dir.parent.resolve()).encode()).hexdigest() + self.project = user_data_dir() / "credentials" / "projects" / f"{project_key}.json" + self.selection = self.state_dir / "state.json" + + def selected(self): + bound = getattr(_held,"bindings",{}).get(str(self.state_dir.absolute())) + if bound: return bound + explicit = os.environ.get("NAPSEER_AUTH_FILE") + if explicit: + return Path(explicit).expanduser().absolute(), "explicit" + if self.legacy != self.state_dir / "auth.json": + return self.legacy, "explicit" + if read_json(self.selection).get("credential_profile") == "user": + return self.user, "user" + if self.project.exists() or self.project.is_symlink(): + return self.project, "project" + if self.legacy.exists() or self.legacy.is_symlink(): + return self.legacy, "legacy_project" + return self.user, "user" + + @contextmanager + def transaction(self): + """Pin one credential source through read/refresh/write despite new overrides.""" + identity = str(self.state_dir.absolute()) + chosen = self.selected() + with credential_lock(chosen[0]): + bindings = getattr(_held,"bindings",{}) + previous = bindings.get(identity) + bindings[identity] = chosen; _held.bindings = bindings + try: yield + finally: + if previous: bindings[identity] = previous + else: bindings.pop(identity,None) + + def use_user(self): + current = read_json(self.user) + if not current.get("token") or current.get("logged_out"): + raise CredentialError("Run nap auth login before selecting the general user login.") + with credential_lock(self.selection): + selection = read_json(self.selection) + selection["credential_profile"] = "user" + atomic_json(self.selection, selection) + return self.user + + def migrate(self): + with credential_lock(self.legacy), credential_lock(self.project): + legacy = read_json(self.legacy) + if not legacy: raise CredentialError("There is no legacy project credential to migrate.") + if self.project.exists(): raise CredentialError("A project credential already exists; migration will not overwrite it.") + atomic_json(self.project, legacy) + if read_json(self.project) != legacy: raise CredentialError("Migration verification failed; original credentials were preserved.") + self.legacy.unlink() + return self.project + + def read(self): + path, source = self.selected() + credentials = read_json(path) + selection = {k: v for k, v in read_json(self.selection).items() if k in SELECTION_KEYS} + # Project-bound grants retain their issued project. General grants only + # consume repository selection; selection cannot confer authority. + return {**selection, **credentials} + + def write(self, data): + path, source = self.selected() + with credential_lock(path): + if source in {"legacy_project", "explicit"}: + atomic_json(path, data) + return + credentials = {k: v for k, v in data.items() if k in CREDENTIAL_KEYS} + if source == "project": + credentials.update({k: v for k, v in data.items() if k in SELECTION_KEYS}) + current = read_json(path) + # Reading project metadata must not churn or overwrite shared + # credentials, including after another process logged out. + if credentials != current: + atomic_json(path, credentials) + selection = {**read_json(self.selection), **{k: v for k, v in data.items() if k in SELECTION_KEYS}} + if selection != read_json(self.selection): + atomic_json(self.selection, selection) + + def login(self, credentials, project=False): + target = self.project if project else self.user + with credential_lock(target): + atomic_json(target, credentials) + if project: + with credential_lock(self.selection): + selection = read_json(self.selection); selection["credential_profile"] = "project" + atomic_json(self.selection, selection) + return target + + def logout(self, project=False): + target = self.selected()[0] if project else self.user + with credential_lock(target): + previous = read_json(target) + # Keep a tombstone: removing an override would silently restore the + # more privileged user session on the next request. + atomic_json(target, {"base_url": previous.get("base_url"), "logged_out": True}) + return target diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 51c2663..f2b238e 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -4,20 +4,11 @@ Run this on the agent machine. It keeps Napseer credentials local and exposes MCP tools over stdin/stdout. The wrapper calls Napseer REST underneath. -Default credential file: - ./.napseer/auth.json +Default credentials: OS user data directory / napseer/credentials/default.json. +Project selection: .napseer/project.json (identity only). +Explicit project credentials: nap auth login --project. +Environment credentials: NAPSEER_API_KEY or NAPSEER_TOKEN; never printed. -Environment overrides: - NAPSEER_TOKEN= - NAPSEER_BASE_URL=https://api.napseer.com - NAPSEER_PROJECT_ID= - -auth.json shape: - { - "base_url": "https://api.napseer.com", - "token": "np_...", - "project_id": "PROJECT_UUID" - } """ import json @@ -56,6 +47,17 @@ _SCRIPT_DIR = pathlib.Path(__file__).parent if str(_SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPT_DIR)) +from napseer_credentials import CredentialStore, credential_lock, CredentialError + +class SafeToolError(RuntimeError): + def __init__(self, code, message, *, status=None, service_code=None): + super().__init__(message) + self.code = str(code) + self.safe_message = str(message) + self.status = status + self.service_code = str(service_code or "") + + DISCOVERY_VIEWS = {"titles", "paths", "summary", "metadata", "full"} PUBLIC_DISCOVERY_VIEWS = DISCOVERY_VIEWS - {"full"} DISCOVERY_DEFAULT_LIMIT = 25 @@ -334,7 +336,7 @@ INDEX_LOCK_PATH = AUTH_DIR / "index.lock" DEFAULT_KEY_PATH = AUTH_DIR / "id_ed25519" SCRIPT_PATH = pathlib.Path(__file__).resolve() SCRIPT_NAME = SCRIPT_PATH.name -SCRIPT_DEPENDENCY_NAMES = ("napseer_spake2.py",) +SCRIPT_DEPENDENCY_NAMES = ("napseer_spake2.py", "napseer_credentials.py") CONTRACT_VERSION = "napseer.mcp.contract.v1" TELEMETRY_DISABLED_VALUES = {"0", "false", "no", "off", "disabled"} TELEMETRY_ALLOWED_FIELDS = { @@ -416,6 +418,8 @@ LOCAL_UI_ASSETS = { # END GENERATED LOCAL UI ASSETS PUBLIC_AUTH_KEYS = { + "logged_out", + "credential_kind", "base_url", "account_id", "project_id", @@ -823,35 +827,17 @@ def vault_exists(): return VAULT_PATH.exists() +def credential_store(): + return CredentialStore(AUTH_DIR, AUTH_PATH) + + def load_public_auth_file(): with AUTH_STATE_LOCK: - if not AUTH_PATH.exists(): - return {} - try: - data = json.loads(AUTH_PATH.read_text(encoding="utf-8")) - except Exception as exc: - raise RuntimeError(f"failed to read {AUTH_PATH}: {exc}") from exc - if not isinstance(data, dict): - raise RuntimeError(f"{AUTH_PATH} must contain a JSON object") - return data + return credential_store().read() def atomic_write_auth_file(data): - AUTH_PATH.parent.mkdir(exist_ok=True) - chmod_best_effort(AUTH_PATH.parent, 0o700) - temporary = AUTH_PATH.with_name( - f".{AUTH_PATH.name}.{os.getpid()}.{threading.get_ident()}.tmp" - ) - try: - temporary.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") - chmod_best_effort(temporary, 0o600) - os.replace(temporary, AUTH_PATH) - chmod_best_effort(AUTH_PATH, 0o600) - finally: - try: - temporary.unlink() - except FileNotFoundError: - pass + credential_store().write(data) def write_public_auth(data): @@ -5685,15 +5671,28 @@ def start_gateway_relay_thread(): ).start() -def load_auth(public_override=None, secret_override=None): +def load_auth(public_override=None, secret_override=None, *, read_locator=True): data = public_override if public_override is not None else load_public_auth_file() secrets_map = secret_override or {} merged = {**data, **secrets_map} + env_token = os.environ.get("NAPSEER_API_KEY") or os.environ.get("NAPSEER_TOKEN") + if env_token: + # An explicit credential is a separate authority, never a refresh hint + # for the stored user session. + merged.update(token=env_token, refresh_token=None, token_expires_at=None, + refresh_expires_at=None, account_mode="api_key" if env_token.startswith("npk_") else "environment") + elif merged.get("logged_out"): + merged.update(token=None, access_token=None, refresh_token=None) + if read_locator and not merged.get("project_id"): + locator = load_project_locator() + if locator: + merged.update(project_id=locator["project_id"], project_slug=locator["project_slug"]) + return { "base_url": os.environ.get("NAPSEER_BASE_URL") or merged.get("base_url") or "https://api.napseer.com", "account_id": os.environ.get("NAPSEER_ACCOUNT_ID") or merged.get("account_id") or merged.get("claimed_account_id"), - "token": os.environ.get("NAPSEER_TOKEN") or merged.get("token") or merged.get("access_token"), - "refresh_token": os.environ.get("NAPSEER_REFRESH_TOKEN") or merged.get("refresh_token"), + "token": env_token or merged.get("token") or merged.get("access_token"), + "refresh_token": (os.environ.get("NAPSEER_REFRESH_TOKEN") if env_token else merged.get("refresh_token")), "token_expires_at": merged.get("token_expires_at"), "refresh_expires_at": merged.get("refresh_expires_at"), "local_auth_secret": os.environ.get("NAPSEER_LOCAL_AUTH_SECRET") or merged.get("local_auth_secret"), @@ -5719,7 +5718,12 @@ def load_auth(public_override=None, secret_override=None): } -AUTH = load_auth() +try: + AUTH = load_auth() +except (CredentialError, SafeToolError): + # Keep login/help/MCP discovery available for recovery. Authenticated calls + # reread the original state and return its error; they never use a fallback. + AUTH = load_auth(public_override={}, read_locator=False) BASE_URL = AUTH["base_url"].rstrip("/") TOKEN = AUTH["token"] LOCAL_AUTH_SECRET = AUTH.get("local_auth_secret") @@ -5753,7 +5757,7 @@ Usage: nap project [...] Subcommands: init Initialize Napseer in this directory. - Creates a new project (worker enrollment). + Creates a project using your configured login. Same as `nap project create` with no args. create [slug] Create a new project for this directory. @@ -6338,7 +6342,7 @@ def raise_if_mcp_request_cancelled(): def request_json(method, path, payload=None, token_required=True, retry_auth=True, extra_headers=None, - *, cancellation_sensitive=True, preserve_response_on_cancel=False, timeout_seconds=None): + *, cancellation_sensitive=True, preserve_response_on_cancel=False, timeout_seconds=None, _raw_body=None, _binary_response=False, _base_url=None): if cancellation_sensitive: raise_if_mcp_request_cancelled() timeout = HTTP_TIMEOUT_SECONDS if timeout_seconds is None else timeout_seconds @@ -6349,17 +6353,22 @@ def request_json(method, path, payload=None, token_required=True, retry_auth=Tru # replacement access and refresh tokens without a restart. refresh_public_auth_state() if token_required and not TOKEN: - raise RuntimeError("NAPSEER_TOKEN is required") - body = None if payload is None else json.dumps(payload).encode("utf-8") + raise SafeToolError("auth_required", "No login is configured. Run `nap auth login` or configure a project API key.") + body = _raw_body if _raw_body is not None else (None if payload is None else json.dumps(payload).encode("utf-8")) headers = {"Content-Type": "application/json", "User-Agent": "napseer-mcp-python/0.1"} if token_required: headers["Authorization"] = f"Bearer {TOKEN}" if extra_headers: headers.update(extra_headers) request_token = TOKEN - request = urllib.request.Request(f"{BASE_URL}{path}", data=body, headers=headers, method=method) + request = urllib.request.Request(f"{_base_url or BASE_URL}{path}", data=body, headers=headers, method=method) try: with api_urlopen(request, timeout=timeout) as response: + if _binary_response: + body = response.read(8*1024*1024+1) + if len(body)>8*1024*1024: raise SafeToolError("file_too_large", "Download exceeds the file size limit.") + if cancellation_sensitive: raise_if_mcp_request_cancelled() + return body text = response.read().decode("utf-8") if cancellation_sensitive and not preserve_response_on_cancel: raise_if_mcp_request_cancelled() @@ -6374,6 +6383,9 @@ def request_json(method, path, payload=None, token_required=True, retry_auth=Tru exc.close() if cancellation_sensitive: raise_if_mcp_request_cancelled() + mapped_error = safe_http_error(exc, operation=f"{method} request", body_text=error_body) + if mapped_error.service_code == "project_mismatch": + raise mapped_error from None if exc.code == 401 and token_required and retry_auth: try: # The attach flow may have completed after this request was @@ -6390,14 +6402,16 @@ def request_json(method, path, payload=None, token_required=True, retry_auth=Tru extra_headers=extra_headers, cancellation_sensitive=cancellation_sensitive, preserve_response_on_cancel=preserve_response_on_cancel, - timeout_seconds=timeout_seconds, + timeout_seconds=timeout_seconds, _raw_body=_raw_body, _binary_response=_binary_response, _base_url=_base_url, ) renew_auth(stale_token=request_token) + except SafeToolError: + raise except Exception as renew_exc: - raise safe_auth_refresh_error() from renew_exc + raise SafeToolError("auth_refresh_unavailable", "Login renewal could not reach a valid response. Credentials were preserved; retry when the service recovers.") from renew_exc return request_json(method, path, payload, token_required=True, retry_auth=False, extra_headers=extra_headers, cancellation_sensitive=cancellation_sensitive, - preserve_response_on_cancel=preserve_response_on_cancel, timeout_seconds=timeout_seconds) + preserve_response_on_cancel=preserve_response_on_cancel, timeout_seconds=timeout_seconds, _raw_body=_raw_body, _binary_response=_binary_response, _base_url=_base_url) raise safe_http_error( exc, operation=f"{method} request", @@ -6422,17 +6436,17 @@ def rest_query_string(params): return urllib.parse.urlencode(normalized, doseq=True) -def request_form_json(method, path, payload, token_required=False, extra_headers=None): +def request_form_json(method, path, payload, token_required=False, extra_headers=None, _base_url=None): raise_if_mcp_request_cancelled() if token_required and not TOKEN: - raise RuntimeError("NAPSEER_TOKEN is required") + raise SafeToolError("auth_required", "No login is configured. Run `nap auth login` or configure a project API key.") body = urllib.parse.urlencode(payload).encode("utf-8") headers = {"Content-Type": "application/x-www-form-urlencoded", "User-Agent": "napseer-mcp-python/0.1"} if token_required: headers["Authorization"] = f"Bearer {TOKEN}" if extra_headers: headers.update(extra_headers) - request = urllib.request.Request(f"{BASE_URL}{path}", data=body, headers=headers, method=method) + request = urllib.request.Request(f"{_base_url or BASE_URL}{path}", data=body, headers=headers, method=method) try: with api_urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as response: text = response.read().decode("utf-8") @@ -7958,48 +7972,27 @@ def renew_anonymous_auth_with_ssh(): def _renew_auth_locked(): - oauth_refresh_error = None - if AUTH.get("account_mode") in {"operator_account", "operator_project", "claimed"} and REFRESH_TOKEN: + if AUTH.get("account_mode") in {"api_key", "environment"}: + raise SafeToolError("credential_rejected", "The configured credential was rejected. Check its scope, expiry and revocation; no alternate login was used.", status=401) + if AUTH.get("account_mode") in {"operator_account", "operator_project", "claimed"}: + if not REFRESH_TOKEN: + raise SafeToolError("auth_required", "This login cannot be renewed. Run `nap auth login`.", status=401) try: - refreshed = request_form_json( - "POST", - "/v1/oauth/token", - { - "grant_type": "refresh_token", - "client_id": AUTH.get("oauth_client_id") or "nap-cli", - "code": REFRESH_TOKEN, - }, - token_required=False, - ) - except RuntimeError as exc: - oauth_refresh_error = exc - refreshed = None - if refreshed is not None: - updates = { - "base_url": BASE_URL, - "token": refreshed["access_token"], - "token_expires_at": iso_timestamp_after_seconds(int(refreshed.get("expires_in") or 0)), - "refresh_token": refreshed.get("refresh_token") or REFRESH_TOKEN, - "refresh_expires_at": refreshed.get("refresh_expires_at"), - } - if AUTH.get("account_mode") == "operator_account": - replace_public_auth_state( - {**updates, "account_mode": "operator_account"}, - clear_keys=PROJECT_BINDING_AUTH_KEYS, - ) - else: - save_auth( - { - **updates, - "project_id": refreshed.get("project_id") or DEFAULT_PROJECT_ID, - } - ) - return { - "status": "renewed", - "method": "oauth_refresh", - "token_expires_at": TOKEN_EXPIRES_AT, - "project_id": DEFAULT_PROJECT_ID, - } + refreshed = request_form_json("POST", "/v1/oauth/token", { + "grant_type": "refresh_token", "client_id": AUTH.get("oauth_client_id") or "nap-cli", + "refresh_token": REFRESH_TOKEN, + }, token_required=False) + except SafeToolError as exc: + if exc.status in {400, 401}: + raise SafeToolError("auth_required", "The login grant is expired or revoked. Run `nap auth login`. Existing state was preserved.", status=401) from None + raise + updates = validated_oauth_credentials(refreshed) + updates["account_mode"] = AUTH.get("account_mode") + updates["oauth_scope"] = refreshed.get("scope") or AUTH.get("oauth_scope") + # Project selection remains local even for a general account login. + save_auth(updates) + return {"status": "renewed", "method": "oauth_refresh", "token_expires_at": TOKEN_EXPIRES_AT, "project_id": DEFAULT_PROJECT_ID} + oauth_refresh_error = None if REFRESH_TOKEN: try: @@ -8056,8 +8049,10 @@ def renew_auth(stale_token=None): # before attempting a refresh. If another lane already replaced the token # that received a 401, reuse that state instead of rotating credentials a # second time. - with AUTH_RENEW_LOCK: + with AUTH_RENEW_LOCK, AUTH_STATE_LOCK, credential_store().transaction(): refresh_public_auth_state() + if not TOKEN and not REFRESH_TOKEN: + raise SafeToolError("auth_required", "No renewable login is configured. Run `nap auth login`.", status=401) if stale_token is not None and TOKEN and TOKEN != stale_token: return { "status": "already_renewed", @@ -8137,49 +8132,58 @@ def oauth_loopback_authorize(args): self.end_headers() self.wfile.write(b"Missing OAuth authorization code.") return - result_queue.put({"code": code}) + try: + result_queue.put_nowait({"code": code}) + except queue.Full: + self.send_response(409) + self.end_headers() + return self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() self.wfile.write(b"

Napseer authorization complete

You can close this tab.

") server = ReusableThreadingHTTPServer(("127.0.0.1", int(args.get("port", 0))), Handler) - redirect_uri = f"http://127.0.0.1:{server.server_address[1]}/oauth/callback" - authorization_payload = { - "client_id": args.get("client_id") or "nap-cli", - "redirect_uri": redirect_uri, - "state": state, - "code_challenge": oauth_code_challenge(verifier), - "code_challenge_method": "S256", - "scope": args["scope"], - "flow": args["flow"], - } - if args.get("claim_token"): - authorization_payload["claim_token"] = args["claim_token"] - if args.get("project_id_hint"): - authorization_payload["project_id_hint"] = args["project_id_hint"] - authorization = request_json( - "POST", - "/v1/oauth/native/authorizations", - authorization_payload, - token_required=False, - ) - authorize_url = authorization.get("authorization_url") - if not authorize_url: - raise RuntimeError("OAuth authorization endpoint did not return authorization_url") - - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - print(f"Open this Napseer authorization URL if the browser does not open: {authorize_url}", file=sys.stderr) - if args.get("open_browser", True): - webbrowser.open(authorize_url) - + thread = None try: - result = result_queue.get(timeout=int(args.get("timeout_seconds", 300))) - except queue.Empty as exc: - raise RuntimeError("timed out waiting for OAuth loopback callback") from exc + redirect_uri = f"http://127.0.0.1:{server.server_address[1]}/oauth/callback" + authorization_payload = { + "client_id": args.get("client_id") or "nap-cli", + "redirect_uri": redirect_uri, + "state": state, + "code_challenge": oauth_code_challenge(verifier), + "code_challenge_method": "S256", + "scope": args["scope"], + "flow": args["flow"], + } + if args.get("claim_token"): + authorization_payload["claim_token"] = args["claim_token"] + if args.get("project_id_hint"): + authorization_payload["project_id_hint"] = args["project_id_hint"] + authorization = request_json( + "POST", + "/v1/oauth/native/authorizations", + authorization_payload, + token_required=False, _base_url=api_base_url, + ) + authorize_url = authorization.get("authorization_url") + if not authorize_url: + raise RuntimeError("OAuth authorization endpoint did not return authorization_url") + + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + print(f"Open this Napseer authorization URL if the browser does not open: {authorize_url}", file=sys.stderr) + if args.get("open_browser", True): + webbrowser.open(authorize_url) + + try: + result = result_queue.get(timeout=int(args.get("timeout_seconds", 300))) + except queue.Empty as exc: + raise RuntimeError("timed out waiting for OAuth loopback callback") from exc finally: - server.shutdown() + if thread is not None and thread.is_alive(): + server.shutdown() + thread.join(timeout=2) server.server_close() token = request_form_json( @@ -8192,7 +8196,7 @@ def oauth_loopback_authorize(args): "redirect_uri": redirect_uri, "code_verifier": verifier, }, - token_required=False, + token_required=False, _base_url=api_base_url, ) token["api_base_url"] = api_base_url token["authorization"] = authorization @@ -8203,6 +8207,7 @@ LOCAL_PROJECT_OAUTH_SCOPE = ( "openid profile email " "napseer.projects.read " "napseer.nodes.read napseer.nodes.write " + "napseer.files.read napseer.files.write napseer.operations.read napseer.operations.write " "napseer.locks.write " "napseer.schedules.read napseer.schedules.write " "napseer.agents.read napseer.agents.write " @@ -8218,50 +8223,137 @@ LOCAL_PROJECT_OAUTH_SCOPE = ( # remains narrower and receives a server-bound project token. OPERATOR_ACCOUNT_OAUTH_SCOPE = LOCAL_PROJECT_OAUTH_SCOPE.replace( "napseer.projects.read ", - "napseer.projects.read napseer.projects.write ", + "napseer.projects.read napseer.projects.write napseer.api_keys.manage ", 1, ) +def validated_oauth_credentials(result): + if (not isinstance(result, dict) + or not isinstance(result.get("access_token"), str) or not result["access_token"] + or not isinstance(result.get("refresh_token"), str) or not result["refresh_token"] + or isinstance(result.get("expires_in"), bool) + or not isinstance(result.get("expires_in"), int) or result["expires_in"] <= 0): + raise SafeToolError("auth_response_invalid", "Napseer returned an incomplete login response. Existing credentials were preserved; retry when the service recovers.") + return { + "base_url": result.get("api_base_url") or BASE_URL, + "token": result["access_token"], "refresh_token": result["refresh_token"], + "token_expires_at": iso_timestamp_after_seconds(result["expires_in"]), + "refresh_expires_at": result.get("refresh_expires_at"), + "account_mode": "operator_account", "oauth_client_id": "nap-cli", + "oauth_scope": result.get("scope"), + } + + def operator_account_login(args=None): args = args or {} - result = oauth_loopback_authorize({ - **args, - "flow": "operator_login", - "scope": args.get("scope") or OPERATOR_ACCOUNT_OAUTH_SCOPE, - }) - token_expires_at = iso_timestamp_after_seconds(int(result.get("expires_in") or 0)) - replace_public_auth_state( - { - "base_url": result.get("api_base_url") or BASE_URL, - "token": result.get("access_token"), - "refresh_token": result.get("refresh_token"), - "token_expires_at": token_expires_at, - "refresh_expires_at": result.get("refresh_expires_at"), - "account_mode": "operator_account", - "oauth_client_id": "nap-cli", - "oauth_scope": result.get("scope"), - }, - clear_keys=PROJECT_BINDING_AUTH_KEYS, - ) - return { - "status": "authenticated", - "mode": "operator_account", - "auth_path": str(AUTH_PATH), - "base_url": result.get("api_base_url") or BASE_URL, - "scope": result.get("scope"), - "token_expires_at": token_expires_at, - "message": ( - "Account authenticated without a project binding. Run `nap project attach` " - "for an existing project or `nap project create` for a new one." - ), - } + if args.get("project"): + return operator_project_attach({**args, "project_login": True}) + result = oauth_loopback_authorize({**args, "flow": "operator_login", + "scope": args.get("scope") or OPERATOR_ACCOUNT_OAUTH_SCOPE}) + credentials = validated_oauth_credentials(result) + target = credential_store().login(credentials) + try: refresh_public_auth_state() + except (CredentialError, SafeToolError): pass # Account login succeeded; a repository override still needs repair. + return {"status": "authenticated", "mode": "operator_account", "auth_path": str(target), + "base_url": credentials["base_url"], "token_expires_at": credentials["token_expires_at"], + "message": "Account login stored for this user. Run `nap project attach` for an existing project or `nap project create` for a new one. Explicit project credentials continue to take precedence."} + + + +def configure_api_key(args): + variable = args.get("env") + if not variable or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", variable): + raise SafeToolError("api_key_env_required", "Use --env with the name of an environment variable containing the API key.") + value = os.environ.get(variable, "") + if not value.startswith("npk_") or len(value) < 36: + raise SafeToolError("api_key_invalid", "The named environment variable does not contain a Napseer API key.") + locator = load_project_locator() + require_project_locator_service(locator) + credentials = {"base_url": BASE_URL, "token": value, "account_mode": "api_key"} + if locator: + credentials.update(project_id=locator["project_id"], project_slug=locator["project_slug"]) + path = credential_store().login(credentials, project=True) + refresh_public_auth_state() + return {"status": "configured", "credential_source": "project", "auth_path": str(path), + "access_verified": False, "message": "API key stored in user data for this repository. Its permissions are checked by the next authenticated operation."} + + +def cli_api_keys(args): + action = args[0] if args else "list" + if action == "list": + return request_json("GET", "/v1/api-keys") + if action == "revoke": + identifier = cli_option(args[1:], "--id", default="") + try: + identifier = str(uuid.UUID(identifier)) + except ValueError: + raise SafeToolError("api_key_id_required", "Use --id with the key ID from `nap auth keys list`.") from None + return request_json("DELETE", f"/v1/api-keys/{identifier}") + if action == "create": + project_id = cli_option(args[1:], "--project-id", default=DEFAULT_PROJECT_ID) + if not project_id: + raise SafeToolError("project_required", "Select a project or provide --project-id before issuing a key.") + payload = {"name": cli_option(args[1:], "--name", default="Agent"), "project_id": project_id, + "scopes": cli_option(args[1:], "--scopes", default="napseer.projects.read,napseer.nodes.read").split(","), + "expires_in_days": int(cli_option(args[1:], "--days", default="30"))} + result = request_json("POST", "/v1/api-keys", payload) + key = result.pop("api_key", None) + if not key or not str(key).startswith("npk_"): + raise SafeToolError("api_key_response_invalid", "Key issuance returned an incomplete response. Inspect `nap auth keys list` before retrying.") + from napseer_credentials import atomic_json, user_data_dir + path = user_data_dir() / "credentials" / "issued" / f"{uuid.UUID(result['id'])}.json" + try: + atomic_json(path, {"base_url": BASE_URL, "token": key, "account_mode": "api_key", "project_id": project_id}) + except Exception: + raise SafeToolError("api_key_storage_failed", "The key was issued but could not be stored. Revoke it with `nap auth keys revoke --id " + str(result["id"]) + "` before issuing another.") from None + return {**result, "credential_file": str(path), "message": "Key saved privately. Configure the agent with NAPSEER_AUTH_FILE pointing to this file; the key is not printed."} + raise SafeToolError("api_key_command_invalid", "Use `nap auth keys list`, `create --name NAME --project-id ID --scopes SCOPES --days DAYS`, or `revoke --id ID`.") + + +def operator_logout(args=None): + args = args or {} + store = credential_store() + target = store.selected()[0] if args.get("project") else store.user + with AUTH_STATE_LOCK, credential_lock(target): + from napseer_credentials import read_json + previous = read_json(target) + remote_revoked = False + if previous.get("refresh_token"): + # Use captured issuer/credential; never another selected project's + # login. A failed revocation keeps credentials available for retry. + body = urllib.parse.urlencode({"token": previous["refresh_token"], + "client_id": previous.get("oauth_client_id") or "nap-cli"}).encode() + request = urllib.request.Request(previous["base_url"].rstrip("/") + "/v1/oauth/revoke", + data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST") + try: + with api_urlopen(request, timeout=10) as response: + response.read(1024) + remote_revoked = True + except Exception: + raise SafeToolError("logout_pending", "Session revocation could not be confirmed. Retry `nap auth logout` when the service is reachable; credentials were preserved.") from None + from napseer_credentials import atomic_json + atomic_json(target, {"base_url": previous.get("base_url"), "logged_out": True}) + refresh_public_auth_state() + return {"status": "logged_out", "remote_revoked": remote_revoked, "auth_path": str(target)} def operator_project_attach(args=None): args = args or {} locator = load_project_locator() require_project_locator_service(locator) + try: refresh_public_auth_state() + except CredentialError: + if not args.get("project_login"): raise + selected_id = args.get("project_id") or (locator or {}).get("project_id") + if selected_id and TOKEN and AUTH.get("account_mode") == "operator_account" and not args.get("project_login"): + project = request_json("GET", f"/v1/projects/{selected_id}") + if locator and str(project.get("id")) != locator["project_id"]: + raise SafeToolError("project_locator_mismatch", "Selected project differs from the repository locator.") + save_auth_file_credentials({"project_id": project["id"], "project_slug": project["slug"], "project_name": project["name"]}) + write_project_locator(project) + return {"status": "attached", "mode": "operator_account", "project_id": project["id"], + "message": "Project selected using the existing user login."} result = oauth_loopback_authorize({ **args, "flow": "local_operator_access", @@ -8276,21 +8368,11 @@ def operator_project_attach(args=None): "project_locator_mismatch", "OAuth selected a different project than the committed .napseer/project.json locator; local credentials were not changed.", ) - token_expires_at = iso_timestamp_after_seconds(int(result.get("expires_in") or 0)) - replace_public_auth_state( - { - "base_url": result.get("api_base_url") or BASE_URL, - "token": result.get("access_token"), - "refresh_token": result.get("refresh_token"), - "token_expires_at": token_expires_at, - "refresh_expires_at": result.get("refresh_expires_at"), - "project_id": project_id, - "account_mode": "operator_project", - "oauth_client_id": "nap-cli", - "oauth_scope": result.get("scope"), - }, - clear_keys=WORKER_BINDING_AUTH_KEYS, - ) + credentials = validated_oauth_credentials(result) + credentials.update(project_id=project_id, account_mode="operator_project") + target = credential_store().login(credentials, project=True) + token_expires_at = credentials["token_expires_at"] + refresh_public_auth_state() try: project = request_json("GET", f"/v1/projects/{project_id}") save_auth_file_credentials({ @@ -8310,19 +8392,19 @@ def operator_project_attach(args=None): return { "status": "attached", "mode": "operator_project", - "auth_path": str(AUTH_PATH), + "auth_path": str(target), "base_url": result.get("api_base_url") or BASE_URL, "project_id": project_id, "project": project, "project_locator": project_locator_status(locator if project is None else None), "scope": result.get("scope"), "token_expires_at": token_expires_at, - "message": "OAuth2 PKCE project token and selected project stored in this directory's Napseer auth state.", + "message": "Project credentials stored in this user's data folder; this repository keeps project identity only.", } def save_auth_file_credentials(updates): - with AUTH_STATE_LOCK: + with AUTH_STATE_LOCK, credential_store().transaction(): return _save_auth_file_credentials_locked(updates) @@ -8341,7 +8423,7 @@ def _save_auth_file_credentials_locked(updates): def save_auth(updates): - with AUTH_STATE_LOCK: + with AUTH_STATE_LOCK, credential_store().transaction(): return _save_auth_locked(updates) @@ -8410,46 +8492,8 @@ def ensure_local_files(): def ensure_enrolled(args=None): if TOKEN: - return {"status": "already_enrolled", "token_expires_at": TOKEN_EXPIRES_AT} - args = args or {} - slug = str(args.get("slug") or pathlib.Path.cwd().name or "napseer-project") - worker_name = str(args.get("worker_name") or AUTH.get("worker_name") or f"{slug}-agent") - device_fingerprint = str(args.get("device_fingerprint") or AUTH.get("device_fingerprint") or socket.gethostname()) - root_path = str(args.get("root_path") or AUTH.get("root_path") or pathlib.Path.cwd()) - worker_capabilities = AUTH.get("worker_capabilities") or {"local_mcp": True, "mcp_wrapper": SCRIPT_NAME} - verified = request_json( - "POST", - "/v1/enrollment/token", - { - "worker_name": worker_name, - "device_fingerprint": device_fingerprint, - "root_path": root_path, - "worker_capabilities": worker_capabilities, - }, - token_required=False, - ) - save_auth( - { - "base_url": BASE_URL, - "token": verified["token"]["access_token"], - "refresh_token": verified["token"].get("refresh_token"), - "token_expires_at": verified["token"].get("expires_at"), - "refresh_expires_at": verified["token"].get("refresh_expires_at"), - "worker_id": verified["worker"]["id"], - "agent_id": verified["worker"]["agent_id"], - "worker_name": worker_name, - "device_fingerprint": device_fingerprint, - "root_path": root_path, - "worker_capabilities": worker_capabilities, - "account_mode": AUTH.get("account_mode") or "anonymous", - } - ) - return { - "status": "enrolled", - "method": "token", - "token_expires_at": verified["token"].get("expires_at"), - "refresh_expires_at": verified["token"].get("refresh_expires_at"), - } + return {"status": "configured", "token_expires_at": TOKEN_EXPIRES_AT} + raise SafeToolError("auth_required", "Napseer has no configured login. Ask the user to run `nap auth login` or configure an API key. No account or project was created.") def gateway_service_preregister(args=None): @@ -8761,7 +8805,7 @@ def resolve_project_id(args): with PROJECT_RESOLUTION_LOCK: # Reload here as well as in request_json so a long-running MCP worker # observes `nap project attach` performed by another process. - if AUTH_PATH.exists() or not DEFAULT_PROJECT_ID: + if credential_store().selected()[0].exists() or not DEFAULT_PROJECT_ID: refresh_public_auth_state() project_id = DEFAULT_PROJECT_ID locator = load_project_locator() @@ -8777,30 +8821,35 @@ def resolve_project_id(args): "project_access_required", "This repository already declares a Napseer project and will not create a duplicate anonymous project. Run `nap project attach`; if the project is still anonymous, claim it from a machine that retains its enrollment identity, then retry here.", ) - slug = default_project_slug() - try: - initialized = bootstrap_project( - { - "slug": slug, - "name": default_project_name(slug), - "description": "Created automatically by the local Napseer MCP runtime.", - "encryption": "standard", - } - ) - except SafeToolError: - raise - except Exception as exc: - raise SafeToolError( - "project_bootstrap_failed", - "This directory has no Napseer project and automatic anonymous setup failed. " - "Run `nap doctor`, then retry; account login is not required unless an anonymous limit was reached.", - ) from exc - project_id = initialized.get("project_id") or DEFAULT_PROJECT_ID - if not project_id: - raise SafeToolError( - "project_bootstrap_failed", - "Automatic anonymous setup completed without selecting a project. Run `nap doctor` and retry.", - ) + ensure_enrolled() + with credential_lock(AUTH_DIR / "bootstrap"): + refresh_public_auth_state() + if DEFAULT_PROJECT_ID or load_project_locator(): + return resolve_project_id(args) + slug = default_project_slug() + try: + initialized = bootstrap_project( + { + "slug": slug, + "name": default_project_name(slug), + "description": "Created automatically by the local Napseer MCP runtime.", + "encryption": "standard", + } + ) + except SafeToolError: + raise + except Exception as exc: + raise SafeToolError( + "project_bootstrap_failed", + "This directory has no Napseer project and automatic project setup failed. " + "Run `nap doctor`, then retry with your configured account.", + ) from exc + project_id = initialized.get("project_id") or DEFAULT_PROJECT_ID + if not project_id: + raise SafeToolError( + "project_bootstrap_failed", + "Automatic project setup completed without selecting a project. Run `nap doctor` and retry.", + ) return project_id @@ -8846,15 +8895,11 @@ def create_project_with_state(args): raise status = "existing" auth_updates = { - "base_url": BASE_URL, - "token": TOKEN, "project_id": project["id"], "project_slug": project["slug"], "project_name": project["name"], "project_encryption_state": project.get("encryption_state") or "standard", } - if AUTH.get("account_mode") == "operator_account": - auth_updates["account_mode"] = "operator_project" save_auth(auth_updates) write_project_locator(project) try: @@ -9077,9 +9122,9 @@ def cli_project_init(args): """First-time setup wizard for a fresh directory. Steps, in order, only running the ones that are needed: - 1. SSH-key enroll this machine (anonymous) if no bearer token exists. + 1. Require user-configured credentials; never enroll automatically. 2. Create a project on the backend if none is configured for this folder. - 3. Persist the resulting project/slug/name in ./.napseer/auth.json. + 3. Persist repository identity separately from user credentials. No browser is opened. Use `nap project attach` to OAuth-attach a pre-existing project to a user account, or `nap project claim` to upgrade an anonymous @@ -9177,8 +9222,7 @@ def cli_project_init(args): "status": "initialized", "message": ( f"Folder initialized. Project '{bootstrap['project']['slug']}' " - f"created and stored in {AUTH_PATH}. Next: `nap project attach` " - "to link it to your Napseer account, or `nap gateway configure` " + f"created under your configured account. Run `nap gateway configure` " "to start a local gateway." ), "steps": steps, @@ -9461,15 +9505,7 @@ def cli_configure_status(): refresh_current_project_metadata() encryption = project_encryption_status({}) except Exception: - if REFRESH_TOKEN: - try: - renew_auth() - project_configured = bool(DEFAULT_PROJECT_ID) - encryption = project_encryption_status({}) if project_configured else {"state": "unknown", "updated_at": ""} - except Exception: - encryption = {"state": AUTH.get("project_encryption_state") or "unknown", "updated_at": ""} - else: - encryption = {"state": AUTH.get("project_encryption_state") or "unknown", "updated_at": ""} + encryption = {"state": "unknown", "updated_at": ""} return { "status": ( "project_locator_mismatch" @@ -9496,7 +9532,8 @@ def cli_configure_status(): ), "cwd": str(pathlib.Path.cwd()), **state_dir_status_payload(), - "auth_path": str(AUTH_PATH), + "auth_path": str(credential_store().selected()[0]), + "credential_source": credential_store().selected()[1], "auth_configured": bool(TOKEN), "account_mode": AUTH.get("account_mode"), "project_configured": project_configured, @@ -14618,8 +14655,133 @@ def kanban_workflow_tool_schemas(): ] +def coordination_file_tools(): + string = {"type": "string"} + def spec(name, description, properties, required=()): + return {"name": name, "description": description, "inputSchema": {"type": "object", "properties": properties, "required": list(required), "additionalProperties": False}} + return [ + spec("nap_operation_list", "Reconcile durable execution claims and outcomes after reconnect. Expired leases are reclaimable; pagination is explicit.", {"cursor": {"type":"integer"}, "limit": {"type":"integer","maximum":100}, "active_only":{"type":"boolean"}}), + spec("nap_operation", "Claim work with a unique UUID id, then heartbeat every minute. Read the id after an uncertain result. Expired claims cannot finish; claim anew. Heartbeat can link progress and blockers with evidence_node_id. Success requires evidence_node_id. This execution lease does not replace guarded node writes or authorize external actions.", {"action":{"enum":["claim","get","heartbeat","succeed","fail","cancel"]},"id":string,"node_id":string,"holder":string,"ttl_seconds":{"type":"integer","minimum":30,"maximum":900},"evidence_node_id":string}, ["action","id"]), + spec("nap_file_list", "List bounded file metadata and attachment identities; file bytes are never returned as tool text.", {"node_id":string,"cursor":string,"limit":{"type":"integer","maximum":100}}), + spec("nap_file_upload", "Upload a local regular file up to 8 MiB into project storage. Content-derived upload identity makes identical retries safe. Optional node_id attaches to a canonical document. End-to-end encrypted projects currently reject uploads.", {"local_path":string,"node_id":string,"id":string}, ["local_path"]), + spec("nap_file_download", "Download project bytes to a NEW local file after checking SHA-256 and size. Existing files are never overwritten.", {"id":string,"local_path":string}, ["id","local_path"]), + spec("nap_file_archive", "Archive a project file by immutable identity. Bytes remain in storage; active listings omit it.", {"id":string}, ["id"]), + ] + + +def operation_tool(args): + project = resolve_project_id({}) + identity = str(uuid.UUID(str(args["id"]))) + root = f"/v1/projects/{project}/operations" + action = args["action"] + if action == "get": + return request_json("GET", f"{root}/{identity}") + if action == "claim": + return request_json("POST", root, {"id": identity, "node_id": args["node_id"], "holder": args["holder"], "ttl_seconds": args.get("ttl_seconds",300)}) + if action not in {"heartbeat","succeed","fail","cancel"}: + raise SafeToolError("invalid_action", "Choose claim, get, heartbeat, succeed, fail or cancel.") + return request_json("POST", f"{root}/{identity}", {"action": action, "ttl_seconds": args.get("ttl_seconds",300), "evidence_node_id":args.get("evidence_node_id")}) + + +def export_project(args): + """A portable, checked active-project snapshot; never contains login data.""" + import zipfile + destination = pathlib.Path(args["local_path"]).expanduser().absolute() + if destination.exists(): raise SafeToolError("destination_exists","Choose a new export path; existing files are preserved.") + project = resolve_project_id({}) + root=f"/v1/projects/{project}" + deadline=time.monotonic()+300 + def listing(path, view=None): + items=[];cursor=None + for _ in range(100): + if time.monotonic()>deadline: raise SafeToolError("export_timeout","Export deadline reached; no archive was published.") + page=request_json("GET",path+"?"+rest_query_string({"limit":100,"cursor":cursor,"view":view})) + items.extend(page["items"]) + if len(items)>5000: raise SafeToolError("export_limit","This export exceeds 5000 records; use an operator database backup for this project.") + next_cursor=page.get("next_cursor") + if not next_cursor: return items + if next_cursor==cursor: raise SafeToolError("pagination_failed","Export pagination stopped advancing.") + cursor=next_cursor + raise SafeToolError("export_limit","Export page limit reached.") + nodes=listing(root+"/nodes","metadata") + files=listing(root+"/files") + identities={item["id"]:item.get("updated_at") for item in nodes} + file_ids={item["id"] for item in files} + if any(item.get("node_id") and item["node_id"] not in identities for item in files): + raise SafeToolError("export_attachment_missing","A file references an unavailable document. Restore that document or use a database backup before exporting.") + total=sum(item["size_bytes"] for item in files) + if total>512*1024*1024: raise SafeToolError("export_limit","File bytes exceed the 512 MiB export limit; use an operator database backup.") + temporary=None + try: + fd,temporary=tempfile.mkstemp(prefix=".napseer-export-",dir=destination.parent) + os.close(fd) + with zipfile.ZipFile(temporary,"w",compression=zipfile.ZIP_DEFLATED) as archive: + for item in nodes: + if time.monotonic()>deadline: raise SafeToolError("export_timeout","Export deadline reached; no archive was published.") + document=request_json("GET",root+"/nodes/"+str(uuid.UUID(item["id"]))+"?view=edit") + if document["updated_at"]!=identities[item["id"]]: raise SafeToolError("export_conflict","Documentation changed during export. Retry after current writes finish.") + archive.writestr(f"nodes/{item['id']}.json",json.dumps(document,ensure_ascii=False,indent=2)) + for item in files: + if time.monotonic()>deadline: raise SafeToolError("export_timeout","Export deadline reached; no archive was published.") + identity=str(uuid.UUID(item["id"])) + body=request_json("GET",root+"/files/"+identity,_binary_response=True) + if len(body)!=item["size_bytes"] or hashlib.sha256(body).hexdigest()!=item["sha256"]: raise SafeToolError("file_integrity_failed","Export file integrity check failed; no archive was published.") + archive.writestr(f"files/{identity}",body) + # Refuse a mixed snapshot when records changed during pagination. + if identities!={item["id"]:item.get("updated_at") for item in listing(root+"/nodes","metadata")} or file_ids!={item["id"] for item in listing(root+"/files")}: + raise SafeToolError("export_conflict","Project changed during export. Retry after current writes finish.") + archive.writestr("manifest.json",json.dumps({"schema":"napseer.project-export.v1","project_id":project,"created_at":iso_now(),"scope":"active project nodes and files; cross-project links retained as references","nodes":nodes,"files":files},ensure_ascii=False,indent=2)) + with open(temporary,"rb") as source: os.fsync(source.fileno()) + os.link(temporary,destination) # Atomic, and refuses an existing destination. + except OSError: + raise SafeToolError("export_destination_unavailable","Export could not be saved. Choose a new path in an existing writable directory.") from None + finally: + if temporary: pathlib.Path(temporary).unlink(missing_ok=True) + return {"status":"exported","project_id":project,"local_path":str(destination),"nodes":len(nodes),"files":len(files),"file_bytes":total} + + +def file_tool(name, args): + import stat + project = resolve_project_id({}) + root = f"/v1/projects/{project}/files" + if name == "nap_file_list": + return request_json("GET", root + "?" + rest_query_string({key:args.get(key) for key in ("node_id","cursor","limit")})) + if name == "nap_file_archive": + return request_json("DELETE", root + "/" + str(uuid.UUID(str(args["id"])))) + path = pathlib.Path(args["local_path"]).expanduser().absolute() + if name == "nap_file_upload": + try: + fd = os.open(path, os.O_RDONLY | getattr(os,"O_NOFOLLOW",0) | getattr(os,"O_NONBLOCK",0)) + with os.fdopen(fd,"rb") as source: + if not stat.S_ISREG(os.fstat(source.fileno()).st_mode): + raise SafeToolError("invalid_file", "Choose a regular local file.") + body = source.read(8*1024*1024+1) + except OSError: + raise SafeToolError("file_unavailable", "The local file could not be read.") from None + if len(body)>8*1024*1024: + raise SafeToolError("file_too_large", "Files can be up to 8 MiB.") + digest=hashlib.sha256(body).hexdigest() + identity = str(uuid.UUID(str(args["id"]))) if args.get("id") else str(uuid.uuid5(uuid.UUID(project), f"{path.name}:{args.get('node_id','')}:{digest}")) + return request_json("POST",root+"?"+rest_query_string({"id":identity,"name":path.name,"node_id":args.get("node_id")}),extra_headers={"Content-Type":"application/octet-stream"},_raw_body=body) + identity = str(uuid.UUID(str(args["id"]))) + found=request_json("GET",f"{root}/{identity}/metadata") + body=request_json("GET",f"{root}/{identity}",_binary_response=True) + if len(body)!=found["size_bytes"] or hashlib.sha256(body).hexdigest()!=found["sha256"]: + raise SafeToolError("file_integrity_failed","Downloaded file failed its size or SHA-256 check; no file was written.") + created=False + try: + with os.fdopen(os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600),"wb") as destination: + created=True + destination.write(body);destination.flush();os.fsync(destination.fileno()) + except OSError: + if created: path.unlink(missing_ok=True) + raise SafeToolError("destination_unavailable","Choose a new file in an existing writable directory. Existing files were preserved.") from None + return {"id":identity,"local_path":str(path),"size_bytes":len(body),"sha256":found["sha256"]} + + def raw_tools(): return [ + *coordination_file_tools(), { "name": "nap_whoami", "description": "Show safe local gateway, vault, and project status. Does not reveal secrets.", @@ -16056,6 +16218,7 @@ def tool_category(name): READ_ONLY_TOOLS = { + "nap_operation_list", "nap_file_list", "nap_whoami", "nap_gateway_status", "nap_chat_secret_status", @@ -16107,6 +16270,7 @@ READ_ONLY_TOOLS = { } LOCAL_FILE_TOOLS = { + "nap_file_download", "nap_update_self", "nap_reindex_project", "nap_index_sync", @@ -16275,6 +16439,13 @@ def contract_profile(args=None): CANONICAL_TOOL_NAMES = { + "nap_operation_list", + "nap_operation", + "nap_file_list", + "nap_file_upload", + "nap_file_download", + "nap_file_archive", + # Contract and health. "nap_apropos", "nap_man", @@ -16512,6 +16683,7 @@ TOOL_CATEGORIES.update( ], "project": ["nap_whoami", "nap_auth_refresh"], "memory": [ + "nap_operation_list", "nap_operation", "nap_file_list", "nap_file_upload", "nap_file_download", "nap_file_archive", "nap_discover", "nap_context", "nap_node_by_path", @@ -16574,6 +16746,13 @@ TOOL_CATEGORIES.update( ) CORE_TOOL_NAMES = { + "nap_operation_list", + "nap_operation", + "nap_file_list", + "nap_file_upload", + "nap_file_download", + "nap_file_archive", + "nap_apropos", "nap_man", "nap_doctor", @@ -16673,13 +16852,6 @@ _base_contract_profile = contract_profile _base_tool_contract_metadata = tool_contract_metadata -class SafeToolError(RuntimeError): - def __init__(self, code, message, *, status=None, service_code=None): - super().__init__(message) - self.code = str(code) - self.safe_message = str(message) - self.status = status - self.service_code = str(service_code or "") def safe_http_error(exc, *, operation="request", body_text=""): @@ -16792,7 +16964,7 @@ WORKER_BINDING_AUTH_KEYS = { def replace_public_auth_state(updates, *, clear_keys=()): - with AUTH_STATE_LOCK: + with AUTH_STATE_LOCK, credential_store().transaction(): current_public = load_public_auth_file() for key in clear_keys: current_public.pop(key, None) @@ -16838,6 +17010,12 @@ def tool_auth_mode(name): def tool_contract_metadata(name): metadata = _base_tool_contract_metadata(name) + if name == "nap_operation": + metadata.update(lock_policy="atomic-execution-lease", idempotency="reconcile-same-run-id") + if name == "nap_file_upload": + metadata.update(lock_policy="immutable-file-id", idempotency="same-id-and-bytes") + if name == "nap_file_archive": + metadata.update(idempotency="safe") if name in {"nap_batch", "nap_plan_transition"}: metadata["dry_run_supported"] = True if name == "nap_batch": @@ -17701,6 +17879,12 @@ def call_tool_impl(name, args): "invalid_view", "Full bodies are available only through nap_node_get after resolving a node identity.", ) + if name == "nap_operation_list": + return request_json("GET", f"/v1/projects/{resolve_project_id({})}/operations?" + rest_query_string(args)) + if name == "nap_operation": + return operation_tool(args) + if name in {"nap_file_list","nap_file_upload","nap_file_download","nap_file_archive"}: + return file_tool(name,args) if name == "nap_whoami": return gateway_status() if name == "nap_gateway_status": @@ -18010,7 +18194,7 @@ def handle(message): "protocolVersion": PROTOCOL_VERSION, "capabilities": {"tools": {"listChanged": False}}, "serverInfo": {"name": "napseer-local", "version": "0.1.6"}, - "instructions": "Local authenticated MCP wrapper. It manages token and project_id from ./.napseer/auth.json; agents should not handle secrets or pass project ids for normal tools.", + "instructions": "Local authenticated MCP wrapper. It resolves user or project credentials and repository identity; agents should not handle secrets or pass project ids for normal tools.", "napseer": { "auth_path": str(AUTH_PATH), "base_url": BASE_URL, @@ -18163,6 +18347,20 @@ def cli_main(argv): "limit": int(cli_option(args, "--limit", default="200")), }), indent=2)) return + if command == "export": + args=argv[2:] + validate_cli_options(args,value_options={"--path"},command_label="nap export") + path=cli_option(args,"--path") + if not path: raise SafeToolError("destination_required","Use nap export --path NEW_ARCHIVE.zip.") + print(json.dumps(export_project({"local_path":path}),indent=2)); return + if command == "files": + action = argv[2] if len(argv)>2 else "list" + args = argv[3:] + validate_cli_options(args, value_options={"--path","--id","--node-id","--cursor","--limit"}, command_label="nap files") + names={"list":"nap_file_list","upload":"nap_file_upload","download":"nap_file_download","archive":"nap_file_archive"} + if action not in names: raise SafeToolError("invalid_action","Use nap files list|upload|download|archive.") + payload={key:cli_option(args,flag) for key,flag in {"local_path":"--path","id":"--id","node_id":"--node-id","cursor":"--cursor","limit":"--limit"}.items() if cli_option(args,flag) is not None} + print(json.dumps(file_tool(names[action],payload),indent=2)); return if command == "configure": print(json.dumps(cli_configure_status(), indent=2)) return @@ -18195,18 +18393,27 @@ def cli_main(argv): if subcommand in {"help", "-h", "--help"} or cli_flag(args, "--help", "-h"): print("""Usage: nap auth status - nap auth login [--frontend-url URL] [--api-base-url URL] [--no-browser] [--timeout SECONDS] + nap auth login [--project] [--frontend-url URL] [--api-base-url URL] [--no-browser] [--timeout SECONDS] nap auth repair + nap auth logout [--project] + nap auth api-key --env VARIABLE_NAME + nap auth keys list|create|revoke + nap auth migrate Move legacy credentials to user data without broadening scope. + nap auth use-user Explicitly use the general login in this repository. `login` signs in to the account only. It does not select a project. Use `nap project attach` for an existing project or `nap project create` for a new one. Refresh is automatic during authenticated calls; use `repair` only for recovery. `login-local` remains a hidden compatibility alias for `login`.""") return + if subcommand in {"use-user","migrate"}: + validate_cli_options(args, command_label=f"nap auth {subcommand}") + path = credential_store().use_user() if subcommand == "use-user" else credential_store().migrate() + print(json.dumps({"status":"configured","auth_path":str(path),"message":"Credential selection updated. Existing project identity is preserved."},indent=2)); return if subcommand in {"login", "login-local", "operator-login"}: validate_cli_options( args, - flags={"--no-browser"}, + flags={"--no-browser", "--project"}, value_options={ "--frontend-url", "--api-base-url", @@ -18218,6 +18425,7 @@ Refresh is automatic during authenticated calls; use `repair` only for recovery. command_label="nap auth login", ) print(json.dumps(operator_account_login({ + "project": cli_flag(args, "--project"), "frontend_url": cli_option(args, "--frontend-url", default=None), "api_base_url": cli_option(args, "--api-base-url", "--base-url", default=None), "open_browser": not cli_flag(args, "--no-browser"), @@ -18225,6 +18433,16 @@ Refresh is automatic during authenticated calls; use `repair` only for recovery. "port": cli_option(args, "--port", default=0), }), indent=2)) return + if subcommand == "api-key": + validate_cli_options(args, value_options={"--env"}, command_label="nap auth api-key") + print(json.dumps(configure_api_key({"env": cli_option(args, "--env")}), indent=2)) + return + if subcommand == "keys": + print(json.dumps(cli_api_keys(args), indent=2)) + return + if subcommand == "logout": + print(json.dumps(operator_logout({"project": cli_flag(args, "--project")}), indent=2)) + return if subcommand in {"refresh", "repair"}: print(json.dumps(renew_auth(), indent=2)) return @@ -18241,6 +18459,7 @@ Refresh is automatic during authenticated calls; use `repair` only for recovery. return if subcommand == "attach": print(json.dumps(operator_project_attach({ + "project_id": cli_option(argv[3:], "--project-id", default=None), "frontend_url": cli_option(argv[3:], "--frontend-url", default=None), "api_base_url": cli_option(argv[3:], "--api-base-url", "--base-url", default=None), "open_browser": not cli_flag(argv[3:], "--no-browser"), diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4830d99 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +"""All tests use disposable user credential state, including legacy test scripts.""" +import pytest + + +@pytest.fixture(autouse=True) +def isolated_user_credentials(monkeypatch, tmp_path): + monkeypatch.setenv("NAPSEER_USER_DATA_DIR", str(tmp_path / "user-data")) + for name in ("NAPSEER_API_KEY", "NAPSEER_TOKEN", "NAPSEER_REFRESH_TOKEN"): + monkeypatch.delenv(name, raising=False) diff --git a/tests/test_agent_files.py b/tests/test_agent_files.py new file mode 100644 index 0000000..5ceaf76 --- /dev/null +++ b/tests/test_agent_files.py @@ -0,0 +1,127 @@ +"""Public worker/CLI entry points against synthetic binary HTTP storage.""" +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import threading +import uuid +import zipfile +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit, parse_qs + +import pytest +from test_user_credentials import worker, outcome, SCRIPT +from napseer_credentials import CredentialStore, atomic_json + +PROJECT="11111111-1111-4111-8111-111111111111" +NODE="22222222-2222-4222-8222-222222222222" + +@pytest.fixture +def storage(tmp_path): + state={"files":{},"corrupt":False,"keys":[],"projects":[]} + node={"id":NODE,"name":"Guide","full_path":"/services/guide","type":"documentation","updated_at":"2026-09-09T00:00:00Z","content_text":"Recovery procedure","metadata":{},"links":[]} + class Handler(BaseHTTPRequestHandler): + def log_message(self,*_args): pass + def reply(self,status,value,binary=False): + body=value if binary else json.dumps(value).encode() + self.send_response(status);self.send_header("Content-Length",str(len(body)));self.end_headers();self.wfile.write(body) + def do_GET(self): + path=urlsplit(self.path).path + if path=='/v1/projects':return self.reply(200,{'items':state['projects']}) + if path=='/v1/api-keys':return self.reply(200,{'items':state['keys']}) + if path.endswith('/nodes'): return self.reply(200,{"items":[node]}) + if path.endswith('/nodes/'+NODE):return self.reply(200,node) + if path.endswith('/files'): return self.reply(200,{"items":[entry[0] for entry in state["files"].values()]}) + identity=path.split('/')[-2] if path.endswith('/metadata') else path.split('/')[-1] + entry=state["files"].get(identity) + if not entry:return self.reply(404,{"error":{"code":"not_found"}}) + if path.endswith('/metadata'):return self.reply(200,entry[0]) + return self.reply(200,b"corrupted" if state["corrupt"] else entry[1],True) + def do_POST(self): + if self.path=='/v1/projects': + payload=json.loads(self.rfile.read(int(self.headers['Content-Length']))) + project={**payload,'id':str(uuid.uuid4()),'encryption_state':'standard'} + state['projects'].append(project);return self.reply(200,project) + if self.path=='/v1/api-keys': + payload=json.loads(self.rfile.read(int(self.headers['Content-Length']))) + key={**payload,'id':str(uuid.uuid4())};state['keys'].append(key) + return self.reply(200,{**key,'api_key':'npk_'+uuid.uuid4().hex}) + query=parse_qs(urlsplit(self.path).query) + body=self.rfile.read(int(self.headers['Content-Length'])) + identity=query['id'][0] + info={"id":identity,"name":query['name'][0],"node_id":query.get('node_id',[None])[0],"size_bytes":len(body),"sha256":hashlib.sha256(body).hexdigest()} + state['files'][identity]=(info,body) + return self.reply(200,info) + def do_DELETE(self): + identity=urlsplit(self.path).path.split('/')[-1];state['files'].pop(identity,None) + return self.reply(200,{"archived":True}) + server=ThreadingHTTPServer(('127.0.0.1',0),Handler) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + store=CredentialStore(tmp_path/'.napseer') + store.login({"base_url":f"http://127.0.0.1:{server.server_port}","token":"synthetic-access","account_mode":"operator_account"}) + atomic_json(store.selection,{"project_id":PROJECT}) + try:yield state + finally:server.shutdown();server.server_close();thread.join(timeout=2) + + +def test_agent_upload_retries_download_and_export_preserve_binary_and_references(tmp_path,storage): + body=bytes([0,255,128,13,10,65]);source=tmp_path/'proof.bin';source.write_bytes(body) + args={"local_path":str(source),"node_id":NODE} + first=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_upload',{args!r})")) + assert first['ok'],first.get('code') + identity=first['result']['id'] + again=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_upload',{args!r})")) + assert again['result']['id']==identity + assert len(storage['files'])==1 + destination=tmp_path/'download.bin' + result=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_download',{{'id':{identity!r},'local_path':{str(destination)!r}}})")) + assert result['ok'];assert destination.read_bytes()==body + rejected=outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_download',{{'id':{identity!r},'local_path':{str(source)!r}}})")) + assert rejected['code']=='destination_unavailable';assert source.read_bytes()==body + target=tmp_path/'export.zip' + env=dict(os.environ,NAPSEER_PROJECT_ROOT=str(tmp_path),NAPSEER_TELEMETRY='0') + for name in ['NAPSEER_AUTH_FILE','NAPSEER_BASE_URL','NAPSEER_PROJECT_ID']:env.pop(name,None) + exported=subprocess.run([sys.executable,str(SCRIPT),'export','--path',str(target)],cwd=tmp_path,env=env,capture_output=True,text=True,timeout=15) + assert exported.returncode==0 + assert json.loads(exported.stdout)['status']=='exported' + with zipfile.ZipFile(target) as archive: + manifest=json.loads(archive.read('manifest.json')) + assert manifest['files'][0]['node_id']==NODE + assert archive.read('files/'+identity)==body + assert json.loads(archive.read('nodes/'+NODE+'.json'))['content_text']=='Recovery procedure' + assert not any('auth' in name for name in archive.namelist()) + storage['corrupt']=True + rejected=outcome(worker(tmp_path,f"m.export_project({{'local_path':{str(tmp_path/'broken.zip')!r}}})")) + assert rejected['code']=='file_integrity_failed' + assert not (tmp_path/'broken.zip').exists() + assert not list(tmp_path.glob('.napseer-export-*')) + assert outcome(worker(tmp_path,f"m.call_tool_impl('nap_file_archive',{{'id':{identity!r}}})"))['ok'] + assert not storage['files'] + + +def test_cli_key_issuance_writes_private_credential_without_printing_it(tmp_path,storage): + env=dict(os.environ,NAPSEER_PROJECT_ROOT=str(tmp_path),NAPSEER_TELEMETRY='0') + for name in ['NAPSEER_AUTH_FILE','NAPSEER_BASE_URL','NAPSEER_PROJECT_ID']:env.pop(name,None) + created=subprocess.run([sys.executable,str(SCRIPT),'auth','keys','create','--project-id',PROJECT,'--name','Test delegate'],cwd=tmp_path,env=env,capture_output=True,text=True,timeout=15) + assert created.returncode==0 + assert 'npk_' not in created.stdout + result=json.loads(created.stdout);path=Path(result['credential_file']) + assert path.stat().st_mode & 0o777 == 0o600 + assert json.loads(path.read_text())['token'].startswith('npk_') + assert not (tmp_path/'.napseer/auth.json').exists() + + +def test_two_agents_bootstrap_one_project_with_general_login(tmp_path,storage): + store=CredentialStore(tmp_path/'.napseer');store.selection.unlink() + first=worker(tmp_path,"m.resolve_project_id({})") + second=worker(tmp_path,"m.resolve_project_id({})") + one,two=outcome(first),outcome(second) + assert one['ok'] and two['ok'] + assert one['result']==two['result'] + assert len(storage['projects'])==1 + assert json.loads(store.user.read_text())['account_mode']=='operator_account' + assert 'project_id' not in json.loads(store.user.read_text()) + assert not store.legacy.exists() + assert (tmp_path/'.napseer/project.json').exists() diff --git a/tests/test_anonymous_auth_recovery.py b/tests/test_anonymous_auth_recovery.py index fd16e50..c54908f 100644 --- a/tests/test_anonymous_auth_recovery.py +++ b/tests/test_anonymous_auth_recovery.py @@ -29,37 +29,18 @@ def anonymous_auth(mod): } -def test_initial_enrollment_persists_refresh_credential(): +def test_unconfigured_agent_requires_user_login_without_enrollment(): mod = load_module() mod.TOKEN = None - mod.AUTH = anonymous_auth(mod) - mod.BASE_URL = mod.AUTH["base_url"] - mod.DEFAULT_PROJECT_ID = None - saved = [] requests = [] - mod.save_auth = lambda payload: saved.append(dict(payload)) - - def request(method, path, payload=None, token_required=False): - requests.append((method, path, payload, token_required)) - return { - "token": { - "access_token": "access", - "refresh_token": "refresh", - "expires_at": "2099-01-01T00:00:00Z", - "refresh_expires_at": "", - }, - "worker": {"id": "worker", "agent_id": "agent-id"}, - } - - mod.request_json = request - mod.ensure_enrolled({"slug": "example"}) - - assert saved[0]["refresh_token"] == "refresh" - assert "refresh_expires_at" in saved[0] - assert [(method, path) for method, path, _, _ in requests] == [ - ("POST", "/v1/enrollment/token") - ] - assert requests[0][3] is False + mod.request_json = lambda *args, **kwargs: requests.append(args) + try: + mod.ensure_enrolled({"slug": "example"}) + except mod.SafeToolError as exc: + assert exc.code == "auth_required" + else: + raise AssertionError("unconfigured agents must not enroll") + assert not requests def test_failed_anonymous_refresh_uses_ssh_recovery(): @@ -107,65 +88,28 @@ def test_failed_anonymous_refresh_uses_ssh_recovery(): assert saved[0]["refresh_token"] == "recovered-refresh" -def test_failed_mixed_state_refresh_uses_ssh_recovery(): +def test_oauth_failure_never_falls_back_to_anonymous_identity(): mod = load_module() - mod.AUTH = { - **anonymous_auth(mod), - "account_mode": "operator_project", - "oauth_client_id": "nap-cli", - "project_id": "project", - "ssh_private_key": "test-private-key", - "ssh_public_key": "ssh-ed25519 test-public-key", - } - mod.BASE_URL = mod.AUTH["base_url"] - mod.DEFAULT_PROJECT_ID = "project" - mod.REFRESH_TOKEN = "stale-refresh" - mod.REFRESH_EXPIRES_AT = None + mod.AUTH = {**anonymous_auth(mod), "account_mode": "operator_project"} + mod.TOKEN = "expired-access" + mod.REFRESH_TOKEN = "revoked-refresh" mod.refresh_public_auth_state = lambda: mod.AUTH - mod.auth_public_key = lambda: "ssh-ed25519 test-public-key" - mod.sign_with_auth_key = lambda *args: "test-signature" - saved = [] - mod.save_auth = lambda payload: saved.append(dict(payload)) - - def request_form(method, path, payload=None, token_required=False): - assert (method, path, token_required) == ("POST", "/v1/oauth/token", False) - raise RuntimeError("HTTP 401") - - def request(method, path, payload=None, token_required=False): - if path == "/v1/enrollment/refresh": - raise RuntimeError("HTTP 401") - if path == "/v1/enrollment/challenges": - return {"challenge_id": "challenge", "challenge_text": "sign-me"} - if path == "/v1/enrollment/verify": - return { - "token": { - "access_token": "recovered-access", - "refresh_token": "recovered-refresh", - "expires_at": "2099-01-01T00:00:00Z", - "refresh_expires_at": "2099-12-31T00:00:00Z", - }, - "worker": { - "id": "worker", - "agent_id": "agent-id", - "name": "agent", - "device_fingerprint": "device", - "root_path": "/repo", - "project_id": "project", - }, - } - raise AssertionError(path) - - mod.request_form_json = request_form - mod.request_json = request - result = mod.renew_auth() - - assert result["method"] == "anonymous_ssh_recovery" - assert result["project_id"] == "project" - assert saved[0]["refresh_token"] == "recovered-refresh" + calls = [] + def rejected(*args, **kwargs): + raise mod.SafeToolError("http_401", "Login rejected", status=401) + mod.request_form_json = rejected + mod.request_json = lambda *args, **kwargs: calls.append(args) + try: + mod.renew_auth() + except mod.SafeToolError as exc: + assert exc.code == "auth_required" + else: + raise AssertionError("OAuth revocation must require user login") + assert not calls if __name__ == "__main__": - test_initial_enrollment_persists_refresh_credential() + test_unconfigured_agent_requires_user_login_without_enrollment() test_failed_anonymous_refresh_uses_ssh_recovery() - test_failed_mixed_state_refresh_uses_ssh_recovery() + test_oauth_failure_never_falls_back_to_anonymous_identity() print("ok: anonymous auth recovery tests passed") diff --git a/tests/test_cli_command_system.py b/tests/test_cli_command_system.py index b426942..2c4264d 100644 --- a/tests/test_cli_command_system.py +++ b/tests/test_cli_command_system.py @@ -3,6 +3,7 @@ import hashlib import json import pathlib import time +import sys import pytest @@ -15,6 +16,8 @@ SCRIPT_PATH = ( def load_installer(): + if str(SCRIPT_PATH.parent) not in sys.path: + sys.path.insert(0, str(SCRIPT_PATH.parent)) spec = importlib.util.spec_from_file_location("nap_install_command_system_test", SCRIPT_PATH) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) @@ -33,7 +36,7 @@ def test_root_help_exposes_only_canonical_commands(capsys): output = capsys.readouterr().out visible = [name for name, metadata in module.COMMAND_METADATA.items() if metadata["visible"]] assert visible == [name for name, _summary in module.CANONICAL_COMMANDS] - assert len(visible) == 11 + assert len(visible) == 13 for name in visible: assert f" {name}" in output for hidden in ("chat", "plan", "lineage", "agent", "feedback", "where", "install"): @@ -436,7 +439,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.2" + assert result["cli_distribution"]["release_version"] == module.CLI_RELEASE_VERSION 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() @@ -493,10 +496,10 @@ def test_manifest_accepts_a_future_compatible_release(): module = load_installer() manifest, _payloads = fake_bundle(module) manifest["release_version"] = "0.2.2" - manifest["contract"]["current"] = "2026-08-02" + manifest["contract"]["current"] = "2026-09-11" for item in manifest["items"]: item["version"] = "0.2.2" - item["contract_version"] = "2026-08-02" + item["contract_version"] = "2026-09-11" items = module.validated_manifest_items(manifest) @@ -506,11 +509,11 @@ def test_manifest_accepts_a_future_compatible_release(): def test_manifest_rejects_a_minimum_contract_newer_than_installer(): module = load_installer() manifest, _payloads = fake_bundle(module) - manifest["contract"]["current"] = "2026-08-02" - manifest["contract"]["minimum_supported"] = "2026-08-02" + manifest["contract"]["current"] = "2026-09-11" + manifest["contract"]["minimum_supported"] = "2026-09-11" for item in manifest["items"]: - item["contract_version"] = "2026-08-02" - item["minimum_contract_version"] = "2026-08-02" + item["contract_version"] = "2026-09-11" + item["minimum_contract_version"] = "2026-09-11" try: module.validated_manifest_items(manifest) @@ -520,6 +523,31 @@ def test_manifest_rejects_a_minimum_contract_newer_than_installer(): raise AssertionError("incompatible minimum contract must be rejected") +def test_legacy_update_preserves_active_runtime_and_bootstrap_recovers(tmp_path, monkeypatch): + module = load_installer() + monkeypatch.setattr(module, "INSTALL_DIR", tmp_path / "install") + monkeypatch.setattr(module, "BIN_DIR", tmp_path / "bin") + manifest, payloads = fake_bundle(module) + previous = module.INSTALL_DIR / "releases" / "previous" + previous.mkdir(parents=True) + (previous / "nap_install.py").write_text("# existing release\n") + (module.INSTALL_DIR / "current").symlink_to(previous, target_is_directory=True) + credentials = module.INSTALL_DIR / "auth.json" + credentials.write_text('{"refresh_token":"synthetic-preserved"}') + monkeypatch.setattr(module, "bundle_manifest", lambda: manifest) + monkeypatch.setattr(module, "fetch_script", lambda name: payloads[name]) + current_contract = module.CLI_DISTRIBUTION_CONTRACT_VERSION + monkeypatch.setattr(module, "CLI_DISTRIBUTION_CONTRACT_VERSION", "2026-08-01") + with pytest.raises(RuntimeError, match="newer bootstrap installer"): + module.main(["nap", "update"]) + assert (module.INSTALL_DIR / "current").resolve() == previous + assert list((module.INSTALL_DIR / "releases").iterdir()) == [previous] + monkeypatch.setattr(module, "CLI_DISTRIBUTION_CONTRACT_VERSION", current_contract) + assert module.main(["nap_install.py", "mcp", "install"]) == 0 + assert (module.INSTALL_DIR / "current" / "napseer_credentials.py").is_file() + assert credentials.read_text() == '{"refresh_token":"synthetic-preserved"}' + + def test_cli_log_compaction_preserves_live_writer_inode(tmp_path): module = load_installer() log_path = tmp_path / "gateway.log" diff --git a/tests/test_live_auth_state_reload.py b/tests/test_live_auth_state_reload.py index f360ad3..ad256fb 100644 --- a/tests/test_live_auth_state_reload.py +++ b/tests/test_live_auth_state_reload.py @@ -55,7 +55,7 @@ def run(): mod.DEFAULT_PROJECT_ID = None assert mod.resolve_project_id({}) == "project-1" - current = {} + current = {"token": "configured-account", "account_mode": "operator_account"} mod.DEFAULT_PROJECT_ID = "stale-project" bootstrap_calls = [] @@ -75,7 +75,7 @@ def run(): } ] - current = {} + current = {"token": "configured-account", "account_mode": "operator_account"} mod.DEFAULT_PROJECT_ID = None def limited_project(_args): diff --git a/tests/test_mcp_memory_ergonomics_helpers.py b/tests/test_mcp_memory_ergonomics_helpers.py index 0e8a31e..b49b1a7 100644 --- a/tests/test_mcp_memory_ergonomics_helpers.py +++ b/tests/test_mcp_memory_ergonomics_helpers.py @@ -283,7 +283,7 @@ def test_node_get_rejects_path_reads(mod): def test_tools_include_node_by_path_descriptor(mod): tool_by_name = {item["name"]: item for item in mod.tools()} - assert len(tool_by_name) == 52 + assert len(tool_by_name) == 58 assert len(tool_by_name) == len(mod.tools()) get_tool = next(item for item in mod.tools() if item["name"] == "nap_node_get") diff --git a/tests/test_mcp_memory_surface_efficiency.py b/tests/test_mcp_memory_surface_efficiency.py index 9fa8100..05e1cc3 100644 --- a/tests/test_mcp_memory_surface_efficiency.py +++ b/tests/test_mcp_memory_surface_efficiency.py @@ -32,9 +32,10 @@ def mod(monkeypatch): return module -def test_default_surface_is_exactly_the_core_fifteen(mod): +def test_default_surface_includes_knowledge_files_and_coordination(mod): assert [tool["name"] for tool in mod.tools()] == [ "nap_apropos", "nap_man", "nap_doctor", "nap_whoami", + "nap_operation_list", "nap_operation", "nap_file_list", "nap_file_upload", "nap_file_download", "nap_file_archive", "nap_discover", "nap_context", "nap_node_by_path", "nap_node_get", "nap_create_node", "nap_node_patch", "nap_bulk", "nap_batch", "nap_ln", "nap_mv", "nap_rm", diff --git a/tests/test_project_attach_oauth.py b/tests/test_project_attach_oauth.py index 0e2c412..ca16d5d 100644 --- a/tests/test_project_attach_oauth.py +++ b/tests/test_project_attach_oauth.py @@ -2,6 +2,7 @@ """Smoke-test existing-project OAuth attachment for folder bootstrap.""" import importlib.util +import json import pathlib import sys import tempfile @@ -59,12 +60,12 @@ def run(): result = mod.operator_project_attach({"open_browser": False}) + saves.insert(0, json.loads(pathlib.Path(result["auth_path"]).read_text())) assert saves[0]["token"] == "oauth-token" assert saves[0]["refresh_token"] == "oauth-refresh-token" assert saves[0]["project_id"] == PROJECT_ID assert saves[0]["account_mode"] == "operator_project" - assert replacements[0] == mod.WORKER_BINDING_AUTH_KEYS - assert "project_id" not in replacements[0] + assert mod.WORKER_BINDING_AUTH_KEYS.isdisjoint(saves[0]) assert "napseer.projects.read" in saves[0]["oauth_scope"] assert saves[1]["project_slug"] == "existing-project" assert result["project_id"] == PROJECT_ID @@ -81,6 +82,7 @@ def run(): "scope": args["scope"], } result = mod.operator_account_login({"open_browser": False}) + saves.insert(0, json.loads(pathlib.Path(result["auth_path"]).read_text())) assert saves[0]["refresh_token"] == "account-refresh-token" assert saves[0]["account_mode"] == "operator_account" assert "project_id" not in saves[0] diff --git a/tests/test_project_create_oauth.py b/tests/test_project_create_oauth.py index fdc51ec..ef86585 100644 --- a/tests/test_project_create_oauth.py +++ b/tests/test_project_create_oauth.py @@ -59,6 +59,7 @@ def test_account_login_requests_create_and_runtime_scopes(): result = mod.operator_account_login({"open_browser": False}) + saves.append(json.loads(pathlib.Path(result["auth_path"]).read_text())) requested = set(captured[0]["scope"].split()) assert "napseer.projects.write" in requested assert set(mod.LOCAL_PROJECT_OAUTH_SCOPE.split()).issubset(requested) @@ -84,7 +85,7 @@ def test_legacy_account_scope_fails_before_http_with_recovery(): assert not requests -def test_successful_create_transitions_local_state_to_project_mode(): +def test_successful_create_preserves_general_account_mode(): mod = load_module() configure_account_state(mod, mod.OPERATOR_ACCOUNT_OAUTH_SCOPE) saves = [] @@ -102,7 +103,8 @@ def test_successful_create_transitions_local_state_to_project_mode(): assert status == "created" assert project["id"] == PROJECT_ID assert saves[0]["project_id"] == PROJECT_ID - assert saves[0]["account_mode"] == "operator_project" + assert "account_mode" not in saves[0] + assert mod.AUTH["account_mode"] == "operator_account" assert locators == [project] @@ -162,7 +164,7 @@ def test_anonymous_limits_are_actionable_without_exposing_response_details(): if __name__ == "__main__": test_account_login_requests_create_and_runtime_scopes() test_legacy_account_scope_fails_before_http_with_recovery() - test_successful_create_transitions_local_state_to_project_mode() + test_successful_create_preserves_general_account_mode() test_server_scope_denial_is_actionable() test_anonymous_limits_are_actionable_without_exposing_response_details() print("ok: project create OAuth lifecycle passed") diff --git a/tests/test_project_locator.py b/tests/test_project_locator.py index 76f7060..9b3e97a 100644 --- a/tests/test_project_locator.py +++ b/tests/test_project_locator.py @@ -169,7 +169,8 @@ def test_attach_forwards_and_verifies_repository_project_hint(): assert oauth_calls[0]["project_id_hint"] == PROJECT_ID assert result["project_id"] == PROJECT_ID assert result["project_locator"]["project_id"] == PROJECT_ID - assert saves[0]["project_id"] == PROJECT_ID + credentials = json.loads(pathlib.Path(result["auth_path"]).read_text()) + assert credentials["project_id"] == PROJECT_ID if __name__ == "__main__": diff --git a/tests/test_user_credentials.py b/tests/test_user_credentials.py new file mode 100644 index 0000000..0a44231 --- /dev/null +++ b/tests/test_user_credentials.py @@ -0,0 +1,284 @@ +"""Real workers and synthetic HTTP provider: user sessions across repositories.""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py" +sys.path.insert(0, str(SCRIPT.parent)) +from napseer_credentials import CredentialStore, atomic_json, read_json + + +@pytest.fixture +def provider(): + state = {"refreshes": 0, "requests": 0, "failure": None, "revoked": False} + mutex = threading.Lock() + state["refresh_started"] = threading.Event() + state["refresh_release"] = threading.Event() + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def reply(self, status, value): + data = json.dumps(value).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + with mutex: + state["requests"] += 1 + if self.headers.get("Authorization") == "Bearer fresh-access" and not state["revoked"]: + self.reply(200, {"ok": True}) + else: + self.reply(401, {"error": {"code": "unauthorized"}}) + + def do_POST(self): + form = parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode()) + if self.path == "/v1/oauth/revoke": + state["revoked"] = True + return self.reply(200, {}) + assert self.path == "/v1/oauth/token", "no enrollment fallback is allowed" + if state["failure"] == "transport": + return self.reply(503, {"error": {"code": "unavailable"}}) + if state["failure"] == "malformed": + return self.reply(200, {"access_token": "incomplete"}) + if form.get("refresh_token") != ["old-refresh"] or state["refreshes"]: + return self.reply(401, {"error": {"code": "invalid_grant"}}) + if state.get("hold_refresh"): + state["refresh_started"].set() + assert state["refresh_release"].wait(5) + with mutex: + state["refreshes"] += 1 + self.reply(200, {"access_token": "fresh-access", "refresh_token": "fresh-refresh", + "expires_in": 3600, "refresh_expires_at": "2099-01-01T00:00:00Z"}) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", state + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def seed(root, url): + root.mkdir(exist_ok=True) + store = CredentialStore(root / ".napseer") + store.login({"base_url": url, "token": "old-access", "refresh_token": "old-refresh", + "account_mode": "operator_account", "oauth_client_id": "nap-cli"}) + return store + + +def worker(root, expression): + code = "import importlib.util, json, sys\n" + ( + "spec = importlib.util.spec_from_file_location('worker', sys.argv[1])\n" + "m = importlib.util.module_from_spec(spec)\nspec.loader.exec_module(m)\n" + "try:\n result = " + expression + "\n print(json.dumps({'ok': True, 'result': result}))\n" + "except Exception as exc:\n print(json.dumps({'ok': False, 'code': getattr(exc, 'code', type(exc).__name__)}))\n" + ) + env = dict(os.environ, NAPSEER_PROJECT_ROOT=str(root), NAPSEER_TELEMETRY="0") + for key in ("NAPSEER_AUTH_FILE", "NAPSEER_BASE_URL", "NAPSEER_PROJECT_ID"): + env.pop(key, None) + return subprocess.Popen([sys.executable, "-c", code, str(SCRIPT)], cwd=root, env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + +def outcome(process): + out, _err = process.communicate(timeout=15) + assert process.returncode == 0, "worker must exit without exposing credential state" + return json.loads(out) + + +def test_shared_login_refreshes_once_across_real_processes_and_survives_restart(tmp_path, provider): + url, state = provider + first, second = tmp_path / "first", tmp_path / "second" + store = seed(first, url) + second.mkdir() + processes = [worker(root, "m.request_json('GET', '/v1/projects')") for root in [first, second]] + assert all(outcome(p)["ok"] for p in processes) + assert state["refreshes"] == 1 + assert outcome(worker(second, "m.request_json('GET', '/v1/projects')"))["ok"] + assert state["refreshes"] == 1 + assert store.user.stat().st_mode & 0o777 == 0o600 + assert not (first / ".napseer/auth.json").exists() + assert not (second / ".napseer/auth.json").exists() + + +@pytest.mark.parametrize("failure", ["transport", "malformed"]) +def test_refresh_failure_preserves_credentials_and_recovers(tmp_path, provider, failure): + url, state = provider + store = seed(tmp_path, url) + before = store.user.read_bytes() + state["failure"] = failure + assert not outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"] + assert store.user.read_bytes() == before + state["failure"] = None + assert outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"] + + +def test_project_override_does_not_fall_back_to_user_authority(tmp_path, provider): + url, state = provider + store = seed(tmp_path, url) + store.login({"base_url": url, "token": "npk_expired", "account_mode": "api_key"}, project=True) + result = outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')")) + assert not result["ok"] + assert state["refreshes"] == 0 + assert read_json(store.user)["token"] == "old-access" + store.logout(project=True) + assert not outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"] + assert state["refreshes"] == 0 + + +def test_unconfigured_agent_never_enrolls_or_creates_project(tmp_path): + result = outcome(worker(tmp_path, "m.resolve_project_id({})")) + assert result == {"ok": False, "code": "auth_required"} + assert not (tmp_path / ".napseer").exists() + + +def test_logout_revokes_and_next_worker_observes_tombstone(tmp_path, provider): + url, state = provider + store = seed(tmp_path, url) + assert outcome(worker(tmp_path, "m.operator_logout({})"))["ok"] + assert state["revoked"] + assert read_json(store.user)["logged_out"] + assert not outcome(worker(tmp_path, "m.request_json('GET', '/v1/projects')"))["ok"] + assert state["requests"] == 0 + + +def test_global_credentials_keep_repository_selections_independent(tmp_path, provider): + url, _ = provider + first = tmp_path / "first" + store = seed(first, url) + other = CredentialStore(tmp_path / "second/.napseer") + store.write({**store.read(), "project_id": "first"}) + other.write({**other.read(), "project_id": "second"}) + assert "project_id" not in read_json(store.user) + assert store.read()["project_id"] == "first" + assert other.read()["project_id"] == "second" + + +def test_logout_waits_for_inflight_rotation_and_cannot_be_undone(tmp_path, provider): + url,state=provider + store=seed(tmp_path,url) + state["hold_refresh"]=True + refresh=worker(tmp_path,"m.request_json('GET','/v1/projects')") + assert state["refresh_started"].wait(5) + logout=worker(tmp_path,"m.operator_logout({})") + state["refresh_release"].set() + outcome(refresh) + assert outcome(logout)["ok"] + assert state["revoked"] + assert read_json(store.user).get("logged_out") + assert not outcome(worker(tmp_path,"m.request_json('GET','/v1/projects')"))["ok"] + + +def test_refresh_pins_credential_source_when_override_appears(tmp_path, provider): + url,state=provider + store=seed(tmp_path,url) + state["hold_refresh"]=True + refresh=worker(tmp_path,"m.request_json('GET','/v1/projects')") + assert state["refresh_started"].wait(5) + override={"base_url":url,"token":"npk_project_test","account_mode":"api_key"} + store.login(override,project=True) + state["refresh_release"].set() + assert not outcome(refresh)["ok"], "retry observes the explicit override" + assert read_json(store.project)==override + assert read_json(store.user)["token"]=="fresh-access" + + +def test_legacy_migration_preserves_authority_and_general_selection_is_explicit(tmp_path,provider): + url,_=provider + store=seed(tmp_path,url) + legacy={"base_url":url,"token":"legacy-project","account_mode":"operator_project","project_id":"test-project"} + atomic_json(store.legacy,legacy) + assert store.selected()[1]=="legacy_project" + store.migrate() + assert not store.legacy.exists() + assert store.read()["token"]=="legacy-project" + store.use_user() + assert store.read()["token"]=="old-access" + assert read_json(store.project)==legacy + + +def test_failed_login_initialization_releases_actual_loopback_socket(tmp_path,monkeypatch): + import socket + spec=importlib.util.spec_from_file_location("cleanup_worker",SCRIPT) + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + reserved=socket.socket();reserved.bind(("127.0.0.1",0));port=reserved.getsockname()[1];reserved.close() + monkeypatch.setattr(module,"request_json",lambda *a,**k: (_ for _ in ()).throw(RuntimeError("synthetic unavailable"))) + with pytest.raises(RuntimeError): + module.oauth_loopback_authorize({"scope":"test","flow":"operator_login","port":port,"open_browser":False}) + probe=socket.socket() + try: probe.bind(("127.0.0.1",port)) + finally: probe.close() + + +def test_native_login_uses_requested_service_pkce_callback_and_shared_store(tmp_path,monkeypatch): + import base64 + import hashlib + import urllib.request + from urllib.parse import urlsplit, urlencode + state={} + class Handler(BaseHTTPRequestHandler): + def log_message(self,*_args): pass + def do_POST(self): + raw=self.rfile.read(int(self.headers['Content-Length'])) + if self.path.endswith('/authorizations'): + state['authorization']=json.loads(raw) + payload={"authorization_url":"http://synthetic-browser.invalid/consent"} + else: + form=parse_qs(raw.decode());state['exchanged']=True + expected=base64.urlsafe_b64encode(hashlib.sha256(form['code_verifier'][0].encode()).digest()).decode().rstrip('=') + assert expected==state['authorization']['code_challenge'] + assert form['code']==['synthetic-one-time-code'] + payload={"access_token":"synthetic-native-access","refresh_token":"synthetic-native-refresh","expires_in":3600} + body=json.dumps(payload).encode();self.send_response(200);self.end_headers();self.wfile.write(body) + server=ThreadingHTTPServer(('127.0.0.1',0),Handler) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + spec=importlib.util.spec_from_file_location('native_login_worker',SCRIPT) + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + monkeypatch.setattr(module,'AUTH_DIR',tmp_path/'.napseer') + monkeypatch.setattr(module,'AUTH_PATH',tmp_path/'.napseer/auth.json') + monkeypatch.setattr(module,'BASE_URL','http://127.0.0.1:1') + store = CredentialStore(tmp_path/'.napseer') + store.user.parent.mkdir(parents=True,exist_ok=True) + store.user.write_text('{broken synthetic state') + def consent(_url): + grant=state['authorization'] + callback=grant['redirect_uri']+'?'+urlencode({'code':'synthetic-one-time-code','state':grant['state']}) + with urllib.request.urlopen(callback,timeout=3) as response: assert response.status==200 + return True + monkeypatch.setattr(module.webbrowser,'open',consent) + try: + result=module.operator_account_login({'base_url':f'http://127.0.0.1:{server.server_port}','timeout_seconds':5}) + assert result['status']=='authenticated';assert state['exchanged'] + store=CredentialStore(tmp_path/'.napseer') + assert store.user.exists();assert not store.legacy.exists() + assert read_json(store.user)['base_url']==f'http://127.0.0.1:{server.server_port}' + finally:server.shutdown();server.server_close();thread.join(timeout=2) + + +def test_corrupt_credentials_allow_help_but_never_authenticated_fallback(tmp_path): + store=CredentialStore(tmp_path/'.napseer') + store.user.parent.mkdir(parents=True,exist_ok=True) + store.user.write_text('{broken synthetic state') + result=outcome(worker(tmp_path,"m.request_json('GET','/v1/projects')")) + assert result['code']=='CredentialError' + assert store.user.read_text()=='{broken synthetic state' + env=dict(os.environ,NAPSEER_PROJECT_ROOT=str(tmp_path)) + help_result=subprocess.run([sys.executable,str(SCRIPT),'auth','--help'],cwd=tmp_path,env=env,capture_output=True,text=True,timeout=10) + assert help_result.returncode==0 + assert 'nap auth login' in help_result.stdout