From 9bce54ac0af099c51a4accafc1a32675843f3bdb Mon Sep 17 00:00:00 2001 From: HumbleTurtle Date: Wed, 20 May 2026 22:36:33 -0300 Subject: [PATCH] Add project-scoped OAuth attach flow --- resources/scripts/nap_install.py | 4 +- resources/scripts/napseer_mcp_server.py | 46 ++++++++++++++++++-- scripts/nap_install.py | 4 +- tests/test_project_attach_oauth.py | 58 +++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 tests/test_project_attach_oauth.py diff --git a/resources/scripts/nap_install.py b/resources/scripts/nap_install.py index a551418..57facc4 100644 --- a/resources/scripts/nap_install.py +++ b/resources/scripts/nap_install.py @@ -683,8 +683,8 @@ def main(argv): print("Usage: nap [auth|project|chat|plan|lineage|gateway|agent|feedback|status|update|where|install]") print(" auth Auth commands: login-local, status.") print(" Example: nap auth login-local") - print(" project Project commands: create, status, encryption.") - print(" Examples: nap project create, nap project status, nap project encryption status") + print(" project Project commands: init/attach existing, create, status, encryption.") + print(" Examples: nap project init, nap project create, nap project status") print(" Encrypted project setup is completed by the local gateway: nap gateway vault process") print(" chat Chat secret commands: secret status, secret setup, secret rotate.") print(" Examples: nap chat secret setup --vault-passphrase TEXT, nap chat secret status") diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 6570935..5456e33 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -6593,29 +6593,58 @@ def oauth_loopback_authorize(args): return token +LOCAL_PROJECT_OAUTH_SCOPE = ( + "openid profile email " + "napseer.projects.read " + "napseer.nodes.read napseer.nodes.write " + "napseer.locks.write " + "napseer.schedules.read napseer.schedules.write " + "napseer.agents.read napseer.agents.write " + "napseer.service_registrations.read napseer.service_registrations.write " + "napseer.encryption.read napseer.vault.read " + "napseer.chat.read napseer.chat.write " + "napseer.gateway.read napseer.gateway.connect" +) + + def operator_loopback_login(args=None): args = args or {} result = oauth_loopback_authorize({ **args, "flow": "local_operator_access", - "scope": "openid profile email napseer.feedback.read", + "scope": args.get("scope") or LOCAL_PROJECT_OAUTH_SCOPE, }) + project_id = result.get("project_id") + if not project_id: + raise RuntimeError("OAuth authorization did not return a selected project_id") operator_auth = { "base_url": result["api_base_url"], "token": result["access_token"], "token_expires_at": iso_timestamp_after_seconds(int(result.get("expires_in") or 0)), - "account_mode": "operator", + "project_id": project_id, + "account_mode": "operator_project", "oauth_client_id": "nap-cli", "oauth_scope": result.get("scope"), } save_auth_file_credentials(operator_auth) + try: + project = request_json("GET", f"/v1/projects/{project_id}") + save_auth_file_credentials({ + "project_slug": project.get("slug"), + "project_name": project.get("name"), + "project_encryption_state": project.get("encryption_state"), + }) + except Exception: + project = None return { "status": "authenticated", "auth_path": str(AUTH_PATH), "base_url": result["api_base_url"], + "project_id": project_id, + "project": project, "scope": result.get("scope"), "token_expires_at": operator_auth["token_expires_at"], - "message": "OAuth2 PKCE loopback token stored in the repo-local Napseer auth state.", + "message": "OAuth2 PKCE loopback token and selected project stored in this folder's Napseer auth state.", } @@ -12275,6 +12304,15 @@ def cli_main(argv): return if command == "project": subcommand = argv[2] if len(argv) > 2 else "help" + if subcommand in {"init", "attach", "bootstrap-existing"}: + print(json.dumps(operator_loopback_login({ + "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"), + "timeout_seconds": int(cli_option(argv[3:], "--timeout", "--timeout-seconds", default="300")), + "port": cli_option(argv[3:], "--port", default=0), + }), indent=2)) + return if subcommand == "create": print(json.dumps(cli_project_create(argv[3:]), indent=2)) return @@ -12302,7 +12340,7 @@ def cli_main(argv): "passphrase": cli_option(argv[4:], "--passphrase", default=None), }), indent=2)) return - print("Usage: napseer_mcp_server.py project [create [slug] [--name NAME] [--description TEXT] [--encryption plaintext]|claim [--no-browser] [--timeout SECONDS] [--return-url URL]|status|encryption [status|set plaintext|plaintext]]") + print("Usage: napseer_mcp_server.py project [init|attach [--no-browser] [--timeout SECONDS]|create [slug] [--name NAME] [--description TEXT] [--encryption plaintext]|claim [--no-browser] [--timeout SECONDS] [--return-url URL]|status|encryption [status|set plaintext|plaintext]]") return if command == "lineage": subcommand = argv[2] if len(argv) > 2 else "status" diff --git a/scripts/nap_install.py b/scripts/nap_install.py index a551418..57facc4 100644 --- a/scripts/nap_install.py +++ b/scripts/nap_install.py @@ -683,8 +683,8 @@ def main(argv): print("Usage: nap [auth|project|chat|plan|lineage|gateway|agent|feedback|status|update|where|install]") print(" auth Auth commands: login-local, status.") print(" Example: nap auth login-local") - print(" project Project commands: create, status, encryption.") - print(" Examples: nap project create, nap project status, nap project encryption status") + print(" project Project commands: init/attach existing, create, status, encryption.") + print(" Examples: nap project init, nap project create, nap project status") print(" Encrypted project setup is completed by the local gateway: nap gateway vault process") print(" chat Chat secret commands: secret status, secret setup, secret rotate.") print(" Examples: nap chat secret setup --vault-passphrase TEXT, nap chat secret status") diff --git a/tests/test_project_attach_oauth.py b/tests/test_project_attach_oauth.py new file mode 100644 index 0000000..2af4400 --- /dev/null +++ b/tests/test_project_attach_oauth.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Smoke-test existing-project OAuth attachment for folder bootstrap.""" + +import importlib.util +import pathlib +import sys + + +PROJECT_ID = "11111111-1111-1111-1111-111111111111" + + +def load_module(): + script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py" + sys.path.insert(0, str(script_path.parent)) + spec = importlib.util.spec_from_file_location("napseer_mcp_server_project_attach_test", script_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +def run(): + mod = load_module() + saves = [] + + def save_auth(updates): + saves.append(dict(updates)) + + mod.oauth_loopback_authorize = lambda args: { + "api_base_url": "https://api.example.test", + "access_token": "oauth-token", + "expires_in": 3600, + "scope": args["scope"], + "project_id": PROJECT_ID, + } + mod.save_auth_file_credentials = save_auth + mod.request_json = lambda method, path: { + "id": PROJECT_ID, + "slug": "existing-project", + "name": "Existing Project", + "encryption_state": "plaintext", + } + + result = mod.operator_loopback_login({"open_browser": False}) + + assert saves[0]["token"] == "oauth-token" + assert saves[0]["project_id"] == PROJECT_ID + assert saves[0]["account_mode"] == "operator_project" + assert "napseer.projects.read" in saves[0]["oauth_scope"] + assert saves[1]["project_slug"] == "existing-project" + assert result["project_id"] == PROJECT_ID + assert result["project"]["name"] == "Existing Project" + + print("ok: project attach OAuth smoke passed") + + +if __name__ == "__main__": + run()